@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.

TopicPurpose
telemetrySensor readings and device status updates
commandsCommands dispatched to devices, filtered by target device ID
_cmd_ackCommand 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 it

Installation

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

Shared 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

OptionTypeDescription
clientNoLagSocketRequired. The injected core NoLag client the app owns and connects.
appNamestringThe 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.
deviceIdstringStable device identifier (auto-generated if omitted). Used as the filter for commands (device) or acks (controller).
deviceNamestringOptional human-readable device name.
role'device' | 'controller'The one role this wrapper plays (default 'device').
metadataRecord<string, unknown>Optional custom data attached to device presence.
groupsstring[]Groups to join once the wrapper is ready.
maxTelemetryPointsnumberMax telemetry readings retained per device/sensor pair (default 1000).
commandTimeoutnumberCommand acknowledgment timeout in ms (default 30000).
debugbooleanEnable wrapper debug logging (default false).
MethodReturnsDescription
ready()Promise<void>Resolves once wrapper setup completed. Join methods throw before this resolves.
detach()voidRelease this wrapper's handlers and topics, and clear pending command timers; terminal, never closes the socket.
joinGroup(name, opts?)DeviceGroupSubscribe 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)voidUnsubscribe 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

EventPayloadDescription
connectednoneWrapper setup completed on a live connection.
disconnectedreason: stringConnection closed.
reconnectingnoneThe core client is attempting to reconnect.
reconnectednoneConnection restored; group membership and presence are restored automatically.
errorerror: ErrorA transport or protocol error occurred.
deviceOnlinedevice: DeviceA device joined the lobby.
deviceOfflinedevice: DeviceA device left the lobby.

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[]Read buffered readings, oldest first. Omit both arguments for every device and sensor, or pass only deviceId for one device.
sendCommand(targetDeviceId, command, params?)ControllerPromise<DeviceCommand>Dispatch a named command to one device. Resolves on 'acked' or 'completed', rejects on 'failed' or timeout.
ackCommand(commandId, status, result?)DevicevoidAcknowledge a received command. status is 'acked', 'completed' or 'failed'; on 'failed' a string result becomes the error message.
getDevices()BothDevice[]Remote devices present in this group.
getDevice(deviceId)BothDevice | undefinedOne remote device by ID.
setFilters(values) / addFilters(values) / removeFilters(values)BothvoidChange which telemetry filter values this group receives. Commands and acks are unaffected.

DeviceGroup Events

EventPayloadDescription
telemetryTelemetryReadingA reading was received 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.
deviceJoineddevice: DeviceA device joined this group.
deviceLeftdevice: DeviceA device left this group.

Types

TypeShape
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 }