@nolag/iot
IoT device telemetry and command dispatch with acknowledgment tracking.
Overview
Connect IoT devices and controllers over real-time channels. Devices push telemetry (sensor readings, status updates, or any structured measurement) while controllers dispatch commands with Promise-based acknowledgment tracking and configurable timeouts. Two roles participate 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, physical location, or function. Your app owns one core NoLag client and injects it into NoLagIoT; the wrapper attaches its behaviour to that connection.
Key Features
- Real-time telemetry broadcast from devices to all group subscribers
- Promise-based command dispatch with configurable acknowledgment timeout
- Commands and acks routed by device ID with filters, so a device only receives its own commands
- Ephemeral topics throughout, suited to high-frequency data
- Device online/offline presence via lobby
- Multiple groups per connection; join by zone, building floor, vehicle type, etc.
How It Works
NoLagIoT attaches to an injected @nolag/js-sdk client and manages a lobby that reflects device presence. Calling joinGroup(name) returns a DeviceGroup that subscribes to three topics: telemetry for sensor readings, commands for command payloads, and _cmd_ack for acknowledgments. None of them is logged server-side. Routing uses filters: a device subscribes to commands filtered on its own deviceId, and a controller subscribes to _cmd_ack filtered on its deviceId, so a command reaches only its target and an ack reaches only its sender. A controller sees every command on the group but never emits command; only devices do.
When a controller calls sendCommand() the SDK stores a pending Promise keyed on the command ID and publishes the DeviceCommand. When the target device calls ackCommand(), the matching Promise resolves on 'acked' or 'completed' and rejects on 'failed', or rejects after commandTimeout. The first ack settles the command; later acks for the same ID are ignored. Publishers never receive their own messages, so telemetry you send is stored in your own buffer without emitting telemetry. The app owns the socket lifecycle; the wrapper never opens or closes it.
| Topic | Purpose |
|---|---|
telemetry | Sensor readings and device status updates |
commands | Commands dispatched to devices, filtered by target device ID |
_cmd_ack | Command acknowledgments, filtered by the sending controller's device ID |
A fresh subscribe or an ordinary reconnect never replays history, and this SDK never triggers replay; see Replay for the one case where the broker replays messages.
Before you start. Create an app from the nolag-iot-sdk blueprint. That seeds the rooms this SDK expects (factory-floor) and the online lobby, and returns an app slug with a random suffix. That slug is the appName you pass to the wrapper. You can also do this in the portal: Apps, New App, pick the blueprint.
import { NoLagApi } from "@nolag/js-sdk";
const api = new NoLagApi(process.env.NOLAG_API_KEY); // nlg_live_...
const app = await api.apps.create({ name: "My App", blueprintId: "nolag-iot-sdk" });
console.log(app.slug); // e.g. "my-app-a3f9": this is your appName
const actor = await api.actors.create({ name: "sensor-01", actorType: "device" });
console.log(actor.accessToken); // shown once; keep itInstallation
npm install @nolag/iot @nolag/js-sdkShared connection. One core NoLag client can back several wrapper SDKs at once, for example IoT telemetry, a dashboard, and notify on a single socket, as long as each wrapper uses a distinct appName. Each wrapper attaches its handlers on construction and releases them with detach(), and never touches the socket itself. Your app owns connect() and disconnect().
Quick Start
The device and the controller are two clients with different device IDs. Both join the seeded factory-floor group.
import { NoLag } from '@nolag/js-sdk'
import { NoLagIoT } from '@nolag/iot'
const client = NoLag(DEVICE_TOKEN)
const iot = new NoLagIoT({
client,
appName: APP_SLUG, // the slug returned when you created the app
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
// Join the seeded group
const group = iot.joinGroup('factory-floor')
// Publish readings. The sensor id is the first argument; 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 arrive here. Ack once: 'acked' or
// 'completed' resolves the controller's Promise, 'failed' rejects it.
group.on('command', async (cmd) => {
console.log('Received 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))
}
})
// Teardown: the wrapper releases its handlers; the app closes the socket.
iot.detach()
client.disconnect()API Reference
NoLagIoT
Constructor Options
| Option | Type | Description |
|---|---|---|
client | NoLagSocket | Required. The injected core NoLag client the app owns and connects. |
appName | string | The app slug used as the topic prefix. Pass the suffixed slug returned when you created the app; the default 'iot' will not match a hosted app. |
deviceId | string | Stable device identifier (auto-generated if omitted). Used as the filter for commands (device) or acks (controller). |
deviceName | string | Optional human-readable device name. |
role | 'device' | 'controller' | The one role this wrapper plays (default 'device'). |
metadata | Record<string, unknown> | Optional custom data attached to device presence. |
groups | string[] | Groups to join once the wrapper is ready. |
maxTelemetryPoints | number | Max telemetry readings retained per device/sensor pair (default 1000). |
commandTimeout | number | Command acknowledgment timeout in ms (default 30000). |
debug | boolean | Enable wrapper debug logging (default false). |
| Method | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once wrapper setup completed. Join methods throw before this resolves. |
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 device group and return it. Synchronous; returns the existing instance if already joined. opts.filters limits the telemetry subscription; commands and acks keep their device-ID routing. |
leaveGroup(name) | void | Unsubscribe from a device group and release its resources. |
getGroups() | DeviceGroup[] | All currently joined groups. |
getOnlineDevices() | Device[] | Devices currently present in the online lobby. |
NoLagIoT Events
| Event | Payload | Description |
|---|---|---|
connected | none | Wrapper setup completed on a live connection. |
disconnected | reason: string | Connection closed. |
reconnecting | none | The core client is attempting to reconnect. |
reconnected | none | Connection restored; group membership and presence are restored automatically. |
error | error: Error | A transport or protocol error occurred. |
deviceOnline | device: Device | A device joined the lobby. |
deviceOffline | device: Device | A device left the lobby. |
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[] | Read buffered readings, oldest first. Omit both arguments for every device and sensor, or pass only deviceId for one device. |
sendCommand(targetDeviceId, command, params?) | Controller | Promise<DeviceCommand> | Dispatch 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. status is 'acked', 'completed' or 'failed'; on 'failed' a string result becomes the error message. |
getDevices() | Both | Device[] | Remote devices present in this group. |
getDevice(deviceId) | Both | Device | undefined | One remote device by ID. |
setFilters(values) / addFilters(values) / removeFilters(values) | Both | void | Change which telemetry filter values this group receives. Commands and acks are unaffected. |
DeviceGroup Events
| Event | Payload | Description |
|---|---|---|
telemetry | TelemetryReading | A reading was received 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: Device | A device joined this group. |
deviceLeft | device: Device | A device left this group. |
Types
| Type | Shape |
|---|---|
TelemetryReading | { id, deviceId, sensorId, value, unit?, tags?, timestamp, isReplay } |
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 } |