SDK at a glance
This page is the compact reference: one place to look up a signature, a default or a limit. It is maintained by hand next to the generated SDK reference; if the two ever disagree, the reference wins.
It does not teach. For when and why to use each member, and for what happens in which order, read the plugin guide (start with its lifecycle), Values and Testing. Each section below links to the matching part of the guide. To learn by building, follow the tutorial.
Imports: @lox-nexus/plugin-sdk (classes), @lox-nexus/plugin-sdk/values, @lox-nexus/plugin-sdk/testing, @lox-nexus/plugin-sdk/wire.
@lox-nexus/plugin-sdk
Plugin (reference)
abstract class Plugin<Config = unknown> extends Disposable {
constructor(protected readonly context: PluginContext)
get config(): Config
get log(): PluginLogger
get logLevel(): PluginLogLevel // { current, onChange(listener) }
get store(): PluginStore
get status(): PluginStatus
get http(): PluginHttp
get slug(): string | undefined
get timeZone(): string // IANA, e.g. "Europe/Vienna"
get clock(): Clock // now(), and the clock every()/after() run on
start(): void | Promise<void>
catalog(): CatalogNode[] | Promise<CatalogNode[]>
panel(): Panel | undefined | Promise<Panel | undefined>
panelChanged(): void // tell open pages the panel changed (at most once a second)
unhandledRejection(reason: unknown): void
get watch(): WatchView // the chosen signals (watching plugins)
watched?(values: readonly WatchedValue[], dropped: number): void | Promise<void>
watchChanged(): void
}
| Member | Notes |
|---|---|
start() |
Reject only for what a restart can't fix (bad credentials): the instance goes to the error state and is not restarted. For an unreachable device resolve and call status.set("degraded", ...). |
catalog() |
Asked on demand by the UI; may query the device; a throw is shown to the user. Default []. Answers (catalog, panel, webhook responses) over 16 MiB as JSON fail instead. |
panel() |
Asked every 3 s while the page is open; with static panelUpdates = "push" only when panelChanged() says so and every 30 s. undefined shows nothing. Never put secrets in it. See Panel. |
panelChanged() |
Tells open pages the panel changed (a device paired, a poll finished). Reaches the host at most once a second; does nothing on older hosts. |
unhandledRejection(reason) |
Called for a promise in the worker that was rejected without a handler (a forgotten .catch). The worker keeps running; the default logs an error with the stack. Override to say more or to set a degraded status; it must not throw. PluginTestHost.unhandledRejection(reason) calls it in tests. |
config |
Already validated against the static config schema, defaults applied. Available in field initializers of subclasses. |
slug |
The instance's slug: its topic segment and webhook path (/hook/<slug>/…). Undefined with hosts that don't send it. Fixed for one run (a slug change restarts the instance). |
clock |
this.clock.now() instead of Date.now(); every() and after() run on it. Also on sensors and actuators. See Time. |
logLevel |
current is "debug" while the user switched on debug logging for the instance (for 15 minutes) or the server's own log is at debug, "info" otherwise; onChange(listener) returns the unsubscribe. Lines below it are dropped in the worker. Use it to raise a library's own verbosity; plain log.debug() needs nothing. "debug" for good with older hosts. |
timeZone |
The installation's IANA time zone, for anything that depends on the local time. Nexus takes it from the Loxone project it read last; without one (and with older hosts) it is the server's zone, which in a container is often UTC. Fixed for one run. |
Static declarations on the subclass (PluginClass); all optional:
static readonly config?: z.ZodType // default: no config (empty object)
static readonly sensors?: readonly SensorClass[] // ids unique among sensors
static readonly actuators?: readonly ActuatorClass[] // ids unique among actuators
static readonly watches?: { label: string; description?: string } // receives values of signals the user chooses
static readonly configVersion?: number // the config's shape; default 1, raise it with migrateConfig
static migrateConfig?({ config, from, store }: ConfigMigration): unknown // older config → current shape
static test?(config): Promise<string | void> // Test connection; read only, throw a message the user can act on
static readonly actions?: readonly ActionClass[] // see Action
static readonly panelUpdates?: "push" // the plugin calls panelChanged(); pages then ask for the panel when told, else every 30 s
static discover?(field: string, config: unknown): Promise<Discovered> // Find for discoverable() fields; read only, at most 15 s
discover runs in a throwaway worker, like test, for a config field marked with
discoverable(); config is the form as filled in so far, unvalidated. It answers up to 100
{ label, value, detail? }: value is what the field gets when the user picks it. Use
Mdns.browse for devices that announce themselves. See
Find on the network.
A watching plugin implements watched(values, dropped) (batches, oldest first; the next comes
when it returns) and optionally watchChanged(); this.watch.signals() / signal(id) describe
the chosen signals. See Watching signals.
type WatchDeclaration = { label: string; description?: string }
interface WatchView { signals(): WatchedSignal[]; signal(id: string): WatchedSignal | undefined }
type WatchedSignal = { id: string; topic: string; label: string; valueType: ValueType | "any"; owner?: { kind: "miniserver" | "plugin"; id: string }; unit?: string; decimals?: number }
type WatchedValue = { signalId: string; ts: number; kind: "state" | "command"; value: Value; forced?: true; initial?: true }
The guide explains each of these: the plugin class, starting and failing, catalog, panel.
Sensor (reference)
abstract class Sensor<P extends Plugin = Plugin, Params = unknown> extends Disposable {
constructor(readonly plugin: P, readonly params: Params, private readonly output: SensorOutput, readonly signal: SignalInfo = { id: "", label: "" })
start(): void | Promise<void>
protected emit(value: Value): void
protected fail(message: string): void
protected recover(): void
}
| Member | Notes |
|---|---|
start() |
A rejection makes adding the signal fail and disposes the sensor. |
emit(value) |
Sends a value to the bus; equal consecutive values are deduped there. No-op after dispose. |
fail(message) |
The sensor can't deliver any more; shown on the signal until the next emit() or recover(). |
recover() |
The failure is over although there is no value to send (an entity back as "unavailable"). Only tells the host after a fail(), so it is cheap to call on every change. |
signal |
{ id, label } of the configured signal this sensor is; label "" with hosts that don't send it. |
Static declarations (SensorClass):
static readonly id: string // required; stored with the signal, never rename
static readonly label?: string // default: id
static readonly description?: string // one sentence the UI shows when adding it
static readonly params?: z.ZodType // default: empty object; validated before construction
static readonly valueType?: ValueType | "any" // what emit() sends; default "any"
static readonly unit?: string // suggested for new signals, e.g. "W" (up to 16 characters)
static readonly decimals?: number // suggested for new signals: decimals to show (0-6)
static readonly staleAfterMs?: number // suggested for new signals: warn after this long without a value
SensorOutput: { emit(value: Value): void; fail(message: string): void; recover?(): void }, the runtime's side, not used by plugin code.
In the guide: Sensors.
Actuator (reference)
abstract class Actuator<P extends Plugin = Plugin, Params = unknown, V extends Value = Value> extends Disposable {
constructor(readonly plugin: P, readonly params: Params, readonly signal: SignalInfo = { id: "", label: "" })
start(): void | Promise<void>
abstract run(value: V, command?: ActuatorCommand): void | Promise<void>
protected fail(message: string): void
protected recover(): void
}
interface ActuatorCommand { readonly replayed: boolean }
| Member | Notes |
|---|---|
run(value, command) |
Throwing or rejecting reports a failed result. Emits and logs made while it runs (also from timers and promises it started) carry the command's cause. Never overlaps itself. command.replayed is true when the host delivers a value again (an active force after the plugin started), not a new command: an actuator that restarts a device or opens a door should ignore it. Older hosts never set it. |
start() |
A rejection makes adding the signal fail. |
fail(message) |
The actuator can't work at the moment (its device is gone); shown on the signal until recover(). Commands still reach run(). |
recover() |
Ends a failure. Only tells the host after a fail(), so it is cheap to call on every change. |
Static declarations (ActuatorClass):
static readonly id: string // required; stored with the signal, never rename
static readonly label?: string // default: id
static readonly description?: string // one sentence the UI shows when adding it
static readonly params?: z.ZodType // default: empty object
static readonly accepts?: ValueType | "any" // wires convert to this before run(); default "any"
static readonly concurrency?: ActuatorConcurrency // "queue" (default) | "latest"
static readonly unit?: string // suggested for new signals, e.g. "%"
static readonly decimals?: number // suggested for new signals (0-6)
"latest": while a command runs, only the newest waiting one runs; the replaced ones are reported as superseded (SupersededError, thrown by the runtime, not by plugins).
In the guide: Actuators.
Action (reference)
Something a user starts from the instance page with a small form: pair a device, open a pairing
window, remove a paired controller. Declared in the plugin's static actions; the page shows a
button per action at the top, and a panel table's rowActions open one with its input filled in.
static placement = "panel" takes it off the top: its buttons are then only where the panel puts
them, in a section's heading (actions) or on a table row. An input that is a
z.discriminatedUnion shows as a choice of its discriminator and the chosen branch's fields.
| Member | Notes |
|---|---|
run() |
Returns an ActionResult: the text shown to the user (clipped to 2000 characters), { message?, panel? } with a panel part under it (a QR code, tiles, a notice), or nothing ("Done."). Older hosts show only the message. A throw is shown as the failure, and the form stays filled in. Runs with a 2-minute timeout; one action at a time per instance (a second is refused while one runs). Disposed when it settles or the plugin stops. |
progress(text, fraction?) |
Protected. Shows how a long action gets on in its dialog ("Searching…", 0.4 for 40 %). At most four a second reach the host, the latest always; nothing on older hosts. Not logged. |
input |
Validated against the static input schema, defaults applied. May hold secrets such as pairing codes: never log it. |
plugin |
The plugin instance. |
Static declarations (ActionClass):
static readonly id: string // required; unique among the plugin's actions
static readonly label?: string // the button's text; default: id
static readonly description?: string // one sentence in the dialog
static readonly input?: z.ZodType // the form; default: no input
static readonly confirm?: string // asked before it runs, for actions that undo something
static readonly placement?: "toolbar" | "panel" // "panel": no button at the top, only where the panel puts it
Actions run only while the instance runs or is degraded; a reverse proxy in front of Nexus should allow requests of up to two minutes, or a slow pairing shows as a timeout although it worked.
Disposable (reference)
abstract class Disposable implements DisposableLike {
static readonly MAX_TIMER_MS: 2_147_483_647
get isDisposed(): boolean
dispose(): Promise<void>
protected onDispose(teardown: Teardown): void
protected own<T extends DisposableLike>(resource: T): T
protected useClock(clock: Clock | undefined): void
protected currentClock(): Clock
protected every(ms: number, fn: () => void): () => void
protected after(ms: number, fn: () => void): () => void
protected listen(emitter: Listenable, event: string | symbol, listener: Listener): void
}
| Method | Notes |
|---|---|
dispose() |
Teardowns run in reverse registration order; idempotent. Throws AggregateError after all ran if any failed. |
onDispose(teardown) |
Throws Error if already disposed. |
useClock(clock) |
Sets the clock every() and after() run on, unless one is set; timers already running keep theirs. Plugin, Sensor and Actuator set it from the plugin's context. |
currentClock() |
That clock (the system clock if none is set). A plain Disposable reads the time from it; plugins, sensors and actuators have this.clock. |
own(resource) |
Disposes resource with this object; returns it. A Disposable child without a clock inherits this one's. |
every(ms, fn) |
Returns a cancel function. Throws RangeError unless 0 < ms <= MAX_TIMER_MS. |
after(ms, fn) |
Returns a cancel function. Throws RangeError for negative or non-finite ms; long delays are stepped. Firing or cancelling releases its teardown, so calling it again and again (a restarted pulse) doesn't pile anything up. |
listen(emitter, event, listener) |
Subscribes until disposed. |
Related types: Clock { now(); setTimeout(fn, ms); clearTimeout(handle); setInterval(fn, ms); clearInterval(handle) } (SystemClock.instance in production, TestClock from /testing in tests), Teardown = () => void | Promise<void>, DisposableLike { dispose(): void | Promise<void> }, Listenable { on(event, listener): unknown; off(event, listener): unknown } (an EventEmitter fits), Listener = (...args: any[]) => void.
In the guide: Lifetimes.
Context objects
/** which configured signal a sensor or actuator is; label "" with hosts that don't send it */
interface SignalInfo { id: string; label: string }
interface PluginContext {
readonly instanceId: string
readonly slug?: string
readonly timeZone?: string
readonly config: unknown
readonly log: PluginLogger
readonly store: PluginStore
readonly status: PluginStatus
readonly http: PluginHttp
}
type PluginLogger = Record<"debug" | "info" | "warn" | "error", (message: string, data?: unknown) => void>
interface PluginStore {
get<T = unknown>(key: string): T | undefined
set(key: string, value: unknown): void
delete(key: string): void
keys(): string[]
}
interface PluginStatus { set(status: "running" | "degraded", message?: string): void }
interface PluginHttp { route(method: string, path: string, handler: HttpHandler): void }
type HttpHandler = (request: HttpRequest) => HttpResponse | Promise<HttpResponse>
| Item | Notes |
|---|---|
store.get |
The cast to T is not checked; validate data an older version wrote. |
store.set |
Value must be JSON; BigInt and cycles throw Error; keys are 1 to 200 characters, values at most 1 MiB as JSON (else Error). |
status.set |
message is clipped to 10 000 characters; "running" clears "degraded". |
http.route |
Reachable at /hook/<instance slug><path> without a login: check a secret yourself. Method "*" matches any method; a path ending in /* matches everything below that prefix ("/a/*" matches /a/ and /a/b, not /a; "/*" all). The most specific route wins: exact before prefix, longer prefix first, a named method before "*". Throws Error on a duplicate method + path. A throwing handler gives the caller 502. |
log.* |
data is structured-cloned; more than 64 KiB as JSON is replaced by a note with its size. Never log secrets. |
In the guide: Logging, store and status and Webhooks. Every number (timeouts, sizes) is in Limits at a glance.
Entry, config and re-exports
| Export | Signature | Notes |
|---|---|---|
PluginEntry |
static define<T extends PluginClass>(type: T): T |
export default PluginEntry.define(MyPlugin) in src/index.ts. Starts the worker runtime only when loaded by Nexus. |
secret |
secret<T extends z.ZodType>(schema: T): T |
Marks a field writeOnly: never sent back to the UI, kept when an update omits it. Top-level fields only. |
code |
code<T extends z.ZodType>(schema: T, language?: CodeLanguage): T |
Marks a string field as source code (format: "code" plus the language's contentMediaType), so the UI edits it in a code editor. CodeLanguage is "javascript" (default), "json" or "text". Validating the code stays the plugin's job. |
token |
token<T extends z.ZodType>(schema: T): T |
Marks a string field as a random token (format: "token"), so the form shows a Generate button that fills in 48 hex characters. Leave it visible for copying, or add secret() to hide it. |
signalName |
signalName<T extends z.ZodType>(schema: T): T |
Marks the top-level param of a sensor or actuator whose value names the signal (signalName: true), e.g. a hook or variable name: the form names a new signal, and so its topic, after it until the user names it. |
signalGroup |
signalGroup<T extends z.ZodType>(schema: T): T |
Marks the top-level param of a sensor or actuator that names the device the signal belongs to (signalGroup: true), e.g. a Home Assistant entity: signal lists show the signals with the same value under one collapsible header. Several marked params form a path ("node ▸ endpoint"). Only the lists use it. |
z |
zod | Use this one, not your own copy. |
SupersededError |
class extends Error |
See concurrency: "latest". |
CatalogNode, CatalogEntry, Panel, PanelPart, RowAction, Cell, Tone, Discovered, HttpRequest, HttpResponse |
types, see wire | Return values of catalog(), panel(), discover and webhook handlers. |
Action, ActionClass, ActionResult |
class, types | See Action. |
ActuatorCommand |
type | What run() learns besides the value: replayed. |
duration |
duration(options?: { unit?: "ms" | "s" | "min" | "h"; min?; max? }) |
A number of units (default seconds); the form offers seconds, minutes and hours and stores the field's unit. |
timeOfDay |
timeOfDay() |
"HH:MM", checked; a time picker. |
color |
color() |
"#rrggbb", checked; a colour picker with the hex next to it. |
slider |
slider(min, max, step = 1) |
A number in a range, shown as a slider with the number. |
file |
file(options?: { accept?; maxBytes?; encoding?: "text" | "base64" }) |
A picked file's contents (text, or base64 for binary), at most maxBytes (default 256 KiB). |
discoverable |
discoverable<T extends z.ZodType>(schema: T): T |
Marks a top-level config field for Find: the form runs static discover for it and offers what it found. |
Mdns, MdnsOptions, MdnsService |
static browse(type: string, options?: MdnsOptions): Promise<MdnsService[]> |
Finds services that announce themselves with mDNS ("_home-assistant._tcp.local"): name, host, port, addresses, TXT. Asks once from a random port and listens for timeoutMs (default 3000); needs no daemon or port 5353. For discover. |
@lox-nexus/plugin-sdk/values
In the guide: Values.
Value (reference)
type Value =
| { type: "null" }
| { type: "boolean"; value: boolean }
| { type: "number"; value: number }
| { type: "string"; value: string }
| { type: "singleChannel"; value: SingleChannel }
| { type: "rgbw"; value: Rgbw }
| { type: "tunableWhite"; value: TunableWhite }
| { type: "t5"; value: T5 }
type ValueType = Value["type"]
type ValueOf<T extends ValueType> = Extract<Value, { type: T }>
interface SingleChannel { channel: number; fadeTime: number }
interface Rgbw { red: number; green: number; blue: number; white: number; fadeTime: number; flags: number }
interface TunableWhite { temperature: number; brightness: number; fadeTime: number }
interface T5 { buttons: number }
Units: channels and brightness 0-100 (whole percent), fadeTime seconds, temperature Kelvin (conversions keep 2700-6500), flags raw bits (experimental), buttons bitmask 0-31 (experimental).
Value is also an object (an instance of ValueFactory):
Value.null(): ValueOf<"null">
Value.boolean(value: boolean): ValueOf<"boolean">
Value.number(value: number): ValueOf<"number">
Value.string(value: string): ValueOf<"string">
Value.singleChannel(value: SingleChannel): ValueOf<"singleChannel">
Value.rgbw(value: Rgbw): ValueOf<"rgbw">
Value.tunableWhite(value: TunableWhite): ValueOf<"tunableWhite">
Value.t5(value: T5): ValueOf<"t5">
Value.isType(type: string): type is ValueType
Value.equals(a: Value | undefined, b: Value | undefined): boolean
Value.types: readonly ValueType[]
| Gotcha | |
|---|---|
| Structured factories | Round to whole percent (clamped 0-100) and 0.1 s fades; t5 masks outside 0-31 become 0. They don't validate. |
equals |
Structural; NaN equals NaN, 0 equals -0. |
Conversion and helpers (reference)
| Export | Signature | Notes |
|---|---|---|
Converter |
static convert<T extends ValueType>(value: Value, to: T): ValueOf<T> |
Never throws; impossible conversions give the target's default. Same-type input is returned as is. |
ValueDefaults |
static for<T extends ValueType>(type: T): ValueOf<T>; FADE_TIME = 0.2 (s); TEMPERATURE = 4000 (K) |
A fresh default value per call. |
ValueSchema |
z.ZodType<Value> |
Validates untrusted values; accepts only what a Miniserver can carry. |
ValueTypeSchema |
z.enum([...ValueType]) |
|
ValueFormatter |
new ValueFormatter().format(value: Value | undefined): string |
Display only, lossy; never parse it. Strings come out quoted ("eco"). |
StringToNumber |
new StringToNumber().parse(input: string): number |
Lenient: "20%" gives 0.2, "1.234,5" gives 1234.5, unparseable gives 0. |
T5Buttons |
new T5Buttons(mask); isPressed(button): boolean; names(): string[]; toString(): string; constants LEFT_UPPER=1 RIGHT_UPPER=2 MIDDLE=4 LEFT_LOWER=8 RIGHT_LOWER=16 |
Experimental. |
Numbers |
clamp(value, min, max, fallback = min), percent(value, fallback = 0), fade(seconds), scale(value, min, max, toMin, toMax), percentToHex(value) (all static) |
percent rounds and clamps to 0-100; fade limits to 0-6553.5 s in 0.1 s steps; scale with an empty source range gives toMin. |
@lox-nexus/plugin-sdk/testing
In the guide: Testing.
PluginTestHost (reference)
class PluginTestHost<T extends PluginClass = PluginClass> {
readonly sent: WorkerMessage[]
readonly store: Record<string, unknown>
get plugin(): InstanceType<T>
static start<T extends PluginClass>(type: T, options?: TestHostOptions): Promise<PluginTestHost<T>>
static describe(type: PluginClass): Promise<PluginDescription>
static checkConfig(type: PluginClass, config: unknown, configVersion?: number, store?: Record<string, unknown>): Promise<ConfigCheckResult>
static testConfig(type: PluginClass, config: unknown): Promise<string | undefined>
static discover(type: PluginClass, field: string, config?: unknown): Promise<Discovered>
addSensor(capability: string, params?: unknown, label?: string): Promise<string>
addActuator(capability: string, params?: unknown, label?: string): Promise<string>
remove(id: string): Promise<void>
values(id: string): Value[]
next(id: string, timeoutMs?: number): Promise<Value>
actuate(id: string, value: Value, causeId?: string, replayed?: boolean): Promise<void>
catalog(): Promise<CatalogNode[]>
panel(): Promise<Panel | undefined>
http(request: Partial<HttpRequest> & Pick<HttpRequest, "path">): Promise<Required<HttpResponse>>
action(id: string, input?: unknown): Promise<string | undefined> // runs an action like its button; resolves with its message, rejects with its failure
actionAnswer(id: string, input?: unknown): Promise<ActionAnswer | undefined> // the whole answer: text, or { message?, panel? }
get status(): { status: "running" | "degraded"; message?: string }
watchSignals(signals: WatchedSignal[]): Promise<void>
pushValues(values: Array<Omit<WatchedValue, "ts" | "kind"> & Partial<Pick<WatchedValue, "ts" | "kind">>>, dropped?: number): Promise<void>
stop(): Promise<void>
}
interface ConfigCheckResult { issues: ConfigIssue[]; config: unknown; version: number; store?: { set: Record<string, unknown>; delete: string[] } }
interface ConfigIssue { path: (string | number)[]; message: string }
interface TestHostOptions {
config?: unknown; store?: Record<string, unknown>; instanceId?: string; slug?: string /* default "test" */; clock?: Clock /* e.g. a TestClock */
timeZone?: string; forwardRejections?: boolean; configVersion?: number
hostFeatures?: string[] // default all (HOST_FEATURES); [] shows what an older Nexus gets
}
function cellText(cell: Cell | undefined): string // a panel cell's text, for assertions
class TestClock implements Clock {
constructor(start?: number | string | Date)
get pending(): number
advance(ms: number): Promise<void> // runs each due timer at its own due time
advanceTo(time: number | string | Date): Promise<void>
jump(ms: number): void // a wall-clock step; timers keep their remaining delay
}
| Member | Notes |
|---|---|
start |
Defaults: config {}, store {}, instanceId "test". stop() waits 2 s (real time) for the plugin to stop; production allows 5 s. Throws Error with the plugin's message when init fails (invalid config, start() threw). |
addSensor / addActuator |
Return the new signal's id. Throw for unknown capabilities, invalid params or a failing start(). |
values(id) |
Every value emitted so far. |
next(id, timeoutMs = 2000) |
Waits for the next value emitted after the call; throws on timeout. Waits use the real timers, also while a test mocks them (mock.timers), so a missing value fails instead of hanging. |
checkConfig |
Checks settings as the host does before saving them, without starting the plugin: zod issues at their fields, and the config migrated from configVersion (default: the current version). With store, store in the result says what the migration wrote. |
plugin |
The plugin instance, typed as its class, for reaching into it from a test (e.g. waiting for a poller). |
actuate |
Throws Error with the message run() threw. causeId is the bus message it answers; replayed what run() sees in command.replayed. |
hostFeatures |
By default the plugin runs as on a current Nexus. hostFeatures: [] runs it as on an older one: v1 panels (cells as text, tiles and notices as key-value), an action's message only, no panel.changed or action.progress (both are in sent otherwise). |
cellText(cell) |
Panel values are cells (text or { text?, tone?, until?, since? }): cellText(row[1]) reads the text in an assertion. |
http |
Method defaults to POST; unknown route answers 404; a throwing handler rejects. |
status |
Last reported status, running if none. |
sent |
Everything the plugin sent, structured-cloned like the worker boundary. A log line is { type: "log", level, message, data?, causeId? }; the other types are WorkerMessage in ./wire. |
PluginRuntimeForTests is internal (its constructor may change); don't use it.
@lox-nexus/plugin-sdk/wire
Plugin authors need only the shapes (reference):
interface CatalogNode { label: string; id?: string; children?: CatalogNode[]; entries?: CatalogEntry[] } // id: for a panel's "Signals" button
type CatalogEntry = { kind: "sensor" | "actuator"; capability: string; params: unknown; label: string; value?: Value; unit?: string }
type Tone = "ok" | "warn" | "error" | "info" | "muted"
type Cell = string | { text?: string; tone?: Tone; until?: number; since?: number; at?: number } // epoch ms, counted live: "in 12:30", "for 3 min", "12 s ago"
type PanelPart =
| { type: "keyValue"; items: { label: string; value: Cell; control?: RowAction; catalog?: string }[] }
| { type: "table"; columns: string[]; rows: Cell[][]; rowActions?: RowAction[][]; rowCatalog?: (string | null)[] } // up to 4 buttons per row; catalog node ids
| { type: "tiles"; tiles: { label: string; value: number | string | null; unit?: string; decimals?: number; tone?: Tone; min?: number; max?: number; until?: number; hint?: string }[] } // at most 12
| { type: "notice"; tone: Tone; title?: string; text: string; link?: { label: string; href: string } } // href: http(s) or "#/…"
| { type: "qr"; data: string; caption?: string } // e.g. a Matter pairing payload; hosts before it refuse the panel
| { type: "json"; data: unknown }
type Panel = PanelPart | { type: "sections"; sections: { title: string; panel: PanelPart; actions?: RowAction[] }[] } // at most 20 sections; up to 4 buttons in a heading
type RowAction = { action: string; label?: string; input?: Record<string, unknown>; instant?: boolean; switch?: { checked: boolean } }
type ActionAnswer = string | { message?: string; panel?: PanelPart }
type Discovered = { label: string; value: string; detail?: string }[] // at most 100
type HttpRequest = { method: string; path: string; query: Record<string, string>; headers: Record<string, string>; body: string }
type HttpResponse = { status: number; headers?: Record<string, string>; body?: string }
type PluginHealth = "running" | "degraded"
type LogLevel = "debug" | "info" | "warn" | "error"
| Item | Notes |
|---|---|
CatalogEntry.params |
Validated against the capability's params schema when the user adds it. |
HttpRequest |
path is below /hook/<instance>; repeated query keys and headers are joined with ", "; header names lowercase; the session cookie is not forwarded; body is utf-8 text. |
HttpResponse |
status 100-599; body is utf-8 text. |
Panel cells |
Text, or text with a tone (a dot and colour) and a time the page counts down to (until) or up from (since). Format numbers yourself. sections shows titled parts one below the other (facts above a table); hosts before it refuse that type, which fails only the panel. |
RowAction |
Opens the action's dialog with input filled in. instant: runs at once (after the action's confirm), the result shows above the panel. switch: a switch in state checked that runs the action with input when flipped (put the new state in input). |
| v2 parts | Tiles, notices, rich cells, item controls and instant/switch need the host feature panel.v2; on older hosts the runtime turns them into text and key-value lists, so plugins don't check. |
Messages. Host and worker talk in these messages (type tells them apart). A plugin never
sends them itself; the runtime does. Requests carry a requestId and are answered with response
{ requestId, ok, result?, error? }.
Host → worker (HostMessage) |
Fields | Answer |
|---|---|---|
init |
instanceId, slug?, config, store, timeZone?, logLevel?, configVersion?, hostFeatures? |
ready or failed (after config.migrated when it migrated). hostFeatures (HOST_FEATURES): what the host understands beyond version 1 |
describe |
requestId |
the PluginDescription |
signal.add |
requestId, signal (SignalSpec) |
ok once the sensor's or actuator's start() resolved |
signal.remove |
requestId, id |
ok once disposed |
actuate |
requestId, signalId, value, causeId?, replayed? |
ok, or the error run() threw |
catalog |
requestId |
CatalogNode[] |
panel |
requestId |
a Panel, or none |
http.request |
requestId, request (HttpRequest) |
an HttpResponse |
action |
requestId, action, input |
an ActionAnswer (an object only to hosts with action.result), or none; only sent for actions the description declares |
watch.signals |
signals (WatchedSignal[]) |
none |
watch.values |
seq, values (WatchedValue[]), dropped? |
watch.ack |
log.level |
level |
none; only to workers whose hello lists the feature log.level |
config.check |
requestId, config, configVersion?, store? |
{ issues, config, version, store? }; feature config.check |
config.test |
requestId, config |
the test's message; feature config.check |
config.discover |
requestId, field, config? |
Discovered; only for fields the description lists as discoverable |
ping |
seq |
pong |
shutdown |
stopped |
Worker → host (WorkerMessage) |
Fields | Meaning |
|---|---|---|
hello |
version, features? |
the wire version, sent first; features names additions the worker understands (log.level, config.check) |
ready |
init done |
|
failed |
error |
init failed |
emit |
signalId, value, causeId? |
a sensor's value |
status |
status, message? |
running or degraded |
log |
level, message, data?, causeId? |
a log line |
store.set |
key, value |
persist a store entry |
store.delete |
key |
remove a store entry |
response |
requestId, ok, result?, error? |
the answer to a request |
signal.error |
signalId, error |
a sensor or actuator stopped working |
signal.recovered |
signalId |
the failure is over |
watch.ack |
seq |
a watch.values batch is handled |
config.migrated |
config, version |
init migrated the stored config; only when init carried configVersion |
panel.changed |
the panel changed (panelChanged()); only to hosts with panel.push |
|
action.progress |
requestId, text, fraction? |
a running action's progress; only to hosts with action.progress |
pong |
seq |
answers ping |
stopped |
shutdown done |
For hosts and tools: WIRE_VERSION (1), HOST_FEATURES (HostFeature), HostMessageSchema, WorkerMessageSchema, SignalSpecSchema, HttpRequestSchema, HttpResponseSchema, CatalogEntrySchema, CatalogNodeSchema, PanelSchema, PanelPartSchema, RowActionSchema, CellSchema, ToneSchema, ActionAnswerSchema, DiscoveredSchema, CapabilityDescriptionSchema, PluginDescriptionSchema, PluginHealthSchema, LogLevelSchema, the matching types (HostMessage, WorkerMessage, SignalSpec, CapabilityDescription, PluginDescription, JsonSchema) and PluginWorkerData.