Skip to content

Design Specification

A SignalK plugin, in TypeScript, that renders selected SignalK data to BLE eInk Electronic Shelf Labels (ESLs). This page describes the plugin as it is built. Plans and ideas not yet built are in the roadmap.

  • Native TypeScript, Node 20 compatible (runs on armv7, e.g. a Cerbo GX)
  • Bundled templates for a tide clock and a watch schedule, rendered from any SignalK data, Resources API resource or label fact a template binds to
  • Pluggable vendor support
    • Built-in drivers for Zhsunyco and Gicisky BLE labels (src/devices/zhsunyco/, src/devices/gicisky/), registered by plugin.ts at startup
    • Each driver has a metadata table of the models it supports, keyed by PID, with hwVersion used where a vendor reuses a PID across panel sizes. Each entry gives the label, width, height, vertical offset and colours. esl-cli vendors lists them
    • Additional vendors are added by a separate npm package implementing VendorDriver and calling the exported registerVendorDriver explicitly - no scanning of installed packages. In the SignalK runtime the extension calls it from its own plugin start(); the CLI loads it via esl-cli --require <module>. Extension packages declare this package as a regular dependency, not a peerDependency (SignalK’s guidance is that npm’s peer-dependency resolution interacts poorly with the server’s plugin install layout), and declare the SignalK-level relationship via "signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] } - see Extending
    • The config schema’s device and template options are read live from the registries when the schema is built, so a newly-registered extension shows up the next time the config panel is opened
  • Bluetooth access, either direct or shared
    • Direct BlueZ access over D-Bus via node-ble (the default). The plugin waits, with backoff, for a local adapter before scanning or painting, to cover the boot-time race where the plugin starts before BlueZ is ready
    • The SignalK BLE Manager API (app.bleApi, server >= 2.32.0), opted into with useBleApi, so the plugin shares the adapter with other BLE plugins. Only offered in the config schema when the running server has it. Paints through BLE Manager are serialised so only one label holds a GATT connection at a time
  • Device discovery
    • One shared discovery path across all registered drivers (discoveryCoordinator.ts): each advertisement is read once and offered to each driver’s matchesAdvertisement; only a matching driver’s identifyDevice runs, which may open a GATT connection when the advertisement alone doesn’t identify the model
    • A scan already in progress is joined rather than a second one started, since neither backend can run two discovery sessions at once
    • With direct BlueZ access, scanning happens in bounded windows: an optional startup scan (scanOnStart, off by default, scanDurationSeconds long), an on-demand scan when an “All discovered devices” entry has nothing discovered yet, or esl-cli scan. With BLE Manager, the plugin listens to its advertisement stream for as long as the plugin runs, and only identifies new addresses
    • Discovered devices are persisted in the plugin data directory, keyed by BLE address, and kept for 24 hours after last being seen; a successful paint counts as being seen. The startup scan reports what it found in the plugin status line
    • The device picker lists any device a driver identified as its vendor, even if the PID isn’t in that driver’s metadata table yet (labelled “vendor unrecognised PID 0x…. (address)”), so it can be reported. Such a device can’t be repainted from the plugin, since there’s no width/height to render with; esl-cli paint can paint one by supplying the model facts on the command line
  • Device configuration
    • Each device entry has a friendly name, a combined “Device” choice, an optional location/description, a template and a repaint trigger, with less common settings grouped under “Advanced settings”
    • The “Device” value is "<vendor>:<pid>[:<hwVersion>]@<address>", picked from recently discovered devices, so width/height/colours are known from config alone and never need a live BLE read or a rescan after a restart. The special value ALL (“All discovered devices”) paints the same template and trigger to every currently-known device instead, resolved at repaint time
    • Advanced settings: reframe (crop by default, scale, or fixed to fail on a size mismatch), compress upload (on by default), mirror (none, horizontal, vertical, both), Gicisky wire format (auto or experimental chunked), Zhsunyco write-without-response, force repaint, Zhsunyco AES key (falls back to a default key), and per-device overrides of the plugin-wide paint connect timeout and retry count
    • Config saved before advanced settings were grouped, or nested by an older save bug, is rewritten into the current shape at plugin start (healStoredConfig)
  • Templates
    • SVG files, rendered to bitmaps with resvg-wasm (src/render/svgRenderer.ts). A user templates directory (templatesDir: empty for ~/.signalk/einklabel/templates, relative to ~/.signalk, or absolute) is scanned alongside the bundled templates/ directory; a user template with the same name as a bundled one takes priority. There is no upload UI
    • A template is either a single .svg file or a template family: a directory of variants named <width>x<height>-<colours>.svg (e.g. tides/416x240-BWRY.svg). The best variant for each device is picked at repaint time: exact size and colours, then exact size, then the nearest width with the closest aspect ratio. Dot-prefixed directories (.assets, .blank, .error) are never offered as templates
    • Each template can be assigned to one or more devices. There is no separate data config; a template’s own bindings declare what data it needs, re-read from the template on every repaint
    • Text uses the bundled serif, sans-serif and monospace fonts (resvg-wasm can’t see system fonts); the generic CSS family names are rewritten to the bundled fonts’ exact names
  • Bindings
    • A dynamic <text> element carries a flat key=value,key=value binding in a <desc> child, not a templating engine - e.g. source=resources,resource=tides,path=extremes[0].time,format=local_time. source/context default to signalk/self, a bare path with no = is shorthand for path=..., and array indices use bracket notation. The element’s own text is a placeholder for laying out the template in an editor. See src/render/binding.ts for the grammar
    • Keys: source, context, resource, provider, path, format, category, round, assets, default. An unknown key is an error; default= replaces a missing value
    • source=signalk (default): context=self reads via getSelfPath; any other context is read via getPath against that literal SignalK context, exactly as shown in the Data Browser (e.g. vessels.urn:mrn:imo:mmsi:232345678)
    • source=resources: resource=<name> reads any Resources API resource type (tides, waypoints, …) via app.resourcesApi.listResources in-process. provider=<plugin id> pins it to one resource provider; a pinned binding goes over the REST API instead, since the in-process call ignores the provider id (signalk-server 2.30.0)
    • source=label: facts about the physical label - manufacturer, label, width, height, colours, fonts, description, and position (rounded to about 1km so GPS jitter doesn’t force repaints)
    • source=einklabel: plugin-injected values - repainted (repaint time), local_zone (timezone abbreviation) and plugin_version
    • format= names a formatter from src/render/formatters.ts: local_time, day_mon, local_datetime_short, utc_offset, position, csv. Times use the vessel’s environment.time.timezoneRegion when published, otherwise the host’s timezone - never a tide station’s own timezone field
    • A signalk-sourced numeric value converts to its path’s preferred display unit by default, via GET {signalkApiUrl}/signalk/v1/api/vessels/<context>/meta (there’s no in-process equivalent); format=raw opts out. A value with no path of its own (e.g. resource-sourced) can take an explicit category=<name> (e.g. category=depth), resolved from {signalkApiUrl}/signalk/v1/unitpreferences/... client-side (src/unitCategories.ts). round= sets decimal places
    • An <image> element with a <desc> binding and an assets=<name> key picks one .svg from an asset directory (.assets/<name>/, user templates directory first, then bundled) by the resolved value, e.g. the bundled lunar_phases icons for environment.moon.phaseName. The element is dropped when the value is missing or has no matching file
  • SignalK API base URL
    • signalkApiUrl is optional. Left unset, resolveSignalkApiUrl (src/resolveApiUrl.ts) probes http://localhost:3000, http://localhost and https://localhost, in that order, at the first repaint and caches whichever responds. Always the loopback address, since the plugin runs on the same host as the server. Setting it explicitly skips probing but is still verified
    • It errors clearly if nothing responds (wrong port) or every probe is rejected (anonymous read access not enabled) - these endpoints need anonymous read access, since the plugin has no login flow. A failed resolution is retried on the next repaint rather than cached
    • Path metadata is best-effort (no server just means raw values); a category= or provider= binding without a reachable server fails the render
  • Repainting
    • Each device picks one repaint trigger: a change on a SignalK path (subscription, debounced by 2 seconds, ignoring transient null/missing values), or a fixed interval (every N hours at a configurable minute past the hour, checked by one shared one-minute timer rather than one per device)
    • Every trigger waits for a settle window after plugin start (settleSeconds, default 120), so a template isn’t rendered before other plugins (e.g. derived-data) have published its data. After that window each device is checked once; an interval device already painted for its current slot skips this catch-up
    • Before repainting, the template’s mtime and the assembled data (bindings plus label facts, before plugin-injected values like the repaint time are added) are hashed and compared against the last successful paint, persisted in the plugin data directory per device entry and BLE address (friendlyName:address). If both are unchanged the repaint is skipped, to save the label’s battery and avoid a full-panel flash. A template provider’s output always repaints, since the plugin can’t know whether it would differ
    • “Force repaint” bypasses the unchanged-data skip for one repaint, then clears itself once every target device has painted successfully
    • Only one repaint per device entry runs at a time; a trigger arriving mid-repaint queues one follow-up, run only if the current repaint succeeded
    • A paint is retried up to paintRetries times (default 3, 5 seconds apart), each attempt bounded by paintConnectTimeoutSeconds (default 30) plus a backstop deadline
    • If rendering fails (a broken template, a missing file, or a template provider’s render() rejecting), the bundled .error warning template is painted instead, so stale content is never left on screen unmarked. No success hash is saved, so the next trigger retries
  • Template providers
    • An extension package can offer content generated another way, alongside SVG templates, via registerTemplateProvider - e.g. @rhizomatics/signalk-einklabel-genai-plugin generating content from an LLM prompt. Its entries appear in the same “Template” dropdown, marked by the provider’s suffix. See Extending and src/render/templateProviders.ts
  • esl-cli, a local CLI for scanning, painting and testing templates outside the SignalK plugin
    • vendors, scan, paint (with the same image options as the plugin, plus --colours/--width/--height/--voffset model facts for hardware not yet in a driver’s table), render (template to PNG), fields (every binding in a template with its resolved value) and field (one binding spec). Bindings resolve against a live SignalK server over HTTP, or against local example JSON (-e examples). Extensions can add subcommands via --require. See Command Line Interface