Lox Nexus

Abstract Class: Plugin<Config>

The class you write once per integration: it connects to one device or service and offers its sensors, actuators and actions to Nexus. One instance runs per configured plugin instance, in its own worker thread, so a plugin that crashes or hangs affects nothing else. Extend it, declare the static fields (see PluginClass), and export the class with PluginEntry.define.

What you override: start() (begin work; usually register a poller with every()), and optionally catalog(), panel(), watched(), watchChanged() and unhandledRejection(). What you read: config, log, store, status, clock, timeZone, http, slug, watch. What you call: panelChanged(), and from Disposable own(), every(), after() and listen(), which are cleaned up when the instance stops. Sensors and actuators reach the plugin through their this.plugin, so state they share (an API client, the latest reading) lives here.

The order of events for one run: the constructor (field initializers can already use this.config), then start(), then the sensors and actuators the user configured (each gets its own start()), then requests such as catalog(), panel() and webhooks for as long as it runs. When the instance stops, its signals are disposed and then the plugin. A crash or a restart creates a new object; nothing in memory survives, only the store does.

Plugin code can throw into the host only through start() and the request methods; everything else is logged or, for a promise nobody handles, given to unhandledRejection().

Example

A plugin that polls a smart plug and shares each reading with its sensors:

export class SmartPlugPlugin extends Plugin<SmartPlugConfig> {
  static readonly config = smartPlugConfig
  static readonly sensors = [MeterSensor]
  static readonly actuators = [SwitchActuator]
  readonly client = new PlugClient(this.config.address)
  readonly readings = new EventEmitter()
  override start() {
    void this.poll()
    this.every(this.config.pollSeconds * 1000, () => void this.poll())
  }
  // called with `void`, so it must not reject: an unreachable plug degrades the instance
  async poll() {
    try {
      this.readings.emit("reading", await this.client.status())
      this.status.set("running")
    } catch (error) {
      this.status.set("degraded", error instanceof Error ? error.message : String(error))
    }
  }
}

Extends

Type Parameters

Config

Config = unknown

Constructors

Constructor

new Plugin<Config>(context): Plugin<Config>

Field initializers of subclasses run after this, so they can already use this.config.

Parameters

context

PluginContext

Returns

Plugin<Config>

Overrides

Disposable.constructor

Properties

MAX_TIMER_MS

readonly static MAX_TIMER_MS: 2147483647 = 2_147_483_647

the largest delay setTimeout/setInterval honour; Node turns anything longer into 1 ms

Inherited from

Disposable.MAX_TIMER_MS


context

protected readonly context: PluginContext

Accessors

isDisposed

Get Signature

get isDisposed(): boolean

true from the moment dispose() starts, not when it finishes

Returns

boolean

Inherited from

Disposable.isDisposed


config

Get Signature

get config(): Config

The instance config, already validated against the static config schema (defaults applied). It never changes during one run: when the user saves new settings, the instance restarts and a new plugin object is built with them.

Returns

Config


clock

Get Signature

get clock(): Clock

The time to use instead of Date.now(): this.clock.now(). every() and after() run on it too. The system clock in production; a TestClock when a test gives one.

Returns

Clock


timeZone

Get Signature

get timeZone(): string

The IANA time zone of the installation, e.g. "Europe/Vienna", for anything that depends on the local time (opening hours, "today"). Nexus takes it from the Loxone project; without one it is the server's zone, which in a container is often UTC; so is a zone this runtime doesn't know. Fixed for one run of the instance.

Returns

string


log

Get Signature

get log(): PluginLogger

Writes to the server log, tagged with this instance, and to the instance's page (its last lines). Inside an actuator's run() a line is also linked to the command that caused it. Never log secrets.

Returns

PluginLogger


logLevel

Get Signature

get logLevel(): PluginLogLevel

Which log lines the host keeps: current is "debug" while the user switched on debug logging for this instance, while the server's own log is at debug, and always with older hosts; "info" otherwise. For raising a library's own verbosity along with it; plain log.debug() calls need nothing, they are dropped cheaply while debug is off.

Returns

PluginLogLevel


store

Get Signature

get store(): PluginStore

Small persisted state of this instance, for what must survive a restart (a token, a cursor, the id last seen). Reads are synchronous; writes are saved by the host in the background.

Returns

PluginStore


status

Get Signature

get status(): PluginStatus

Reports the plugin's own health to the UI: "degraded" with a message while something is wrong but the plugin keeps trying, "running" when it is fine again. A new run starts as "running".

Returns

PluginStatus


slug

Get Signature

get slug(): string | undefined

The instance's slug, e.g. to show its webhook URL (/hook/<slug>/…); undefined with hosts that don't send it. A slug change restarts the instance, so it is fixed for one run.

Returns

string | undefined


http

Get Signature

get http(): PluginHttp

Registers webhook routes under /hook/<slug>/…. Register them in start(); routes are unauthenticated, so the handler must check a secret itself (see PluginHttp).

Returns

PluginHttp


watch

Get Signature

get watch(): WatchView

The signals the user chose for this instance, for plugins that declare watches. Empty for other plugins and until the host has sent the list (it arrives after start() resolved, and again whenever the choice changes). Read it inside watched() and watchChanged(), not in start().

Returns

WatchView

Methods

dispose()

dispose(): Promise<void>

Releases everything registered with this object. Plugin code seldom needs it: the runtime disposes plugins, sensors and actuators, and own() disposes children. Call it yourself for an object you created without own(), or to end a child early.

Runs all teardowns in reverse registration order and keeps going when one fails. Idempotent: every call returns the same promise, so a second caller also waits for async teardowns.

Returns

Promise<void>

Throws

AggregateError collecting every teardown error, after all teardowns have run

Inherited from

Disposable.dispose


onDispose()

protected onDispose(teardown): void

Registers a callback that runs on dispose(), for cleanup the helpers don't cover (closing a socket, flushing a buffer). Async callbacks are awaited one after the other, last registered first, so a flush can use what was registered before it. Keep the total under the five seconds a stop has.

Parameters

teardown

Teardown

Returns

void

Throws

Error if already disposed

Inherited from

Disposable.onDispose


useClock()

protected useClock(clock): void

Sets the clock for every() and after() calls from now on, unless one is set already; without one they run on the system clock. Timers started before keep the clock they started on. Plugin, Sensor and Actuator set it from the plugin's context.

Parameters

clock

Clock | undefined

Returns

void

Inherited from

Disposable.useClock


currentClock()

protected currentClock(): Clock

The clock this object runs on: every() and after() use it, and a Disposable that isn't a plugin, sensor or actuator reads the time from it (this.currentClock().now()).

Returns

Clock

Inherited from

Disposable.currentClock


own()

protected own<T>(resource): T

ties the lifetime of a child resource to this object; returns it so construction and registration fit in one expression. A Disposable child without a clock of its own runs on this one's, looked up when a timer starts, so a chain of owners (plugin → client → poller) shares the plugin's clock. Timers a child starts in its constructor, before it is owned, run on the system clock.

Type Parameters

T

T extends DisposableLike

Parameters

resource

T

Returns

T

Inherited from

Disposable.own


every()

protected every(ms, fn): () => void

runs fn every ms milliseconds until disposed or until the returned cancel function is called. The first run is after ms, not at once; call fn yourself first if you want an immediate run. Runs are not queued: a slow async fn can overlap its next run, so guard it yourself if needed.

Parameters

ms

number

fn

() => void

Returns

() => void

Throws

RangeError unless 0 < ms <= MAX_TIMER_MS

Inherited from

Disposable.every


after()

protected after(ms, fn): () => void

runs fn once after ms milliseconds unless disposed before; returns a cancel function. Both firing and cancelling forget the teardown, so repeated use does not accumulate entries. Delays beyond MAX_TIMER_MS are scheduled in steps, so they don't fire early.

The delay counts elapsed time, not wall-clock time: a clock step (an NTP sync after boot) is not noticed. For a deadline given as a wall-clock time, wait in shorter slices and re-check the time each time.

Parameters

ms

number

fn

() => void

Returns

() => void

Throws

RangeError for negative or non-finite delays

Inherited from

Disposable.after


listen()

protected listen(emitter, event, listener): void

Subscribes listener to event of an EventEmitter (or anything with on/off) and removes it again on dispose(), so a long-lived emitter such as the plugin's poller does not keep a disposed sensor alive. Prefer it to calling emitter.on yourself.

Parameters

emitter

Listenable

event

string | symbol

listener

Listener

Returns

void

Inherited from

Disposable.listen


start()

start(): void | Promise<void>

Starts the work. Called once per run, after the constructor and before the first signal is added; the host waits for it to resolve (at most 30 seconds, then it terminates the worker and restarts it like after a crash). Reject only for problems a restart can't fix (invalid credentials, a missing device): the instance goes into the error state, what start() registered is disposed, and it is not restarted. For a device that is merely unreachable, resolve and report status.set("degraded", ...) while retrying. Slow first requests belong in the background (void this.poll()), not in an awaited start().

Returns

void | Promise<void>


catalog()

catalog(): CatalogNode[] | Promise<CatalogNode[]>

Optional tree of ready-to-add sensors and actuators, e.g. built from the device's data. Asked on demand by the UI, so it may query the device; a throw is shown to the user as the error. It must answer within 30 seconds and with at most 16 MiB of JSON, or the request fails. Can be called at any time while the plugin runs, also several times at once.

Returns

CatalogNode[] | Promise<CatalogNode[]>


panel()

panel(): { type: "keyValue"; items: object[]; } | { type: "table"; columns: string[]; rows: (string | { text?: string; tone?: "error" | "ok" | "warn" | "info" | "muted"; until?: number; since?: number; at?: number; })[][]; rowActions?: object[][]; rowCatalog?: (string | null)[]; } | { type: "qr"; data: string; caption?: string; } | { type: "json"; data: unknown; } | { type: "tiles"; tiles: object[]; } | { type: "notice"; tone: "error" | "ok" | "warn" | "info" | "muted"; title?: string; text: string; link?: { label: string; href: string; }; } | { type: "sections"; sections: object[]; } | Promise<{ type: "keyValue"; items: object[]; } | { type: "table"; columns: string[]; rows: (string | { text?: string; tone?: "error" | "ok" | "warn" | "info" | "muted"; until?: number; since?: number; at?: number; })[][]; rowActions?: object[][]; rowCatalog?: (string | null)[]; } | { type: "qr"; data: string; caption?: string; } | { type: "json"; data: unknown; } | { type: "tiles"; tiles: object[]; } | { type: "notice"; tone: "error" | "ok" | "warn" | "info" | "muted"; title?: string; text: string; link?: { label: string; href: string; }; } | { type: "sections"; sections: object[]; } | undefined> | undefined

Optional status view for the plugin's page; undefined shows nothing. The page asks for it every few seconds while it is open, so keep it cheap and read from memory. A plugin that declares static panelUpdates = "push" calls panelChanged() instead, and is asked when it does (and otherwise only every half minute). A throw or an invalid panel fails the request; the same 30 seconds and 16 MiB limits as catalog() apply. Only asked while the instance runs.

Returns

Type Literal

{ type: "keyValue"; items: object[]; }


Type Literal

{ type: "table"; columns: string[]; rows: (string | { text?: string; tone?: "error" | "ok" | "warn" | "info" | "muted"; until?: number; since?: number; at?: number; })[][]; rowActions?: object[][]; rowCatalog?: (string | null)[]; }

type

type: "table"

columns

columns: string[]

rows

rows: (string | { text?: string; tone?: "error" | "ok" | "warn" | "info" | "muted"; until?: number; since?: number; at?: number; })[][]

rowActions?

optional rowActions?: object[][]

rowCatalog?

optional rowCatalog?: (string | null)[]

per row, the catalog node with that row's signals (see CatalogRef), or null for none


Type Literal

{ type: "qr"; data: string; caption?: string; }


Type Literal

{ type: "json"; data: unknown; }


Type Literal

{ type: "tiles"; tiles: object[]; }


Type Literal

{ type: "notice"; tone: "error" | "ok" | "warn" | "info" | "muted"; title?: string; text: string; link?: { label: string; href: string; }; }


Type Literal

{ type: "sections"; sections: object[]; }


Promise<{ type: "keyValue"; items: object[]; } | { type: "table"; columns: string[]; rows: (string | { text?: string; tone?: "error" | "ok" | "warn" | "info" | "muted"; until?: number; since?: number; at?: number; })[][]; rowActions?: object[][]; rowCatalog?: (string | null)[]; } | { type: "qr"; data: string; caption?: string; } | { type: "json"; data: unknown; } | { type: "tiles"; tiles: object[]; } | { type: "notice"; tone: "error" | "ok" | "warn" | "info" | "muted"; title?: string; text: string; link?: { label: string; href: string; }; } | { type: "sections"; sections: object[]; } | undefined>


undefined


panelChanged()

panelChanged(): void

Tells open pages that panel() would now show something else, e.g. a device was paired. Cheap to call often: the host hears about it at most once a second. Does nothing on hosts that don't take it; declare static panelUpdates = "push" so pages rely on it.

Returns

void


watched()?

optional watched(values, dropped): void | Promise<void>

Values of the watched signals (plugins that declare watches), oldest first, in batches of up to a thousand. The next batch comes once this returns (or its promise settles), so a slow export slows delivery instead of piling up; meanwhile the host keeps a bounded queue and dropped says how many values it had to leave out since the last batch. A throw is logged and the feed goes on. Calls never overlap, and a running call finishes before the plugin is disposed on stop. Declare static watches too, or the plugin is never given any values.

Parameters

values

readonly object[]

dropped

number

Returns

void | Promise<void>


watchChanged()

watchChanged(): void

The watched signals changed: chosen or left out, renamed. this.watch has the new list. Called once after start() with the first list, then on every change; always before the values that belong to the new list. Optional to override.

Returns

void


unhandledRejection()

unhandledRejection(reason): void

A promise in this worker was rejected and nobody handled it (a forgotten .catch). The worker keeps running; by default the reason is logged as an error with its stack. Override it to say more, e.g. which line of user code it came from, or to report a degraded status. It must not throw: if it does, the original reason is logged instead. Not called before the plugin is created or after it is disposed; the runtime logs those.

Parameters

reason

unknown

Returns

void