Lox Nexus

@lox-nexus/plugin-sdk

The public SDK for plugins: the base classes (Plugin, Sensor, Actuator, Action), lifetimes (Disposable), the worker entry (PluginEntry) and config helpers (secret, code, token, signalName, signalGroup, z).

Values, the host/worker protocol and the test helpers live in the subpaths @lox-nexus/plugin-sdk/values, /wire and /testing.

Plugin

The class you extend (Plugin), the one line that exports it (PluginEntry), and what the runtime hands it: its config, log, store and status (see PluginContext).

ConfigMigration

What static migrateConfig gets: a stored config of an older configVersion.

Properties

config

config: unknown

the stored config, not validated: it has the shape of version from

from

from: number

the version it was stored with; 1 for instances from before the plugin declared one. A lower bound: the config may already have newer fields

store

store: PluginStore

the instance's store, for moving data out of the config (e.g. a list that grew too big for it). Writes are kept when the instance starts; a settings check works on a throwaway copy.


PluginClass

PluginClass = (new (context) => Plugin) & object

The static side of a plugin class, as the runtime sees it. The declarations are static fields of the subclass rather than of Plugin, so subclasses don't need override on them.

Type Declaration

name

readonly name: string

used in error messages

config?

readonly optional config?: z.ZodType

the instance config schema; default: no config (an empty object)

sensors?

readonly optional sensors?: readonly SensorClass[]

the sensors this plugin offers; ids must be unique among sensors

actuators?

readonly optional actuators?: readonly ActuatorClass[]

the actuators this plugin offers; ids must be unique among actuators

watches?

readonly optional watches?: WatchDeclaration

set when the plugin receives values of signals the user chooses (an exporter): Plugin.watched() gets them. The label and description are shown where the user chooses them.

actions?

readonly optional actions?: readonly ActionClass[]

what users can start from the instance page (pair a device, open a pairing window); ids must be unique

configVersion?

readonly optional configVersion?: number

The version of the config's shape, raised when a change needs migrateConfig; default 1. Configs are stored with the version they were saved with.

migrateConfig?

readonly optional migrateConfig?: (migration) => unknown

Turns a config saved with an older version into the current shape; may also move data into the store. Runs before validation when an instance starts or its settings are checked, and the host stores the result. It must be safe to run again on the same data: older hosts run it at every start with from 1, also on a config already in the current shape. Keep current fields that are there, and don't overwrite what the store already holds.

Parameters
migration

ConfigMigration

Returns

unknown

test?

readonly optional test?: (config) => Promise<string | void>

Test connection: reaches the device or service with this config (already validated) and resolves with a short message ("Connected to Fronius GEN24 at 10.0.0.5"), or throws with one a user can act on. Runs in a throwaway worker without starting the plugin; read, never write.

Parameters
config

never

Returns

Promise<string | void>

panelUpdates?

readonly optional panelUpdates?: "push"

"push": the plugin calls panelChanged() whenever its panel changes, so open pages ask for the panel then, and only every half minute otherwise. Without it they ask every few seconds.

discover?

readonly optional discover?: (field, config) => Promise<any[]>

Find on the network: offers values for a config field marked with discoverable(), e.g. the Home Assistant servers that announce themselves (see Mdns.browse). config is the form as filled in so far, not validated. Runs in a throwaway worker without starting the plugin, for at most 15 seconds; read, never write. Resolves with at most 100 entries.

Parameters
field

string

config

unknown

Returns

Promise<any[]>


PluginLogger

PluginLogger = Record<LogLevel, (message, data?) => void>

Log lines go to the host's log, tagged with the instance and the bus message being handled. Text is clipped to 10 000 characters, and data (structured-cloned; one that cannot be cloned is logged as text) is replaced by a note when it is more than 64 KiB as JSON. Never log secrets.


PluginLogLevel

The lowest level the host keeps for this instance: "debug" while the user switched on debug logging for it, while the server's own log is at debug, and with older hosts; "info" otherwise. Lines below it are dropped in the worker, so debug calls cost little; a plugin can raise its library's own verbosity with it.

Properties

current

readonly current: "error" | "warn" | "info" | "debug"

Methods

onChange()

onChange(listener): () => void

calls listener when current changes;

Parameters
listener

(level) => void

Returns

the unsubscribe

() => void


PluginStore

Small persisted key-value state of one instance (tokens, cursors, "last seen" ids). Reads are synchronous from memory; writes are sent to the host, which stores them.

Methods

get()

get<T>(key): T | undefined

The value last set (also earlier in this run), or undefined. The cast to T is not checked; validate data that an older version may have written.

Type Parameters

T

T = unknown

Parameters
key

string

Returns

T | undefined

set()

set(key, value): void

Remembers value under key: visible to get() at once, saved by the host in the background and handed to the next run's init. The value must be JSON (it is stored as JSON; functions, undefined fields and class instances are dropped or flattened, BigInt and cycles throw), at most 1 MiB as JSON, and not undefined itself (use delete()). Keys are 1 to 200 characters.

Parameters
key

string

value

unknown

Returns

void

Throws

Error for an invalid key or value

delete()

delete(key): void

removes the key; an absent key is a no-op

Parameters
key

string

Returns

void

keys()

keys(): string[]

every key currently stored, in insertion order

Returns

string[]


PluginStatus

The plugin's own view of its health: "degraded" with a message while something is wrong.

Methods

set()

set(status, message?): void

message is clipped to 10 000 characters; "running" clears an earlier degraded state

Parameters
status

"running" | "degraded"

message?

string

Returns

void


PluginContext

Everything a plugin gets from the runtime.

Properties

instanceId

readonly instanceId: string

stable for the life of the configured instance (webhook URLs use the instance's slug instead). Plugin does not expose it as a getter; subclasses read it as this.context.instanceId.

slug?

readonly optional slug?: string

the instance's slug: its topic segment and webhook path (/hook//…); undefined with older hosts

config

readonly config: unknown

already validated against the plugin's static config schema

log

readonly log: PluginLogger

store

readonly store: PluginStore

persisted across restarts and updates of this instance

status

readonly status: PluginStatus

http

readonly http: PluginHttp

watch?

readonly optional watch?: WatchView

only for plugins that declare watches; absent with older runtimes

clock?

readonly optional clock?: Clock

the time and timers the plugin runs on (Plugin.clock); the system clock in production, absent with older runtimes

timeZone?

readonly optional timeZone?: string

IANA name of the installation's time zone (Plugin.timeZone); absent when unknown and with older hosts

logLevel?

readonly optional logLevel?: PluginLogLevel

which log lines the host keeps (Plugin.logLevel); absent with older runtimes

Sensors and actuators

The signals a plugin offers. Extend Sensor for a value that comes from the device and Actuator for a command that goes to it; list them in the plugin's static sensors and actuators.

ActuatorConcurrency

ActuatorConcurrency = "queue" | "latest"

How commands that arrive while one is running are handled (set with the static concurrency):

  • "queue": each runs, in order
  • "latest": only the newest waiting command runs (a dimmer dragged across the range sends many values; only the last one matters)

ActuatorCommand

What run() learns about a command besides its value.

Properties

replayed

readonly replayed: boolean

the host delivers the value again (a force after a restart), not a new command


ActuatorClass

ActuatorClass = (new (plugin, params, signal?) => Actuator<Plugin, unknown, never>) & object

The static side of an actuator class, as the runtime sees it.

Type Declaration

id

readonly id: string

unique within the plugin; part of the stored signal, so don't rename it

label?

readonly optional label?: string

shown in UIs; default: the id

description?

readonly optional description?: string

one sentence for UIs: what it does, e.g. "Dims a light; 0 % turns it off"

params?

readonly optional params?: z.ZodType

default: no params (an empty object)

accepts?

readonly optional accepts?: ValueType | "any"

the value type run() expects; wires convert to it. Default "any": values pass unchanged

concurrency?

readonly optional concurrency?: ActuatorConcurrency

default "queue"

unit?

readonly optional unit?: string

the unit new signals of it show their values in, e.g. "%" (up to 16 characters); the user can change it

decimals?

readonly optional decimals?: number

the decimals new signals of it round their values to for display (0-6); the user can change it


SensorOutput

Where a sensor's values go; the runtime forwards them to the bus.

Methods

emit()

emit(value): void

sends a value to the bus, tagged with the cause of the work that is running (see Actuator)

Parameters
value

Value

Returns

void

fail()

fail(message): void

the sensor stopped working after it started; shown on the signal until it emits or recovers

Parameters
message

string

Returns

void

recover()?

optional recover(): void

the failure is over although there is no value to send; optional for outputs made elsewhere

Returns

void


SensorClass

SensorClass = (new (plugin, params, output, signal?) => Sensor<Plugin, unknown>) & object

The static side of a sensor class, as the runtime sees it.

Type Declaration

id

readonly id: string

unique within the plugin; part of the stored signal, so don't rename it

label?

readonly optional label?: string

shown in UIs; default: the id

description?

readonly optional description?: string

one sentence for UIs: what it reads or does, e.g. "The value of a sensor entity or one of its attributes"

params?

readonly optional params?: z.ZodType

default: no params (an empty object)

valueType?

readonly optional valueType?: ValueType | "any"

what emit() sends, so wires can convert; default "any" (it varies)

unit?

readonly optional unit?: string

the unit new signals of it show their values in, e.g. "W" (up to 16 characters); the user can change it

decimals?

readonly optional decimals?: number

the decimals new signals of it round their values to for display (0-6); the user can change it

staleAfterMs?

readonly optional staleAfterMs?: number

after how long without a value (an unchanged one counts) new signals of it warn that it is stale, e.g. three poll intervals; rounded up to minutes. The user can change it.


SignalInfo

Which configured signal a sensor or actuator instance is, as the user knows it.

Properties

id

id: string

the signal's stable id

label

label: string

the name the user gave the signal; "" when the host didn't send one (older hosts)

Settings form

Helpers that mark config and params fields so the UI shows the right input: secret, code, token, duration, slider and more. Mdns finds devices on the network for a field marked with discoverable.

MdnsService

A service found on the network, e.g. a Home Assistant server.

Properties

name

name: string

the instance's name, e.g. "Home._home-assistant._tcp.local"

host

host: string

the host name it runs on, e.g. "homeassistant.local"

port

port: number

addresses

addresses: string[]

the host's IPv4 and IPv6 addresses the answer gave

txt

txt: Record<string, string>

its TXT record as key-value pairs, e.g. { base_url: "http://10.0.0.5:8123" }


MdnsOptions

where to ask; tests ask a responder on the loopback interface

Properties

timeoutMs?

optional timeoutMs?: number

how long answers are collected, in ms; default 3000

address?

optional address?: string

default 224.0.0.251, mDNS's multicast group

port?

optional port?: number

default 5353

clock?

optional clock?: Clock

the timer's clock; tests may pass a TestClock


CodeLanguage

CodeLanguage = "javascript" | "json" | "text"

Languages the UI knows how to present; others are shown as plain text.


code()

code<T>(schema, language?): T

Marks a string config field as source code (a script, a template), so the UI edits it in a code editor instead of a one-line input; a field that is also secret() stays a masked input. It is described as format: "code" with the language's contentMediaType. Validating the code stays the plugin's job, e.g. compiling it in start() and throwing with the line and column.

Type Parameters

T

T extends ZodType<string | null | undefined, unknown, $ZodTypeInternals<string | null | undefined, unknown>>

Parameters

schema

T

language?

CodeLanguage = "javascript"

Returns

T


duration()

duration(options?): ZodNumber

a duration stored as a number of units (default seconds); the form offers seconds, minutes and hours

Parameters

options?
unit?

"min" | "ms" | "s" | "h"

min?

number

max?

number

Returns

ZodNumber


timeOfDay()

timeOfDay(): ZodString

a time of day as "HH:MM" (24 h), e.g. "06:30"; the form shows a time picker

Returns

ZodString


color()

color(): ZodString

a colour as "#rrggbb"; the form shows a colour picker next to the hex text

Returns

ZodString


slider()

slider(min, max, step?): ZodNumber

a number from min to max, shown as a slider with the number next to it

Parameters

min

number

max

number

step?

number = 1

Returns

ZodNumber


file()

file(options?): ZodString

A file the user picks, e.g. a certificate: its contents as text (default), or as base64 for binary files. accept filters the picker (".pem,.crt"); maxBytes (default 256 KiB) is checked in the form before upload and again here.

Parameters

options?
accept?

string

maxBytes?

number

encoding?

"text" | "base64"

Returns

ZodString


discoverable()

discoverable<T>(schema): T

Marks a top-level config field as one the plugin can find on the network: the form shows Find next to it, which runs the plugin's static discover for the field and offers what it found.

Type Parameters

T

T extends ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>

Parameters

schema

T

Returns

T


secret()

secret<T>(schema): T

Marks a config or params field as secret (a password, a token). It is described as writeOnly, so the host never sends it back to a UI and keeps the stored value when an update omits it.

Only top-level fields of the instance config are protected. A secret inside a nested object or an array is not detected: its value is sent to the UI, and saving the form erases it.

Type Parameters

T

T extends ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>

Parameters

schema

T

Returns

T


signalGroup()

signalGroup<T>(schema): T

Marks the param of a sensor or actuator that names the device the signal belongs to, such as a Home Assistant entity or a bridged Matter device: signal lists show the signals with the same value together, as one group. Several marked params form a path ("node ▸ endpoint"), in the order they are declared. It is described as signalGroup: true, which the UI reads on top-level params only. Nothing else uses it: wires, history and exports address single signals.

Type Parameters

T

T extends ZodType<string | number | null | undefined, unknown, $ZodTypeInternals<string | number | null | undefined, unknown>>

Parameters

schema

T

Returns

T


signalName()

signalName<T>(schema): T

Marks the param of a sensor or actuator whose value names the signal, such as a webhook's hook name or a script's variable: the form names a new signal (and so its topic) after what is typed there, until the user names it. Without one a new signal is named after its kind. It is described as signalName: true, which the UI reads on top-level params only.

Type Parameters

T

T extends ZodType<string | null | undefined, unknown, $ZodTypeInternals<string | null | undefined, unknown>>

Parameters

schema

T

Returns

T


token()

token<T>(schema): T

Marks a string config field as a random token (a shared secret that callers present, such as a webhook token), so the UI offers a button that fills it with a new one: 24 random bytes as 48 hex characters, which the schema must allow. It is described as format: "token". Leave the field visible so the token can be copied into the caller, or combine it with secret() to hide it.

Type Parameters

T

T extends ZodType<string | null | undefined, unknown, $ZodTypeInternals<string | null | undefined, unknown>>

Parameters

schema

T

Returns

T


Discovered

Discovered = z.infer<typeof DiscoveredSchema>

What static discover found for a config field, e.g. Home Assistant servers on the network: value is what the field gets when the user picks it, label and detail what the list shows.

Catalog, panel and actions

What the plugin shows in the UI beyond the form: a catalog of ready-to-add signals, a status panel, and buttons a user can press. Extend Action for something the user starts from the instance page.

ActionResult

ActionResult = string | void | { message?: string; panel?: PanelPart; }

What an action's run() answers: a text, or a message with a panel part shown under it (a QR code, tiles, a notice). Hosts without the "action.result" feature show only the message.


ActionClass

ActionClass = (new (plugin, input) => Action<Plugin, unknown>) & object

The static side of an action class.

Type Declaration

id

readonly id: string

unique within the plugin; the API addresses the action by it

label?

readonly optional label?: string

the button's text; default: the id

description?

readonly optional description?: string

one sentence shown in the action's dialog

input?

readonly optional input?: z.ZodType

the form's schema; default: no input (an empty object)

confirm?

readonly optional confirm?: string

a sentence the user confirms before it runs, for actions that undo something

placement?

readonly optional placement?: "toolbar" | "panel"

where its button is: "toolbar" (default) at the top of the instance page, or "panel" only where the panel puts it, as a section's button (actions) or a table row's (rowActions)


Tone

Tone = z.infer<typeof ToneSchema>

How a value reads at a glance: "ok" green, "warn" amber, "error" red, "info" blue, "muted" grey. The UI shows it with a dot and the colour, never by colour alone.


Cell

Cell = z.infer<typeof CellSchema>

A panel value: plain text, or an object with text, a Tone and a time the browser shows live: until counts down, since counts up, at shows a moment ("12 s ago"). Times are epoch ms. A host without the "panel.v2" feature gets the text instead.


RowAction

RowAction = z.infer<typeof RowActionSchema>

A button for an action, on a table row, a key-value item or a section's heading; it opens the action's dialog with input filled in (its keys must be fields of the action's input schema). instant runs it at once, switch shows it as a switch in the state checked.


PanelPart

PanelPart = z.infer<typeof PanelPartSchema>

A key-value list, a table, a JSON tree, a QR code, tiles or a notice.


Panel

Panel = z.infer<typeof PanelSchema>

A status view: one part, or titled sections of parts; see PanelSchema.


CatalogEntry

CatalogEntry = z.infer<typeof CatalogEntrySchema>

A ready-to-add sensor or actuator in a catalog: the capability with its params filled in.


CatalogNode

A browsable tree ("Kitchen ▸ Light ▸ brightness") returned by Plugin.catalog(). A node has a label and children, entries, or both; the user picks entries to add them as signals:

[{ label: "Kitchen", children: [{ label: "Light", entries: [
  { kind: "sensor", capability: "state", params: { entityId: "light.kitchen" }, label: "Kitchen light" }
] }] }]

Properties

label

label: string

id?

optional id?: string

Names the node for a panel's "Signals" button (rowCatalog, an item's catalog), e.g. "client:aa:bb:cc:dd:ee:ff"; unique within the catalog. Older hosts drop it.

children?

optional children?: CatalogNode[]

entries?

optional entries?: object[]

kind

kind: "sensor" | "actuator"

capability

capability: string

params

params: unknown

validated against the capability's params schema when the entry is added

label

label: string

suggested name for the signal

value?

optional value?: Value

optional: what the sensor reads (or the device reports for an actuator) right now, shown while browsing. Only informative, so an invalid one is dropped instead of failing the catalog.

unit?

optional unit?: string

optional: the value's unit as the device names it, e.g. "W" or "°C"; dropped when longer than 32 characters

Webhooks

HTTP routes the plugin registers with this.http, reachable at /hook/<slug>/....

HttpHandler

HttpHandler = (request) => HttpResponse | Promise<HttpResponse>

A webhook handler; when it throws (or answers with something that is not a valid HttpResponse), the caller gets 502 and the error goes to the server log. The host waits up to 30 seconds for the answer. The plugin sees at most 8 calls at once per instance (more get 429).

Parameters

request

HttpRequest

Returns

HttpResponse | Promise<HttpResponse>


PluginHttp

Webhook routes, reachable at /hook/ without a login: check a secret yourself.

Methods

route()

route(method, path, handler): void

Parameters
method

string

e.g. "POST" (case-insensitive), or "*" for any method

path

string

below the instance, e.g. "/github"; ending in "/" it matches every path below that prefix ("/a/" matches "/a/" and "/a/b", not "/a"; "/" matches all). The most specific route wins: exact before prefix, longer prefix before shorter, a named method before "". The handler sees the full request.path.

handler

HttpHandler

Returns

void

Throws

Error when the method (case-insensitive) and path are already registered


HttpRequest

HttpRequest = z.infer<typeof HttpRequestSchema>

An HTTP request to the plugin's webhook routes; path is below /hook/.


HttpResponse

HttpResponse = z.input<typeof HttpResponseSchema>

the input type: headers and body may be omitted, the runtime fills them in

Lifetimes and time

Everything with a lifetime extends Disposable: timers, listeners and children registered through it are cleaned up for you. Clock is the time it runs on, so tests can move time by hand.

Clock

Time and timers for a Disposable's every() and after(), and for plugin code that asks what time it is (this.clock.now() instead of Date.now()). The runtime gives a plugin the system clock; tests can give it a TestClock (@lox-nexus/plugin-sdk/testing) and move time by hand.

Methods

now()

now(): number

milliseconds since the epoch, like Date.now()

Returns

number

setTimeout()

setTimeout(fn, ms): unknown

like the global setTimeout; the handle is only meant for clearTimeout

Parameters
fn

() => void

ms

number

Returns

unknown

clearTimeout()

clearTimeout(handle): void

Parameters
handle

unknown

Returns

void

setInterval()

setInterval(fn, ms): unknown

like the global setInterval; the handle is only meant for clearInterval

Parameters
fn

() => void

ms

number

Returns

unknown

clearInterval()

clearInterval(handle): void

Parameters
handle

unknown

Returns

void


Teardown

Teardown = () => void | Promise<void>

Cleanup callback registered with a Disposable. May be async; errors are collected, not swallowed.

Returns

void | Promise<void>


DisposableLike

The minimal shape of something with a lifetime; lets own() accept objects that are not Disposable subclasses.

Methods

dispose()

dispose(): void | Promise<void>

Returns

void | Promise<void>


Listener

Listener = (...args) => void

An event listener as listen() takes it. Typed with any[] like Node's EventEmitter, so emitters fit.

Parameters

args

...any[]

Returns

void


Listenable

Structural subset of EventEmitter (and similar) needed by listen().

Methods

on()

on(event, listener): unknown

Parameters
event

string | symbol

listener

Listener

Returns

unknown

off()

off(event, listener): unknown

Parameters
event

string | symbol

listener

Listener

Returns

unknown

Watching signals

For plugins that receive the values of signals the user chooses (exporters), declared with the static watches.

WatchView

The signals the user chose for a watching plugin; empty for others and until the host sends them.

Methods

signals()

signals(): object[]

Returns

object[]

signal()

signal(id): { id: string; topic: string; label: string; valueType: "string" | "number" | "boolean" | "null" | "singleChannel" | "rgbw" | "tunableWhite" | "t5" | "any"; owner?: { kind: "miniserver" | "plugin"; id: string; }; unit?: string; decimals?: number; } | undefined

Parameters
id

string

Returns
Type Literal

{ id: string; topic: string; label: string; valueType: "string" | "number" | "boolean" | "null" | "singleChannel" | "rgbw" | "tunableWhite" | "t5" | "any"; owner?: { kind: "miniserver" | "plugin"; id: string; }; unit?: string; decimals?: number; }

id

id: string

topic

topic: string

its bus topic, e.g. loxone/home/in/temperature; changes when the signal or its owner is renamed

label

label: string

valueType

valueType: "string" | "number" | "boolean" | "null" | "singleChannel" | "rgbw" | "tunableWhite" | "t5" | "any"

owner?

optional owner?: object

the Miniserver or plugin instance it belongs to

owner.kind

kind: "miniserver" | "plugin"

owner.id

id: string

unit?

optional unit?: string

the unit the user gave the signal, e.g. "W"; absent when none (and from older hosts)

decimals?

optional decimals?: number

the decimals the user wants its numbers shown with; absent when not set


undefined


WatchDeclaration

WatchDeclaration = z.infer<typeof WatchDeclarationSchema>

What a plugin that watches signals declares (static readonly watches): shown to the user where they choose the signals it receives.


WatchedSignal

WatchedSignal = z.infer<typeof WatchedSignalSchema>

A signal the user chose for a watching plugin: any signal of Nexus, a Miniserver's or another plugin's.


WatchedValue

WatchedValue = z.infer<typeof WatchedValueSchema>

One value of a watched signal, as subscribers of the bus saw it.