@nolag/iot

Device telemetry and command dispatch with acknowledgement tracking.

Overview

Connect devices and controllers over live channels. Devices push telemetry (sensor readings, status updates or any structured measurement) while controllers send commands and get a Promise that settles when the device acknowledges. Two roles take part in a device group: devices report data and acknowledge commands, and controllers watch telemetry and issue commands. A wrapper plays one role; a process that needs both runs two wrappers on two clients with different device IDs. Groups organise devices by fleet, location or function. Your app owns one core NoLag client and injects it into NoLagIoT; the wrapper attaches its behaviour to that connection and never opens or closes the socket.

For a device without a JavaScript runtime, use the Go or Python SDK, or speak the wire protocol directly. kraken's MQTT listener accepts no connections in v0.9.0.

Key features

  • Telemetry broadcast from devices to every group subscriber
  • Promise-based command dispatch with a configurable acknowledgement timeout
  • Commands routed to their target device with filters
  • Device presence per group and an online lobby
  • Several groups per connection

How it works

NoLagIoT attaches to the injected client and subscribes to the online lobby. joinGroup(name) returns a DeviceGroup: each group is a room, and the group subscribes to three topics in it.

TopicPurpose
telemetrySensor readings and status updates
commandsCommands, published with the target device's ID as filter
_cmd_ackAcknowledgements, published with the sending controller's device ID as filter

Routing uses filters. A device subscribes to commands filtered on its own deviceId, so it receives only the commands addressed to it. A controller subscribes to _cmd_ack filtered on its own deviceId, so it receives the acks for the commands it sent. Controllers subscribe to commands without a filter and devices subscribe to _cmd_ack without a filter, and on kraken a subscriber without filters receives filtered publishes too: every controller sees every command and every device sees every ack. Only devices emit command, and only the controller that sent a command emits commandAck for it. Because of this, a device ID must be a valid filter value (no /, #, + or |).

When a controller calls sendCommand() the wrapper stores a pending Promise keyed on the command ID and publishes the command. When the target device calls ackCommand(), the Promise resolves on 'acked' or 'completed', rejects on 'failed', or rejects after commandTimeout. The first ack settles the command; later acks for the same ID are ignored. Everything is published with echo: false; telemetry you send is stored in your own buffer without emitting telemetry.

Topics to grant

The wrapper expects these names in its app (the nolag-iot-sdk blueprint):

KindNames
Topicstelemetry, commands, _cmd_ack (all three subscribed in every joined group)
Roomsfactory-floor, plus every group name you pass to joinGroup()
Lobbyonline (subscribed on every connect)

Both roles subscribe to all three topics. Devices publish on telemetry and _cmd_ack, and controllers publish on commands, so pubSub on the group is the simple grant; split it per topic if you want devices unable to send commands. If the online lobby is not granted, ready() resolves only after the 10 second lobby subscribe timeout.

With kraken's static auth file (format). Device presence only works in rooms that have a rule carrying room_slug and room_id, and every device and controller token must use the same appId:

{
  "tokens": {
    "sensor-01-token": {
      "actorTokenId": "sensor-01",
      "projectId": "demo-project",
      "actorType": "device",
      "appId": "iot-app",
      "appName": "iot",
      "allowedTopics": [
        { "pattern": "iot/factory-floor/#", "permission": "pubSub", "room_id": "iot-factory-floor", "room_slug": "factory-floor" }
      ],
      "allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "iot-online" }]
    }
  }
}

With a @nolag/core project document (format), as one entry in apps:

{
  "slug": "iot",
  "name": "IoT",
  "accessMode": "open",
  "topics": ["telemetry", "commands", "_cmd_ack"],
  "rooms": [{ "slug": "factory-floor", "name": "Factory Floor" }],
  "lobbies": [{ "slug": "online", "name": "Online", "rooms": ["factory-floor"] }]
}

Command filters are routing, not access control: any actor that can subscribe to commands without a filter sees every command in the group. Separate fleets that must not see each other into different groups (rooms) with their own grants, or into access scopes.

Installation

npm install @nolag/iot @nolag/js-sdk

@nolag/iot peer-depends on @nolag/js-sdk ^1.12.0. Both packages are MIT licensed.

Quick start

The device and the controller are two clients with different device IDs. Both join the factory-floor group.

import { NoLag } from '@nolag/js-sdk'
import { NoLagIoT } from '@nolag/iot'

const client = NoLag(DEVICE_TOKEN, { url: 'ws://localhost:8080/ws' })

const iot = new NoLagIoT({
  client,
  appName: 'iot',        // the App Slug the topics are granted under
  deviceId: 'sensor-01',
  role: 'device',        // one role per wrapper; 'device' is the default
})

await client.connect()   // the app owns the connection
await iot.ready()        // wrapper setup complete

const group = iot.joinGroup('factory-floor')

// Publish readings: sensor id first; unit and tags are options
group.sendTelemetry('temperature', 22.4, { unit: 'celsius', tags: { line: 'A' } })
group.sendTelemetry('humidity', 61, { unit: 'percent' })

// Commands addressed to this deviceId. Ack once: 'acked' or 'completed'
// resolves the controller's Promise, 'failed' rejects it.
group.on('command', async (cmd) => {
  console.log('Command:', cmd.command, cmd.params, 'from', cmd.sentBy)
  try {
    const result = await handleCommand(cmd.command, cmd.params)
    group.ackCommand(cmd.id, 'completed', result)
  } catch (err) {
    group.ackCommand(cmd.id, 'failed', err instanceof Error ? err.message : String(err))
  }
})

Tear down in the usual order: iot.detach() releases the wrapper's handlers and pending command timers, then client.disconnect() closes the socket.

Reconnects

After the core client reconnects, the wrapper subscribes to the online lobby again, re-sends presence for every joined group and emits reconnected. It does not subscribe to its group topics again: it assumes the broker restored them. kraken v0.9.0 restores subscriptions on reconnect only from what its auth backend returns, and the static auth file returns none, so on a static-auth kraken telemetry, commands and acks stop after a reconnect unless you subscribe again (see Production).

In 1.2.0 the wrapper's handlers are registered on the core client by topic and survive the reconnect; only the broker-side subscriptions are gone. Re-issue them from a connect handler on the core client (not reconnect, which fires before the socket is back), with the same role-based filters the wrapper uses. kraken treats a repeated subscribe as a replacement, so this is also safe where the backend did restore them.

client.on('connect', () => {
  const me = iot.localDevice
  if (!me) return
  // Empty on the first connect; holds the joined groups after a reconnect
  for (const group of iot.getGroups()) {
    const ctx = client.setApp('iot').setRoom(group.name)
    ctx.subscribe('telemetry', group.filters.length > 0 ? { filters: group.filters } : {})
    ctx.subscribe('commands', me.role === 'device' ? { filters: [me.deviceId] } : {})
    ctx.subscribe('_cmd_ack', me.role === 'controller' ? { filters: [me.deviceId] } : {})
  }
})

Nothing is queued for a disconnected client. A command sent while its device is offline is never delivered and times out on the controller, and an ack published while the controller is offline is lost the same way. Telemetry sent during the gap is missing from receivers' buffers.

Limitations on kraken v0.9.0

  • One presence room per connection. Each joined group sets presence in its room, and kraken keeps a connection's presence in one room at a time, so with several groups joined only the last one lists the device in getDevices() on other clients.
  • Lobby. At kraken v0.9.0 a lobby's room list comes from the rooms the subscribing actor can reach and is cached per node for 5 minutes after the last lobby subscribe; after that, deviceOnline and deviceOffline stop until a client subscribes to the lobby again. See Lobbies.

API reference

NoLagIoT

Constructor options

OptionTypeDescription
clientNoLagSocketRequired. The injected core client the app owns and connects.
appNamestringThe App Slug used as the topic prefix (default 'iot').
deviceIdstringStable device identifier (a random UUID if omitted). Used as the filter for commands (device) or acks (controller).
deviceNamestringOptional human-readable name.
role'device' | 'controller'The one role this wrapper plays (default 'device').
metadataRecord<string, unknown>Optional custom data carried in presence.
groupsstring[]Groups to subscribe to once the wrapper is ready.
maxTelemetryPointsnumberMax readings kept per device and sensor pair (default 1000).
commandTimeoutnumberCommand acknowledgement timeout in ms (default 30000).
debugbooleanEnable wrapper debug logging (default false).
Method / propertyReturnsDescription
ready()Promise<void>Resolves once the first setup completed. joinGroup() throws before this.
detach()voidRelease this wrapper's handlers and topics, and clear pending command timers. Terminal; never closes the socket.
joinGroup(name, opts?)DeviceGroupSubscribe to a group, set presence in it and return it; returns the existing instance if already joined. opts.filters limits the telemetry subscription.
leaveGroup(name)voidUnsubscribe from a group and release its resources.
getGroups()DeviceGroup[]All joined groups.
getOnlineDevices()Device[]Devices currently online.
localDeviceDevice | nullThis wrapper's own device record, available after ready().

Events

EventPayloadDescription
connectednoneFirst setup completed.
disconnectedreason: stringThe connection closed.
reconnectingnoneThe core client scheduled a reconnect attempt.
reconnectednoneSetup ran again after a reconnect (see Reconnects).
errorerror: ErrorAn error from the core client, including broker refusals.
deviceOnlinedevice: DeviceA device came online.
deviceOfflinedevice: DeviceA device went offline.

DeviceGroup

MethodRoleReturnsDescription
sendTelemetry(sensorId, value, opts?)DeviceTelemetryReadingPublish a reading and return it. value is a number, string, boolean or object; opts is { unit?, tags?, filter?, filters? }.
getTelemetry(deviceId?, sensorId?)BothTelemetryReading[]Buffered readings, oldest first. Omit both arguments for everything.
sendCommand(targetDeviceId, command, params?)ControllerPromise<DeviceCommand>Send a named command to one device. Resolves on 'acked' or 'completed', rejects on 'failed' or timeout.
ackCommand(commandId, status, result?)DevicevoidAcknowledge a received command with 'acked', 'completed' or 'failed'; on 'failed' a string result becomes the error message.
getDevices() / getDevice(deviceId)BothDevice[] / Device | undefinedRemote devices present in this group.
setFilters(values) / addFilters(values) / removeFilters(values)BothvoidChange the telemetry filters. Commands and acks keep their device ID routing.

Events

EventPayloadDescription
telemetryTelemetryReadingA reading arrived from another device in the group.
commandDeviceCommandA command addressed to this device arrived (device role only).
commandAckDeviceCommandA device acknowledged a command this controller sent; status and result reflect the ack.
deviceJoinedDeviceA device joined this group.
deviceLeftDeviceA device left this group.

Types

TypeShape
TelemetryReading{ id, deviceId, sensorId, value, unit?, tags?, timestamp }
DeviceCommand{ id, targetDeviceId, command, params?, status, sentBy, sentAt, ackedAt?, completedAt?, result?, error? }
CommandStatus'pending' | 'acked' | 'completed' | 'failed' | 'timeout'
Device{ deviceId, actorTokenId, deviceName?, role, metadata?, joinedAt, isLocal }