mqtt-interfaces-core

September 16, 2026 · View on GitHub

Core library for writing xyz2mqtt interfaces ("adapters") in Node.js that follow the mqtt-smarthome convention (spec 2.x). It absorbs the ~80% every adapter repeats so an adapter is left with its device protocol and an item table.

Status: 0.x — API may still change. Adapters on the core: lgtv2mqtt 3, cul2mqtt 1, alexa-remote-mqtt 2, lgsb2mqtt 2.

Contents: What you get · Minimal adapter · Building an adapter (the complete guide) · Conventions implemented · API · Management UIs: she

What you get

  • createAdapter() — MQTT connection with LWT, <name>/connected 0/1/2, retained status/<item> ({val, ts, lc} JSON, or plain values with --no-json-payloads), set/<item> dispatch with plain and {val} payloads, status re-publish after reconnect, <name>/info, maintenance/set/loglevel + restart, Home Assistant device discovery (re)publishing, graceful shutdown on SIGINT/SIGTERM.
  • parseConfig() — yargs CLI with the canonical shared option set plus your own options, <ADAPTER>_* env vars, unprefixed MQTT_URL/MQTT_USERNAME/MQTT_PASSWORD/MQTT_TLS_CA fallback, --config-schema (JSON Schema of all options, with x-env / x-secret / x-file, for management UIs).
  • createLogger() — levels, journald detection (<N> priority prefixes, no own timestamp under systemd), <ADAPTER>_LOG_FORMAT=journal|text, runtime level changes.
  • payload helpersparsePayload, toBoolean, clampInt, toVolume, StatusTracker.
  • HA discovery helpersentity(), devicePayload(), availability(), discoveryId().
  • createInstaller()--install/--uninstall as systemd template unit <adapter>@<name>, /etc/<adapter>/<name>.env, shared /etc/mqtt-interfaces/broker.env.

Minimal adapter

#!/usr/bin/env node
import {createAdapter, parseConfig, createInstaller, entity, toVolume} from 'mqtt-interfaces-core';
import pkg from './package.json' with {type: 'json'};

const config = parseConfig({
  pkg,
  defaults: {name: 'foo'},
  options: {
    address: {alias: 'a', type: 'string', describe: 'device address', demandOption: true},
  },
  examples: [['\$0 -a 192.168.1.20 -u mqtt://broker', 'run in the foreground']],
});

createInstaller({service: pkg.name, envPrefix: config.$envPrefix}).handle(config);

const adapter = createAdapter({
  pkg,
  config,
  deviceLabel: 'foo',
  info: {address: config.address},
  discovery: ({get}) => ({
    device: {mf: 'ACME', ...(get('model') && {mdl: get('model')})},
    components: {
      volume: entity({
        id: `${pkg.name}_${config.name}`,
        name: config.name,
        item: 'volume',
        platform: 'number',
        label: 'Volume',
        command: true,
        jsonPayloads: config.jsonPayloads,
        extra: {min: 0, max: 100},
      }),
    },
  }),
  discoveryTriggers: ['model'],
  onSet: async (parts, value) => {
    if (parts[0] === 'volume') {
      await device.setVolume(toVolume(value));
    }
  },
  onShutdown: () => device.disconnect(),
});

const device = connectToDevice(config.address);
device.on('connect', () => adapter.setDeviceConnected(true));
device.on('close', () => adapter.setDeviceConnected(false));
device.on('volume', (v) => adapter.pubStatus('volume', v));

adapter.start();

Every module is also importable on its own (mqtt-interfaces-core/log, /payload, /config, /adapter, /hadiscovery, /install).

Building an adapter

Everything you need to know to write a new xyz2mqtt adapter, or to port an old one, so that it behaves like the others and works with she's Services page. cul2mqtt and lgtv2mqtt are the reference implementations — copy their layout.

1. What an adapter is

One process = one instance = one device (or one bridge to several devices) = one MQTT topic prefix <name> (the --name option, default per adapter, e.g. lgtv). Everything it publishes lives under <name>/; everything it accepts arrives under <name>/set/. Several instances of the same adapter run side by side as systemd template units <adapter>@<name> with their own config file.

The adapter owns its device protocol and an item table; the core owns MQTT, config, logging, discovery, installation and the maintenance topics. If you find yourself writing any of the latter in an adapter, it belongs in the core.

2. Project skeleton

xyz2mqtt/
├── index.js            entry point (#!/usr/bin/env node): createAdapter + the device part
├── config.js           parseConfig() with the adapter's options; exports OPTIONS + default
├── lib/
│   ├── install.js      createInstaller() wired to the adapter (serviceExtra, beforeStart)
│   ├── hadiscovery.js  pure: items → HA entity map
│   └── ...             pure protocol / mapping modules (testable without a device)
├── test/*.test.js      node:test unit tests for every lib module + the installer
├── example-*.json      example files for user-maintained files (map files) + JSON schemas
├── package.json        see below
├── README.md           options table, topics, HA, install, changelog pointer
├── CHANGELOG.md        `## x.y.z` sections; the release notes are generated from them
├── AGENTS.md           agent notes (optional but the fleet does it)
├── Dockerfile          node:22-alpine, env config (template below)
├── .dockerignore       node_modules, .git, .github, *.md, test, scripts, deploy.sh
├── deploy.sh           dev deploy to a host by tarball (from cul2mqtt)
├── eslint.config.js, .prettierrc, .editorconfig   copies of the core's
└── .github/workflows/ci.yml, release.yml, .github/release-notes.js   copies of the core's

package.json:

{
  "name": "xyz2mqtt",
  "version": "1.0.0",
  "description": "Interface between XYZ and MQTT",
  "type": "module",
  "main": "index.js",
  "bin": {"xyz2mqtt": "index.js"},
  "preferGlobal": true,
  "files": ["index.js", "config.js", "lib/", "example-map.json", "map.schema.json"],
  "engines": {"node": "^20.19 || ^22.12 || >=24"},
  "scripts": {
    "start": "node index.js",
    "lint": "eslint . && prettier --check .",
    "format": "prettier --write . && eslint --fix .",
    "test": "node --test",
    "deploy": "bash deploy.sh"
  },
  "keywords": ["mqtt", "mqtt-smarthome", "home-automation", "home-assistant", "xyz"],
  "mqttInterfaces": {"spec": "2.0", "envPrefix": "XYZ2MQTT", "needs": [], "serviceExtra": []},
  "dependencies": {"mqtt-interfaces-core": "^0.6.0"},
  "devDependencies": {
    "@eslint/js": "^9",
    "eslint": "^9",
    "eslint-config-prettier": "^10",
    "globals": "^16",
    "prettier": "^3"
  }
}
  • Plain JavaScript, ES modules, no TypeScript, no build step. Node ≥ 20.19.
  • Minimal dependencies: adapters run on Raspberry-class machines. mqtt and yargs come with the core — do not depend on them yourself.
  • The npm package name is the adapter name: it names the systemd template unit, the config directory /etc/<adapter>/, the state directory /var/lib/<adapter>/, the system user and the default env prefix (XYZ2MQTT_, non-alphanumerics → _). Lower case, no scope.
  • mqttInterfaces is optional metadata for catalogs (see Management UIs): spec — implemented spec version; envPrefix; needs — what the adapter talks to, shown as badges by catalogs: network (a device or service on the LAN), cloud (a vendor service on the internet), serial (a serial/USB device — add the dialout group via serviceExtra), bluetooth, usb; several may apply (["network", "cloud"]); serviceExtra — the extra [Service] lines the installer adds.

3. config.js — options

import {parseConfig} from 'mqtt-interfaces-core';
import pkg from './package.json' with {type: 'json'};

export const OPTIONS = {
  address: {alias: 'a', type: 'string', describe: 'device address (ip or hostname)', demandOption: true},
  port: {type: 'number', describe: 'device port', default: 8080},
  mode: {type: 'string', describe: 'protocol mode', choices: ['auto', 'legacy'], default: 'auto'},
  'poll-interval': {type: 'number', describe: 'seconds between state polls (0 = push only)', default: 60},
  'api-token': {type: 'string', describe: 'device API token', secret: true},
  'map-file': {
    alias: 'm',
    type: 'string',
    describe: 'JSON file with friendly item names (see example-map.json)',
    file: {format: 'json', example: 'example-map.json', schema: 'map.schema.json', describe: 'friendly item names'},
  },
  'publish-raw': {
    type: 'boolean',
    describe: 'additionally publish raw protocol messages on <name>/raw',
    default: false,
  },
};

export default parseConfig({
  pkg,
  options: OPTIONS,
  defaults: {name: 'xyz'},
  examples: [
    ['\$0 -a 192.168.1.20 -u mqtt://broker', 'run in the foreground'],
    ['sudo \$0 --install -n xyz -a 192.168.1.20 -u mqtt://broker', 'install as service xyz2mqtt@xyz'],
  ],
});

What you get and must not redo yourself:

  • The shared options every adapter has: --mqtt-url/-u/--url, --mqtt-username, --mqtt-password, --mqtt-client-id-prefix, --mqtt-tls-ca, --name/-n, --json-payloads (default on), --ha-discovery (default on), --ha-prefix, --maintenance (default on), --stats-interval (60 s, 0 = off), --verbosity/-v, --install, --uninstall, --config-schema, --help, --version. Override a shared default via defaults ({name: 'xyz'} at least).
  • Every option as environment variable <PREFIX>_<OPTION> (XYZ2MQTT_POLL_INTERVAL), typed; the unprefixed MQTT_URL, MQTT_USERNAME, MQTT_PASSWORD, MQTT_TLS_CA as fallback (that is what /etc/mqtt-interfaces/broker.env holds). Precedence: CLI > env > defaults. No config file format of your own.
  • config.<camelCase> values (config.pollInterval), plus config.$options and config.$envPrefix.
  • --config-schema: a JSON Schema of the instance configuration, one property per option with x-env, enum for choices, required for demandOption (--uninstall --name <n> is exempt from mandatory options — it needs only the name), and:
    • secret: true"x-secret": true — management UIs mask the value (--mqtt-password has it). Mark every credential, token, cookie, key.
    • file: {format, example, schema, describe}"x-file": {…} — the option holds the path of a file the user maintains. format is json, yaml, text or binary (shown, not edited); example and schema are paths relative to the package root — ship them in files. A UI can then offer an editor with validation and "create from example".
  • Rules: kebab-case option names; describe in lower case, one line; sensible defaults that do not point at anyone's personal infrastructure; booleans default to the safe side (--publish-raw off, --raw-set off); paths that the service needs at runtime default to the state directory (process.env.STATE_DIRECTORY, set by systemd) rather than the home directory.

4. lib/install.js — the systemd installer

import {createInstaller} from 'mqtt-interfaces-core';

export const SERVICE = 'xyz2mqtt';
export const ENV_PREFIX = 'XYZ2MQTT';

const installer = createInstaller({
  service: SERVICE,
  envPrefix: ENV_PREFIX,
  description: `${SERVICE} %i - XYZ to MQTT bridge`,
  documentation: 'https://github.com/you/xyz2mqtt',
  serviceExtra: ['SupplementaryGroups=dialout'], // only what the device needs
  // user: 'root', hardening: false,  // only for an adapter that runs other programs (mqttpc)
  // beforeStart: ({name, argv, stateDir, log}) => copy a pairing key into stateDir, …
});

export const {unitFile, envFile, installService, uninstallService, handle} = installer;

handle(config) is the first thing index.js calls: with --install/--uninstall it acts and exits, otherwise it returns false. What --install --name <n> <options> does, as root:

PathContent
/etc/systemd/system/<adapter>@.servicetemplate unit: Type=simple, EnvironmentFile=-/etc/mqtt-interfaces/broker.env, EnvironmentFile=/etc/<adapter>/%i.env, Environment=<PREFIX>_NAME=%i, ExecStart=<node> <index.js>, Restart=always, User=<adapter>, StateDirectory=<adapter>/%i, SyslogIdentifier=<adapter>@%i, hardening (NoNewPrivileges, ProtectSystem=full, ProtectHome, PrivateTmp), plus serviceExtra
/etc/<adapter>/<name>.envthe given options as <PREFIX>_* lines, 0640 root:<adapter>; existing file backed up to .bak
/var/lib/<adapter>/<name>/state directory, owned by the service user; STATE_DIRECTORY in the environment
system user <adapter>--system --no-create-home --shell /usr/sbin/nologin, shared by all instances

--uninstall --name <n> stops, disables and removes the instance and its env file; the template unit goes when the last instance is gone; the state directory is kept. Restart=always is what makes maintenance/set/restart (a clean exit 0) come back.

An adapter that runs other programs (mqttpc) is the exception to both defaults. The sandbox is inherited by every child, so ProtectHome hides /home from a backup script, ProtectSystem=full makes /usr read-only for it and NoNewPrivileges=true stops sudo working at all — a process controller cannot be sandboxed and still do its job. hardening: false drops that block, and user: 'root' runs the unit as root for the rare adapter that has no other way. Neither is something a device adapter should ever need; user is also the escape hatch for an existing account an adapter must share.

Adapter-specific installer work goes into beforeStart (e.g. lgtv2mqtt copies the TV pairing key into the state directory, alexa-remote-mqtt the login cookie) — never into a second script. If an option must not end up in the env file (because the unit sets it itself), pass envOptions.

5. index.js — the adapter

#!/usr/bin/env node
import {createAdapter, clampInt, toBoolean} from 'mqtt-interfaces-core';
import config from './config.js';
import pkg from './package.json' with {type: 'json'};
import {handle as handleInstall} from './lib/install.js';
import {discoveryModel} from './lib/hadiscovery.js';

handleInstall(config); // --install / --uninstall never reach the rest

const adapter = createAdapter({
  pkg,
  config,
  deviceLabel: 'xyz', // how the device is called in log lines
  info: () => ({address: config.address}), // extra fields for <name>/info
  discovery: ({get}) => discoveryModel({name: config.name, model: get('model'), jsonPayloads: config.jsonPayloads}),
  discoveryTriggers: ['model'], // status items whose change re-publishes discovery
  onSet: handleSet, // <name>/set/<parts...>
  onMqttConnect: ({reconnect}) => {}, // after subscriptions and status re-publish
  onShutdown: () => device.close(), // SIGINT/SIGTERM/restart: disconnect the device
});
const {log, pubStatus, clearStatus, setDeviceConnected} = adapter;

async function handleSet(parts, value) {
  switch (parts[0]) {
    case 'volume':
      return device.setVolume(clampInt(value, 0, 100));
    case 'power':
      return device.setPower(toBoolean(value));
    default:
      throw new Error('unknown item ' + parts.join('/')); // → logged at warn
  }
}

const device = connect(config.address);
device.on('connect', () => setDeviceConnected(true));
device.on('close', () => setDeviceConnected(false));
device.on('state', (s) => {
  pubStatus('volume', s.volume);
  pubStatus('power', s.power);
});

adapter.start();

createAdapter() options, in full:

OptionMeaning
pkgyour package.json (name, version, homepage)
configthe parseConfig() result
loglogger override (default createLogger with the env prefix and --verbosity)
deviceLabelname of the device in log lines ('tv', 'cul')
infoobject or function → extra fields of the retained <name>/info
discovery({get, config}) → device block | device block[] | null — see §7
discoveryTriggersstatus items whose change re-publishes discovery (coalesced by discoveryDelay, default 1 s)
onSet(parts, value, topic, raw)handles <name>/set/<parts…>; value is the parsed payload (plain or {val}), return a promise; throw/reject → warn log
subscriptions{pattern: handler} — an adapter's own topics under <name>/ besides set/# ({'paramset/#': …, 'rpc/+/+/+': …}); MQTT wildcards, handler called like onSet with the levels the wildcards captured as parts
listen{pattern: handler} — topics anywhere on the broker for a sink ({'+/status/#': …, '$SYS/#': …}); subscribed as given, handler gets (topic, value, raw, packet)
onMqttConnect({reconnect})after every (re)connect once subscriptions are done and status is re-published
readbacktrue: on the first connect, read back the retained <name>/status/# a previous run left on the broker (collected until it has been quiet for readbackQuiet ms, default 1 s, then unsubscribed); readbackDone() resolves with it, staleStatus() names what this run has not published, clearStatus() clears those too. Off by default
onShutdown()called on SIGINT/SIGTERM/maintenance/set/restart before connected 0 is published; may return a promise (2 s budget)

The object it returns: log, name, topic(...parts), get(item), pubStatus(item, value, {retain, extra, ts, lc}), clearStatus(item), staleStatus(), readbackDone(), republishStatus(), publishInfo(), publishDiscovery({force}), markDiscoveryDirty(), setDeviceConnected(bool), publish(topic, payload, opts) (raw, for <name>/raw-style extras), start(), shutdown(reason, exitCode), and the getters mqtt, mqttConnected, deviceConnected, shuttingDown.

Lifecycle: start() connects to the broker (client id <prefix><name>_<random>, LWT <name>/connected 0), publishes connected (1, or 2 once you called setDeviceConnected(true)) and info, subscribes <name>/set/# and <name>/maintenance/set/+, publishes discovery, and re-publishes all retained status items after a reconnect. Connect to your device independently of the broker and keep reconnecting to it forever with a modest interval (10 s) — the adapter is a daemon, an unreachable device is normal operation, not a reason to exit.

6. Items, topics, payloads — the conventions

  • <name>/connected (retained): 0 LWT/shutdown, 1 broker connected but device not, 2 device connected. Call setDeviceConnected() on every transition; the core does the rest.
  • <name>/status/<item> (retained): every state value. item is snake_case, may be nested (<protocol>/<address>/<field> for bridges, bridge/devices for bridge-level state), never contains +, # or empty levels. Values: booleans as true/false, numbers as numbers, everything else as strings or JSON objects/arrays. Payload is {"val": …, "ts": <ms>, "lc": <ms>} (lc = last change) unless the instance runs with --no-json-payloads; pubStatus() builds it.
  • Events (a button press, a spoken command, a progress tick — things that have no "current value"): pubStatus(item, v, {retain: false}). They are not re-published after a reconnect but stay readable via get() for discovery.
  • clearStatus(item) when an item disappears for good (a device that left a bridge): clears the retained payload.
  • Leftovers of a previous run: an adapter whose item set is dynamic (clients, devices, apps) cannot know what it retained before a restart — a client that left meanwhile stays present: true on the broker for ever. createAdapter({readback: true}) reads the retained <name>/status/# back on the first connect; once the adapter has its first complete picture it clears what is stale: adapter.readbackDone().then(() => { for (const item of adapter.staleStatus()) clearStatus(item); }). Filter staleStatus() by the prefixes the adapter owns dynamically; a fixed item is published anyway.
  • Extra fields: pubStatus(item, v, {extra: {hm: {...}}}) adds an adapter's own meta data next to val/ts/lc in the JSON payload ({val, ts, lc, hm}; ignored with --no-json-payloads, never overrides the three). Values that carry a device-side time pass {ts, lc} (ms) instead of the adapter's clock.
  • Own topics besides set/# (a command tree that is not a set, an RPC pass-through): createAdapter({subscriptions: {'paramset/#': handler}}) subscribes <name>/paramset/# and dispatches like onSet. Keep them rare; set/<item> is the convention.
  • Sinks — an adapter whose subject is other adapters' traffic rather than a device of its own (influx4mqtt, mqtt2elasticsearch): createAdapter({listen: {'+/status/#': handler}}) subscribes the pattern as given, anywhere on the broker, not under <name>/. The handler gets (topic, value, raw, packet) rather than the captured levels a subscriptions handler gets — what a sink works on is the whole topic, and packet.retain is what separates live traffic from the broker replaying its backlog on subscribe. The adapter's own set and maintenance are matched first, so a wide pattern cannot swallow its own commands; its status topics are ordinary traffic and do reach the sink.
  • <name>/set/<item>[/...]: commands. Accept plain payloads (50, true, on, text) and {"val": …}; use toBoolean, clampInt, toVolume for tolerant parsing. A set on an item should result in a status update from the device's feedback, not from echoing the command.
  • <name>/info (retained): name (npm package), version, spec, node, host, pid, started, maintenance, plus your info extras (tv, cul, mode). Keep it small and static; it is not a status topic.
  • <name>/maintenance/set/loglevel (error|warn|info|debug) and …/restart — provided by the core; --no-maintenance turns them off for untrusted brokers.
  • <name>/maintenance/stats (retained, every --stats-interval seconds, default 60, 0 = off): rss, heapUsed, heapTotal (bytes), cpu (percent of one core over the interval), eventLoopLag (worst ms in the interval), uptime (s), ts — process stats for dashboards such as she's Instances tab. Provided by the core, nothing to do in the adapter.
  • Raw/protocol topics (<name>/raw, <name>/set/raw) are opt-in (--publish-raw, --raw-set), never on by default: a raw transmitter is a security surface.
  • Topic names are API: do not rename items outside a major release; document the migration table in the README when you do (alexa-remote-mqtt 2.0's README shows the pattern).

7. Home Assistant discovery — lib/hadiscovery.js

Device-based discovery (<ha-prefix>/device/<id>/config, HA 2024.4+), on by default. Keep the builder pure and test it: items in, device block out.

import {entity, discoveryId} from 'mqtt-interfaces-core';

export function discoveryModel({name, model, jsonPayloads}) {
  const id = discoveryId('xyz2mqtt', name); // xyz2mqtt_<name>, sanitised
  const e = (item, platform, label, more = {}) => entity({id, name, item, platform, label, jsonPayloads, ...more});
  return {
    device: {mf: 'ACME', ...(model && {mdl: model})}, // ids, name, sw and the origin block are added
    components: {
      volume: e('volume', 'number', 'Volume', {command: true, extra: {min: 0, max: 100}}),
      power: e('power', 'switch', 'Power', {command: true, extra: {pl_on: 'true', pl_off: 'false'}}),
      signal: e('signal', 'sensor', 'Signal', {category: 'diagnostic', extra: {unit_of_meas: 'dBm'}}),
    },
  };
}
  • entity() sets stat_t (<name>/status/<item>), val_tpl ({{ value_json.val }} with JSON payloads), cmd_t (with command: true), uniq_id (<id>_<item>), ic, ent_cat; extra carries any other HA field in its abbreviated form (pl_on, min, options, unit_of_meas, dev_cla, …). Stateless platforms (button, notify) get no state topic.
  • Availability comes from <name>/connected (online when ≥ 2; availabilityMin: 1 for a device that is legitimately off while the bridge runs, e.g. a TV that wakes on LAN).
  • Bridges (one adapter, many physical devices — cul2mqtt, alexa-remote-mqtt): return an array of device blocks, each with its own id (discoveryId('xyz2mqtt', name) + '_' + device), device.via_device pointing at the bridge's id, and optionally its own availability array (bridge availability + a per-device <name>/status/<dev>/online topic). Devices missing from a later result get their retained announcement cleared.
  • Re-publish when the model changes: list the items in discoveryTriggers, or call markDiscoveryDirty() + publishDiscovery() when items appear dynamically (debounce it).
  • Entity names in label are what the user sees; keep them short and device-relative ("Volume", not "Living room TV volume" — HA prefixes the device name).

8. Device discovery — lib/discovery.js

Finding the device is the same job in every adapter — an SSDP M-SEARCH, a DNS-SD browse, a vendor's UDP broadcast probe, a sweep of the local subnet, a look at the ARP cache, or, for a USB stick, the name udev gave it — so the core owns the sockets, the rate limiting, the timeouts and the merging, and the adapter only declares what its devices look like:

// config.js
export const DISCOVERY = {
  ssdp: {st: 'urn:lge-com:service:webos-second-screen:1'},
  ports: {webos: 3000},
};

export const OPTIONS = {
  // `discover: true` marks the property the scan fills — the one that also accepts "auto"
  tv: {alias: 'a', type: 'string', describe: 'address of the tv, or "auto"', discover: true},
  // …
};

export default parseConfig({pkg, options: OPTIONS, discovery: DISCOVERY, defaults: {name: 'lgtv'}});

Passing the hint as discovery adds --discover, --discover-json, --discover-timeout, --discover-address and --discover-ip (meta options: never written to an env file, never part of an instance configuration) — and marks the option flagged discover: true with x-discover in --config-schema, whose value is the kind of scan the hint asks for: "network", "serial", or both. That marker is how a management UI knows an adapter can be discovered and which affordances to show (she I13); a schema without one is not discovery-capable. A few lines in index.js use the options:

import {runDiscovery, autoAddress} from 'mqtt-interfaces-core';
import config, {DISCOVERY} from './config.js';

// before handleInstall(): `--install --tv auto` then persists what was found, instead of
// leaving every service start to scan the network and fail when the device is briefly away
if (config.discover) {
  await runDiscovery({hint: DISCOVERY, config, log}); // prints and exits
}
if (config.tv === 'auto') {
  try {
    // `config` lets the scan honour --discover-timeout, --discover-address and --discover-ip;
    // the result is the device's fqdn when dns knows it, its address otherwise
    config.tv = await autoAddress(DISCOVERY, {config, log});
  } catch (err) {
    log.error('--tv auto:', err.message); // none, or several — never guess
    process.exit(1);
  }
}

--discover is exempt from mandatory options — the address it is about to go looking for must not be required to run it.

A sink has no state to publish. --json-payloads, --ha-discovery and --ha-prefix configure things an adapter that only forwards messages never does. Left in they appear in --help, in --config-schema — so in a config UI, where someone will reasonably try to use them — and in the env file, all configuring nothing. publishesStatus: false drops the first, publishesDiscovery: false the other two:

export default parseConfig({
  pkg,
  options: OPTIONS,
  defaults: {name: 'influx'},
  publishesStatus: false, // no status items: it writes to influx, not to mqtt
  publishesDiscovery: false, // no `discovery` passed to createAdapter, so no HA entities
});

They are independent: mqttpc publishes items (pid, exit, stdout) but announces no entities, so it sets only the second. Both default to true, so nothing changes for an ordinary adapter.

A scan that needs credentials. hint.needs names the options the scan itself consumes, and they show up as x-discover-needs on the marked property in --config-schema. --discover drops mandatory options — the address it is going looking for must not be required to look for it — but a cloud hint cannot list an account without its login, so those stay demanded. A management UI reads x-discover-needs to know which fields to collect before offering the scan and to pass into the --discover run; they are options of the instance being configured either way, so nothing extra is asked of the user.

Bridges take all of them. autoAddress refuses to guess when the network answers with several, which is right for an adapter that drives one device and wrong for one instance that bridges a whole LAN. There autoAddresses (plural) is the counterpart: it returns every hit as an array and only fails when there is none, since an empty list starts a bridge with nothing to bridge. Declare discover: true on an array option and the property's type in --config-schema tells a management UI which of the two it is getting.

// govee2mqtt: one instance, every lamp on the LAN
if (config.address.length === 1 && config.address[0] === 'auto') {
  config.address = await autoAddresses(DISCOVERY, {config, log});
}

The hint:

keywhat it does
ssdp{st, match(headers, address)} — M-SEARCH to 239.255.255.250:1900, answers become candidates
mdns{service: '_googlecast._tcp', match(entry)} — DNS-SD browse; PTR → SRV → A is resolved into {address, name, port, txt}
udp{port, payload, parse(message, rinfo), address, bindPort} — broadcast probe; parse returns the fields or null to drop
ports{label: port} probed on every candidate → services: {label: true|false}; a candidate with none open is dropped (requirePort: false keeps it)
requirePortfalse keeps a candidate whose declared ports are all closed — the announcement was proof enough
ouiMAC prefixes — matching entries of the ARP cache become candidates
cloud{list({timeout})}[{id, name, model, …}] — the vendor's device list, for hardware not on the LAN at all; failures propagate
needsoption names the scan consumes (['email', 'password']) — kept mandatory under --discover, published as x-discover-needs
serial{contains: ['busware', 'CUL'], match, dir} — USB serial adapters from /dev/serial/by-id
probeasync (address, entry) => fields | null — the last word; null drops the candidate

bindPort is for the vendors who answer to a fixed port rather than to the one the probe came from. Govee's LAN API is the case that forced it: the scan goes to 4001 and every device replies to 4002, so an ephemeral socket hears nothing at all. The port may already be held by a running instance of the same adapter, for the same reason — the probe socket sets reuseAddr, which is enough to share it on most stacks, and where it is not the probe is logged as failed and the other methods carry on.

Every declared method runs in parallel and contributes candidates; they are merged per address (sources: ['mdns', 'oui'] records who found it). A TCP sweep of the local subnets runs only when ports are declared and nothing else answered (sweep: true forces it, false disables it); subnets larger than 4096 hosts are skipped with a warning rather than swept.

Probes go to the method's own group or broadcast address and to the broadcast address of every local subnet — 255.255.255.255 is dropped by some stacks and never leaves the wire on others. Nothing of that crosses a router, so --discover-address takes what does:

--discover-address …what it does
172.16.24.145probes that device, and makes it a candidate in its own right — confirmed by the declared ports
172.16.20.0/24sweeps the range for the declared ports, whatever the sweep rule says

Both are for devices a router away, which is not a rare case: in the deployment both pilots below run in, the CCU and the soundbar each live on their own VLAN.

mDNS is the exception, and only with help: an mDNS reflector (avahi with enable-reflector) does bridge a browse across VLANs. Reflected answers arrive as multicast on port 5353 rather than as unicast to the query's source port, so mdnsQuery listens on both — a socket joined to the group next to the one that asked. Without that second socket, everything behind a reflector is invisible; with it, a soundbar one VLAN away is found by name and model. Do not rely on it though: measured over 20 second browses through a reflector, the same device answered one and not the next, so --discover-address stays the dependable route. Each query is repeated tries times (3) spread over the timeout for the same reason.

Names. Every network candidate carries the names it answers to, verified by a round trip: reverse the address, resolve each name back, keep it only when the address is among the answers. That one check subsumes every "is DNS working here" question — a resolver can serve public names perfectly and have no PTR zone for the LAN, or a PTR pointing at a name nothing resolves.

fieldwhen it is there
addressalways — the identity of the candidate, never replaced
fqdnthe qualified name round-trips: safe to put in a config, and it outlives a dhcp lease
hostnamethe short label round-trips here — which depends on the asking host's search list, so it is offered, never preferred

The short form is the trap: audiocast resolves on a host with search example.lan and nowhere else, so a config holding it breaks the moment the adapter runs elsewhere. --address auto takes the fqdn when there is one and the address otherwise, --discover-ip pins the address, and a management UI gets all three and lets the user choose (she I13). .local names are skipped — they verify here and then fail inside a container on the same host — and the forward half uses dns.lookup, the resolution path an adapter actually uses at runtime.

Serial sticks. A USB adapter needs no scanning: udev already named it, and /dev/serial/by-id/usb-busware.de_CUL868-if00 is the name to configure an adapter with — it survives a replug and a reboot, while the /dev/ttyACM0 it points at can swap places with another stick's. So serial filters that directory (contains, all words, case-insensitive; match for a regexp or a predicate) and yields the stable path as the candidate's address, with the device node it resolves to alongside:

$ cul2mqtt --discover
/dev/serial/by-id/usb-busware.de_CUL868-if00  → /dev/ttyACM0  (serial)

Such a candidate is exempt from ports and the sweep — there is nothing to knock on, being plugged in is the proof. macOS has no by-id directory, so /dev/cu.usb* is listed instead, where the name is all there is; other platforms find nothing rather than guess.

The worked example is Homematic, straight from hm-discover: a magic datagram to UDP 43439, and the CCU's interfaces read off the ports that answer.

const EQ3_PROBE = Buffer.from([0x02, 0x8f, 0x91, 0xc0, 0x01, 0x65, 0x51, 0x33, 0x2d, 0x2a, 0x00, 0x2a, 0x00, 0x49]);

export const DISCOVERY = {
  udp: {
    port: 43439,
    payload: EQ3_PROBE,
    parse(message) {
      if (message.subarray(0, 5).toString('hex') !== '028f91c001') {
        return null; // not an eQ-3 answer
      }
      const [type, serial] = message.subarray(5).toString('binary').split('\0');
      return {type, serial};
    },
  },
  ports: {ReGaHSS: 1999, 'BidCos-Wired': 2000, 'BidCos-RF': 2001, 'HmIP-RF': 2010, VirtualDevices: 9292, CUxD: 8701},
};
$ hm2mqtt --discover --discover-address 172.16.24.145
172.16.24.145  eQ3-HmIP-CCU3-App  serial 3014F711A0001F58A992F585  [ReGa BidCos-RF BidCos-Wired HmIP-RF VirtualDevices]  (udp)

--ccu-address auto uses the same scan and the same options. Two bugs the port to a hint fixed: hm-discover's checkservice passed this.timeout, which is undefined there, so the TCP probes had no timeout and their sockets were never destroyed; and its probe datagram was built as Buffer.from([..., 'e', 'Q', '3', ...]), where each string becomes 0x00 — a CCU still answers, but with a shorter reply that has the firmware version cut off.

For anything the hint cannot express, the pieces are exported on their own: ssdpSearch, mdnsQuery, udpProbe, tcpProbe, arpTable, localSubnets, subnetHosts and pool (the concurrency limiter). They speak the protocols directly — no discovery stack per protocol on a Raspberry Pi.

9. Logging

adapter.log is a createLogger() (levels debug/info/warn/error; journald format with <N> priorities and no own timestamp when running under systemd; text with colours on a TTY; <PREFIX>_LOG_FORMAT=journal|text overrides). Rules the fleet follows:

  • debug: every raw exchange, prefixed mqtt < / mqtt > (the core) and <device> < / <device> > (you). Connection attempts.
  • info: lifecycle — starting, connected, subscribed, discovery published, shutting down.
  • warn: an unreachable or misbehaving device, a rejected set, a maintenance action. Outcomes, not attempts.
  • error: only things that need a human (bad config, unrecoverable state). Never swallow device errors — log them once, dedupe repeats (log the first, then debug until it changes).

10. Tests, lint, CI, release

  • config.js parses the command line at import time: a test that imports OPTIONS from it must satisfy mandatory options first (process.env.XYZ2MQTT_ADDRESS = … before a dynamic await import('../config.js')), or the import exits with "Missing required argument".
  • node --test, files in test/*.test.js. Test the pure modules exhaustively (items, commands, discovery model, mapping) and the installer through its deps/exec hooks; keep the device transport out of unit tests. The core is tested the same way (npm test here, 60 tests).
  • npm run lint = eslint (flat config, @eslint/js recommended + prettier) + prettier --check. Copy eslint.config.js, .prettierrc (4 spaces, single quotes, no bracket spacing, width 120; 2 spaces for json/yml/md) and .editorconfig from this repo. Let a failing lint stop you.
  • CI (.github/workflows/ci.yml): lint + test on Node 20/22/24 on push and PR.
  • Release (.github/workflows/release.yml, copy from here with .github/release-notes.js): bump version in package.json + lockfile, add a ## x.y.z section to CHANGELOG.md (### Breaking / ### Added / ### Changed / ### Fixed), commit, git tag vX.Y.Z, git push --tags. The workflow lints, tests, publishes to npm with provenance (trusted publishing — configure the repo as a trusted publisher on npmjs.com once), builds and pushes the multi-arch Docker image to ghcr.io (see below) and creates the GitHub release with the CHANGELOG section plus the commits since the previous tag. Every adapter ships both: npm and an image. The core and other libraries are npm only — they have no Dockerfile and no docker job.
  • Versioning: semver; renaming topics or options is a major; new items/options a minor; fixes a patch. Keep engines.node in sync with the core.

11. Docker

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY index.js config.js ./
COPY lib/ ./lib/
ENV NODE_ENV=production \
    XYZ2MQTT_MQTT_URL=mqtt://localhost \
    XYZ2MQTT_NAME=xyz \
    XYZ2MQTT_VERBOSITY=info
USER node
ENTRYPOINT ["node", "index.js"]

Config only via environment; a state directory as a volume when the adapter persists anything (XYZ2MQTT_STATE_DIR=/data, VOLUME /data — with RUN mkdir /data && chown node:node /data before it, otherwise docker creates the mount point root-owned and the node user cannot write); --restart unless-stopped so maintenance/set/restart comes back. Serial devices: --device + --group-add. Host networking when the device protocol needs multicast, broadcast or callbacks.

Every adapter publishes its image to ghcr.io/hobbyquaker/<repo> — the packages show up on the GitHub project page, no extra registry account, no rate limit for pulls. The release workflow does it on every tag, for amd64, arm64 and armv7 (Raspberry Pi), from this job:

permissions:
  contents: write
  packages: write # ghcr.io
  id-token: write

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.tag || github.ref }}
      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=semver,pattern={{version}},value=${{ inputs.tag || github.ref_name }}
            type=semver,pattern={{major}}.{{minor}},value=${{ inputs.tag || github.ref_name }}
            type=raw,value=latest
      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64,linux/arm/v7
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

GITHUB_TOKEN is enough — no secret to configure. The github-release job needs [npm, docker], so a failed image build fails the release. The first push creates the package as private: make it public once under Package settings → Change visibility, and link it to the repository. Tags are x.y.z, x.y and latest; qemu makes the arm builds slow but keeps the workflow to a single runner. Document the docker run line in the README next to the npm and systemd install.

12. Development deploys

deploy.sh (take cul2mqtt's) runs the tests, npm packs the adapter — and every file:../… dependency, so you can develop against an unreleased sibling checkout of the core — copies the tarballs to a host, installs into /usr/local/lib/node_modules/<adapter> and restarts the <adapter>@* units. she marks such installs as manual and asks before replacing them with the npm version.

13. Checklist before the first release

  • npm run lint, npm test green; CI workflow in place.
  • README: install (npm install -g, --install, Docker), options table, topics with payload examples, Home Assistant section, --config-schema mention; CHANGELOG started.
  • Dockerfile + .dockerignore present, the release workflow has the docker job, and the ghcr package is public after the first tag.
  • Every credential option has secret: true; every user-maintained file has file: {…} with a shipped example (and a schema when the format allows one).
  • mqttInterfaces field in package.json; files lists everything the runtime and the management UI need.
  • Defaults contain nothing personal; raw/transmit topics are opt-in.
  • Runs as the unprivileged service user with the hardening the template unit applies (ProtectHome, ProtectSystem=full) — state goes to STATE_DIRECTORY, not $HOME.
  • maintenance/set/restart brings the instance back (systemd) and the device reconnects on its own after an outage.
  • Checked in she: the instance shows up, the config form renders every option sensibly, secrets are masked, the map file is editable, logs stream.

Conventions implemented

topicretainednotes
<name>/connectedyes0 LWT/shutdown, 1 mqtt only, 2 mqtt + device
<name>/status/<item>yes{val, ts, lc} JSON (+ adapter extras), or plain value with --no-json-payloads
<name>/set/<item>[/...]plain value or {"val": ...}; handled by the adapter's onSet
<name>/infoyesname, version, spec, node, host, pid, started, maintenance, ...
<name>/maintenance/set/loglevelerror/warn/info/debug; --no-maintenance disables
<name>/maintenance/set/restartgraceful shutdown + exit 0; the supervisor restarts the process
<name>/maintenance/statsyesrss, heapUsed, heapTotal, cpu, eventLoopLag, uptime, ts every --stats-interval s (0 = off)
<ha-prefix>/device/<id>/configyesHA device discovery, on by default; --no-ha-discovery clears it

discovery() returns one device block, or an array of them for a bridge that sees several physical devices (one config topic per device, device.via_device pointing at the bridge; devices missing from a later result are cleared). A device block may carry its own availability array (instead of the default <name>/connected entry) so that one device of a bridge can be shown as unavailable while the bridge itself is fine; with more than one entry avty_mode: 'all' is added.

pubStatus(item, value, {retain: false}) publishes an event (a spoken command, a progress tick): it is not re-published after an mqtt reconnect, but the value stays readable via get(). clearStatus(item) forgets an item and clears its retained payload.

Shared CLI options: --mqtt-url/-u/--url, --mqtt-username, --mqtt-password, --mqtt-client-id-prefix, --mqtt-tls-ca, --name/-n, --json-payloads, --ha-discovery, --ha-prefix, --maintenance, --stats-interval, --verbosity/-v, --install, --uninstall, --config-schema. All of them also as <ADAPTER>_<OPTION> environment variables; MQTT_URL, MQTT_USERNAME, MQTT_PASSWORD, MQTT_TLS_CA unprefixed as fallback (the client id prefix is per instance).

API

Export (from mqtt-interfaces-core)Purpose
createAdapter(opts)the façade — see §5
matchTopic(pattern, levels)the wildcard matcher behind subscriptions (+, trailing #) → captured levels or null
parseConfig({pkg, options, defaults, examples, epilog, check, scriptName, envPrefix})CLI + env config, --config-schema
configSchema({pkg, envPrefix, options, defaults})the JSON Schema without parsing (tests)
SHARED_OPTIONS, SHARED_ENV, applySharedEnv(env, prefix)the canonical option set / broker env fallback
createInstaller({service, envPrefix, description, documentation, envOptions, environment, serviceExtra, beforeStart})systemd template unit installer; returns {unitFile, envFile, unitName, installService, uninstallService, handle, UNIT_PATH, CONF_DIR, STATE_DIR}
envVarName(option, prefix), instanceName(name)helpers of the installer
createLogger({envPrefix, format, color, level, write}), detectFormat(), LEVELSlogging
parsePayload(raw), toBoolean(v), clampInt(v, min, max), toVolume(v), StatusTrackerpayload helpers (StatusTracker: update, get, payload, isRetained, delete)
entity({...}), devicePayload({...}), availability(name, min), discoveryId(adapter, instance), discoveryTopic(prefix, id)Home Assistant discovery
discover(hint, opts), discoverOne(hint, opts), autoAddress(hint, opts), autoAddresses(hint, opts), runDiscovery({hint, config, log})device discovery — see §8
ssdpSearch, mdnsQuery, udpProbe, tcpProbe, arpTable, localSubnets, subnetHosts, pool, DISCOVERY_OPTIONSthe discovery pieces on their own
SPEC_VERSION'2.0'

Management UIs: she

There is no standalone fleet manager: she (Services page, optional) manages instances of adapters built on this core — inventory from <name>/info and <name>/connected, restart and log level over the maintenance topics, and, via the systemd installer layout, config forms from --config-schema, install/uninstall, updates, file editing and per-instance broker credentials on the host. Everything it needs is part of the convention described above; the specifics:

  • Catalog membership is simply depending on mqtt-interfaces-core: she lists the packages of the npm publishers the user trusts (services.trustedPublishers, default the fleet's author) whose latest version depends on the core. No keyword, no registration. The mqttInterfaces field is optional metadata for the catalog (needs, serviceExtra).
  • Config forms come from --config-schema: x-env maps a property to its env variable, x-secret masks it, x-file makes it editable in a Monaco editor (JSON validated against the shipped schema, YAML linted) with "create from example".
  • Host layout she relies on: the template unit with EnvironmentFile=/etc/<adapter>/%i.env, /etc/<adapter>/<name>.env, /var/lib/<adapter>/<name>/, the wrapper or symlink /usr/local/bin/<adapter>. she only reads and writes inside the two directories.
  • Broker credentials: she can write its own broker settings into an instance's env file, or create a dedicated Mosquitto dynsec identity per instance with an ACL limited to <name>/# and homeassistant/# — which is exactly what the conventions above make an adapter need.

License

MIT © Sebastian Raff