@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.
| Topic | Purpose |
|---|---|
telemetry | Sensor readings and status updates |
commands | Commands, published with the target device's ID as filter |
_cmd_ack | Acknowledgements, 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):
| Kind | Names |
|---|---|
| Topics | telemetry, commands, _cmd_ack (all three subscribed in every joined group) |
| Rooms | factory-floor, plus every group name you pass to joinGroup() |
| Lobby | online (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,
deviceOnlineanddeviceOfflinestop until a client subscribes to the lobby again. See Lobbies.
API reference
NoLagIoT
Constructor options
| Option | Type | Description |
|---|---|---|
client | NoLagSocket | Required. The injected core client the app owns and connects. |
appName | string | The App Slug used as the topic prefix (default 'iot'). |
deviceId | string | Stable device identifier (a random UUID if omitted). Used as the filter for commands (device) or acks (controller). |
deviceName | string | Optional human-readable name. |
role | 'device' | 'controller' | The one role this wrapper plays (default 'device'). |
metadata | Record<string, unknown> | Optional custom data carried in presence. |
groups | string[] | Groups to subscribe to once the wrapper is ready. |
maxTelemetryPoints | number | Max readings kept per device and sensor pair (default 1000). |
commandTimeout | number | Command acknowledgement timeout in ms (default 30000). |
debug | boolean | Enable wrapper debug logging (default false). |
| Method / property | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once the first setup completed. joinGroup() throws before this. |
detach() | void | Release this wrapper's handlers and topics, and clear pending command timers. Terminal; never closes the socket. |
joinGroup(name, opts?) | DeviceGroup | Subscribe 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) | void | Unsubscribe from a group and release its resources. |
getGroups() | DeviceGroup[] | All joined groups. |
getOnlineDevices() | Device[] | Devices currently online. |
localDevice | Device | null | This wrapper's own device record, available after ready(). |
Events
| Event | Payload | Description |
|---|---|---|
connected | none | First setup completed. |
disconnected | reason: string | The connection closed. |
reconnecting | none | The core client scheduled a reconnect attempt. |
reconnected | none | Setup ran again after a reconnect (see Reconnects). |
error | error: Error | An error from the core client, including broker refusals. |
deviceOnline | device: Device | A device came online. |
deviceOffline | device: Device | A device went offline. |
DeviceGroup
| Method | Role | Returns | Description |
|---|---|---|---|
sendTelemetry(sensorId, value, opts?) | Device | TelemetryReading | Publish a reading and return it. value is a number, string, boolean or object; opts is { unit?, tags?, filter?, filters? }. |
getTelemetry(deviceId?, sensorId?) | Both | TelemetryReading[] | Buffered readings, oldest first. Omit both arguments for everything. |
sendCommand(targetDeviceId, command, params?) | Controller | Promise<DeviceCommand> | Send a named command to one device. Resolves on 'acked' or 'completed', rejects on 'failed' or timeout. |
ackCommand(commandId, status, result?) | Device | void | Acknowledge a received command with 'acked', 'completed' or 'failed'; on 'failed' a string result becomes the error message. |
getDevices() / getDevice(deviceId) | Both | Device[] / Device | undefined | Remote devices present in this group. |
setFilters(values) / addFilters(values) / removeFilters(values) | Both | void | Change the telemetry filters. Commands and acks keep their device ID routing. |
Events
| Event | Payload | Description |
|---|---|---|
telemetry | TelemetryReading | A reading arrived from another device in the group. |
command | DeviceCommand | A command addressed to this device arrived (device role only). |
commandAck | DeviceCommand | A device acknowledged a command this controller sent; status and result reflect the ack. |
deviceJoined | Device | A device joined this group. |
deviceLeft | Device | A device left this group. |
Types
| Type | Shape |
|---|---|
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 } |