Skip to content
Kinetixui

IoT

KinetixUI IoT is a set of controls, telemetry, pairing and automation patterns for connected products whose hardware answers asynchronously.

@kinetixui/iot is a separate module, not part of @kinetixui/ui. It adds a domain vocabulary — devices, commands, telemetry, alerts, automation rules, pairing, places — on top of the same token contract every other KinetixUI platform is built from, and it is organised around one principle: the interface tells the truth about device state. See State honesty.

Experimental, and React only. The API may change without a major version while this finds its shape. It is not part of the 98 cross-platform component surface, and there is no SwiftUI, Jetpack Compose or Flutter port of it. It ships no transport, no automation engine and no video, and every demo on the website is simulation.

This page is the reference. For the tour — three reference environments, the command lifecycle and each pattern working — see the IoT overview.

Contents of the module#

LayerWhat it isImport
State modelTypes and pure functions. No React, no DOM@kinetixui/iot/functions
PrimitivesOne fact, rendered@kinetixui/iot/react
ControlsOne thing a user can change@kinetixui/iot/react
PatternsA composition with a rule or two@kinetixui/iot/react
CompositionsWhole screens, published as source to copykinetixui.com/iot

Install#

npm install @kinetixui/iot

Part of this page is ahead of the published package. npm install today gives you the state model and the fourteen primitives. The control layer (DevicePowerControl, DeviceLevelControl, DeviceSetpointControl, DeviceModeControl, resolveControlState), the identity and card compositions (DeviceIcon, DeviceIdentity, DeviceControlCard, DeviceGroupCard, RoutineCard) and the control-state, device-category and automation-timing helpers in @kinetixui/iot/functions are in this repository's main but not in the version npm serves, so the sections documenting them are marked Unreleased. They land on the next @kinetixui/iot release; until then, importing one from an installed copy is a build error, not a mistake you made. Check what you have against the changelog.

Three entry points, because the useful half of this module has nothing to do with rendering:

importcontains
@kinetixui/ioteverything
@kinetixui/iot/functionsmodels and pure functions — no React
@kinetixui/iot/reactprimitives, controls and patterns

The functions subpath imports neither React nor the DOM, so it runs in a server route, a worker, a queue consumer or a test with no renderer. That is asserted twice rather than promised: against the source import graph, and against the built dist that npm ships.

Peer dependencies#

react is an optional peer (>=18). The react subpath needs it, and so does the root @kinetixui/iot import because it re-exports the primitives; @kinetixui/iot/functions needs nothing at all. There is no react-dom peer — no primitive renders a portal — and no @kinetixui/ui peer, since nothing here imports a component from it.

# functions only — no React required
npm install @kinetixui/iot
 
# using the primitives
npm install @kinetixui/iot react

Styling prerequisite#

This package has no runtime dependencies, and deliberately does not depend on @kinetixui/tokens or @kinetixui/ui — not even as a peer. The primitives style themselves with Tailwind utilities on the token contract, which are class names rather than imports. So an app needs the token stylesheet loaded and this package inside its Tailwind content:

// tailwind.config.js
content: ["./src/**/*.{ts,tsx}", "./node_modules/@kinetixui/iot/dist/**/*.js"],

The functions subpath needs none of that.

Scope#

Available now

  • a state model — device, health, command lifecycle, telemetry, alert, activity, automation rule, pairing flow, space hierarchy and energy types — with pure functions for each
  • 8 React primitives, 4 device controls and 27 patterns
  • a device taxonomy and a metric registry, so a category and a metric mean the same thing everywhere
  • control-state resolution: resolveControlState turns a device status and a command lifecycle into one answer about whether a control is operable, and whether what you are looking at is confirmed
  • a deterministic simulation and three reference environments, on the website only, as source to copy

Not in this module

  • MQTT, BLE, WebSocket or HTTP polling — no transport of any kind
  • vendor adapters (Matter, Zigbee, LoRaWAN, Modbus, or any product's own API)
  • native SwiftUI, Jetpack Compose or Flutter components
  • a device-discovery or provisioning implementation — pairing here is screen state
  • charting infrastructure — a series is data, and TelemetryTrend is a plain accessible plot
  • an automation engine — AutomationBuilder edits a rule and RoutineCard renders one; nothing evaluates a trigger or schedules a run
  • video — nothing in this module streams, decodes or displays a camera feed

Nothing in this module opens a connection, holds a credential, or parses or executes a device payload. It models what a device is, so that the layer which talks to one has something honest to render into.

Generic on purpose#

The models are shaped to be equally true for smart homes, agriculture, industrial operations, medical devices and fleet tracking. A field earns a place in KinetixDevice only if a soil probe, an infusion pump, a thermostat and a delivery van tracker would all populate it with the same meaning. Anything narrower goes in metadata, and there are no transport or protocol fields at all.

The same reasoning keeps vocabulary out of the library. Mode labels, group nouns ("room", "zone", "line") and unit strings come from your product; the module supplies the state model they hang on.

Architecture#

Five layers, and the boundary that matters is the second one:

  1. Your application and provider layer — wherever device state actually comes from: MQTT, BLE, Matter, a REST API, a WebSocket, a vendor SDK, a cloud service. These are examples of what an application might use. None of them is in this package.
  2. Your application adapter — your code turns whatever a provider sends into the module's shapes, and turns a callback from a control back into a request to the provider.
  3. KinetixUI IoT state (@kinetixui/iot/functions) — the state model as pure functions.
  4. KinetixUI IoT components (@kinetixui/iot/react) — primitives, controls and patterns that draw that state and report intent through callbacks.
  5. Compositions — whole screens, published as source on kinetixui.com/iot for you to copy.

The overview page draws this as a diagram.

Transport boundary#

KinetixUI does not own the transport. The module starts above it. Your application receives device state through an API, a gateway, a device SDK, a broker or something else entirely; the module begins after that and turns the result into consistent product semantics and UI.

In practice the boundary is one function you write. It takes what your provider sends and returns a KinetixDevice, and nothing about the provider leaks past it:

import { normalizeDeviceStatus, type KinetixDevice } from "@kinetixui/iot/functions";
 
// Your provider's shape. This type is yours; the module never sees it.
type ProviderMessage = { id: string; label: string; kind: string; link: string; batteryPct?: number; seen?: string };
 
function toDevice(message: ProviderMessage): KinetixDevice {
  return {
    id: message.id,
    name: message.label,
    type: message.kind,
    status: normalizeDeviceStatus(message.link), // "CONNECTED" -> "online"; anything unrecognised -> "offline"
    battery: message.batteryPct,
    lastSeenAt: message.seen,
  };
}

Going the other way, a control reports intent and your code does whatever your transport requires. The control never assumes it worked:

import { DevicePowerControl } from "@kinetixui/iot/react";
 
// confirmedPower, requestedPower and sendToDevice are yours.
<DevicePowerControl
  label="Pump 01 power"
  state={confirmedPower}          // what the device last reported
  requested={requestedPower}      // what the user asked for, until the device agrees
  onToggle={(next) => sendToDevice(next)} // yours
/>

State honesty#

This is the principle the rest of the module is built on, so it is stated once, plainly.

A request is not a state. What the user asked for is drawn differently from what the device has confirmed — in the picture and in assistive technology.

Almost every device UI fills the gap between asked and confirmed with an optimistic update. The switch slides, the icon lights, and the screen asserts a state the device has not reported. On a lamp that is harmless. On a door lock, an irrigation valve or a pump it is a lie the user discovers later.

The module holds this line in four places:

  • Controls take state (confirmed) and requested (asked) as separate props, and never treat a press as a result. aria-checked on the power switch reports the confirmed state while a request is open.
  • The command lifecycle is a state machine in which only a confirmed event changes what the device is said to be. acknowledged is not confirmed.
  • Telemetry separates a reading from how much to trust it: stale, missing and errored are states with words, and a gap in a series is drawn as a gap.
  • Unknown is a result. No battery, no signal, no timestamp and no reading are each rendered as “unknown”, never as zero.

Optimistic updates are not forbidden — a product can pass requested equal to the new value and update state when the device confirms. The point is that the requested value stays visibly a request until then.

The command lifecycle#

One user-initiated change, from asked to confirmed or to an honest failure. KinetixCommandLifecycle is a small state machine with nine stages: idle, requested, acknowledged, confirmed, failed, timed-out, unreachable, retrying and cancelled. It holds two values side by side — requestedValue and confirmedValue — and only a confirm event moves confirmedValue. Everything else, including acknowledged, leaves it alone, so “the device heard me” can never be drawn as “the device did it”.

Here is the whole story of a switch that does not get its answer — off, turning on, timeout, unreachable, retry:

import {
  advanceCommandLifecycle,
  canRetryLifecycle,
  describeCommandLifecycle,
  startCommandLifecycle,
} from "@kinetixui/iot/functions";
 
const say = (value: unknown) => String(value);
 
// OFF — the device last confirmed "off". You ask for "on".
let cmd = startCommandLifecycle<"on" | "off">({ confirmed: "off", requested: "on" });
describeCommandLifecycle(cmd, { formatValue: say });
// "No change requested. The device reports off."
 
// TURNING ON — sent, and not yet confirmed. The switch must not say "on".
cmd = advanceCommandLifecycle(cmd, { type: "sent" });
describeCommandLifecycle(cmd, { formatValue: say });
// "Requested on. Waiting for the device; not yet confirmed. It last reported off."
 
// TIMEOUT — no confirmation inside your window. You decide the window; the machine records the outcome.
cmd = advanceCommandLifecycle(cmd, { type: "timeout" });
describeCommandLifecycle(cmd, { formatValue: say });
// "No confirmation for on: the request timed out. The device last reported off."
 
// UNREACHABLE — the device did not answer at all. A different sentence, because the next step differs.
cmd = advanceCommandLifecycle(cmd, { type: "deviceUnreachable", reason: "No reply from the gateway." });
describeCommandLifecycle(cmd, { formatValue: say });
// "The device is unreachable, so on was not confirmed. No reply from the gateway. It last reported off."
 
// RETRY — a new attempt, counted and bounded (three by default).
canRetryLifecycle(cmd);                        // true
cmd = advanceCommandLifecycle(cmd, { type: "retry" });
describeCommandLifecycle(cmd, { formatValue: say });
// "Retrying, attempt 2 of 3. on is still not confirmed; the device last reported off."
 
// Only now does the device's state change.
cmd = advanceCommandLifecycle(cmd, { type: "confirm" });
describeCommandLifecycle(cmd, { formatValue: say });
// "Confirmed: the device reports on."

Three properties are worth relying on:

  • Every unconfirmed sentence names both values — the one requested and the one last reported — and none of them says the device is the requested value before confirmed. A screen-reader user has no colour or spinner to correct an over-claim.
  • The machine never throws and never mutates. transitionCommandLifecycle returns ok: false with the same state and a reason (illegal-transition, or max-attempts past the limit), so a caller fed by duplicate or late deliveries can ignore a rejection and keep rendering.
  • Late truth is accepted. A confirm after timed-out or unreachable is legal: a device that answers late has told you the truth, and refusing it would leave the screen insisting on a state the device has left. confirmed and cancelled are terminal; a new change is a new lifecycle.

The machine has no clock and no timer. Your code decides when to send timeout — isLifecycleTimedOut answers whether a window has passed when you pass it now and a limit — and what to do about it. CommandLifecycle renders a lifecycle as a stepper (requested → acknowledged → confirmed, or a failure branch with a Retry when the machine allows one) with one polite role="status" sentence, the same one describeCommandLifecycle returns.

The device model#

import type { KinetixDevice, KinetixDeviceStatus } from "@kinetixui/iot/functions";
 
type KinetixDeviceStatus =
  | "online" | "offline" | "stale" | "syncing" | "pairing"
  | "updating" | "warning" | "error" | "disabled";
 
type KinetixDevice = {
  id: string;
  name: string;
  type: string;            // product-defined: "thermostat", "soil-probe", "gateway"
  status: KinetixDeviceStatus;
  battery?: number;        // 0–100
  signal?: number;         // normalised 0–100, not dBm
  firmwareVersion?: string;
  lastSeenAt?: string | Date;
  locationName?: string;
  metadata?: Record<string, unknown>;
};

stale is the one worth pointing at: it means "there is a connection story, but the data behind it is old", which is the state device UIs most often render as a confident online.

Status, connectivity and health are different questions#

status is what a device row shows as one badge. Beneath it the module keeps two more precise answers:

  • Connectivity — can the device be reached (online, stale, offline, unreachable)?
  • Health — is it working (healthy, degraded, warning, critical, unknown), with the reasons listed?

deriveDeviceHealth returns both a level and the reasons behind it, so a screen can say why:

import { deriveDeviceHealth, summarizeFleetHealth } from "@kinetixui/iot/functions";
 
deriveDeviceHealth({ status: "offline" });
// { level: "warning", reasons: [{ code: "offline", level: "warning", message: "The device is offline" }], faults: [] }
 
const fleet = summarizeFleetHealth([
  { id: "valve-03", name: "Valve 03", type: "valve", status: "online" },
  { id: "soil-04", name: "Soil sensor 04", type: "soil-sensor", status: "offline" },
]);
fleet.description; // "1 of 2 healthy, 1 warning, 1 offline."

Read that description carefully: it is one offline device, counted once as a warning and once as offline. summarizeFleetHealth reports offline beside the health counts because an offline device also has a health level, so the raw numbers overlap. DeviceHealthSummary therefore gives every device exactly one bucket (offline or unreachable is its own bucket, taken out of warning) so the visible counts sum to the fleet size. If you draw your own summary from byHealth and offline, do the same.

Device categories and the domain registry#

KinetixDevice.type is free text, because your product's vocabulary is yours. resolveDeviceCategory maps it onto a small set of categories, and the taxonomy says everything the module knows about each one:

import {
  KINETIX_DEVICE_TAXONOMY,
  categoryAffordances,
  resolveDeviceCategory,
  resolveDeviceDomain,
} from "@kinetixui/iot/functions";
 
resolveDeviceCategory({ type: "Soil Probe" });   // "soil-sensor"
resolveDeviceCategory("mystery");                // "unknown" — rendered neutrally, never guessed
resolveDeviceDomain("thermostat");               // "climate"
categoryAffordances("thermostat");               // ["setpoint", "mode"]
 
KINETIX_DEVICE_TAXONOMY.valve;
// { domain: "water", label: "Valve", affordances: ["power", "level"], defaultMetrics: ["flow"], groupKey: "valves" }

The 16 categories keep their interaction shape (a light, a lock, a pump, a motor), which decides which controls a device is offered. A separate domain registry (lighting, climate, security, irrigation, pump, industrial, and so on) groups devices for filtering and roll-ups without changing what a device can do. Both live in one table, so adding a category is one entry there and one union member, and nothing else switches on a category to find out its label or affordances. Domain and category values are lowercase strings by design, matching the rest of the module's unions.

Metrics and telemetry#

The telemetry model#

type KinetixTelemetryQuality = "good" | "estimated" | "missing" | "error";
 
type KinetixTelemetryPoint = {
  timestamp: string | Date;
  metric: string;          // product-defined: "temperature", "soil-moisture", "flow"
  value: number;
  unit?: string;           // "°C", "%", "L/min" — printed, never converted
  quality?: KinetixTelemetryQuality;
};
 
type KinetixTelemetrySeries = {
  deviceId: string;
  metric: string;
  points: KinetixTelemetryPoint[];
};

quality exists so that missingness survives the trip to the UI. It is why formatTelemetryValue can refuse to print a number.

The metric registry#

KINETIX_METRIC_REGISTRY says what a metric is called, its usual unit and how many decimals it deserves, so a tile, a plot axis and an accessible summary agree. It holds 13 metrics, from temperature and soil-moisture to flow, energy and battery. Everything in it is a default your product can override per call.

Thresholds are given only where they are not domain-specific: battery, signal-strength and air-quality have widely shared bands. Temperature, humidity and soil moisture deliberately have none — 30 °C is fine in a greenhouse and an emergency in a cold store, and a wrong default is worse than none. You supply the threshold that is true for your product:

import { getMetricDefinition, resolveMetricThresholds } from "@kinetixui/iot/functions";
 
getMetricDefinition("soil-moisture");                        // { id: "soil-moisture", label: "Soil moisture", unit: "%", decimals: 0 }
resolveMetricThresholds("soil-moisture", { warningLow: 28 }); // { warningLow: 28 }

Readings, thresholds, stale and missing#

evaluateReading judges one reading and returns a state with a word and a glyph: normal, warning, critical, stale or unavailable. The precedence is fixed: no usable value beats an old value beats the threshold result. A stale reading is never called normal, however in-range it is, because an old number is not evidence of a current condition.

import { evaluateReading } from "@kinetixui/iot/functions";
 
const now = "2026-01-01T12:00:00Z";
 
evaluateReading({ metric: "air-quality", value: 130, timestamp: "2026-01-01T11:59:00Z", now, staleAfterMs: 300_000 });
// { state: "warning", level: "warning", word: "Warning", glyph: "triangle", side: "high" }
 
evaluateReading({ metric: "air-quality", value: 130, timestamp: "2026-01-01T10:00:00Z", now, staleAfterMs: 300_000 });
// { state: "stale", level: "warning", word: "Stale", glyph: "clock", side: "high" } — what the number alone would have been
 
evaluateReading({ metric: "temperature", value: null, quality: "missing" });
// { state: "unavailable", level: null, word: "Unavailable", glyph: "dash" }

Every state is a different glyph silhouette and a word, so no state is carried by colour alone.

Series, gaps and accessible summaries#

A series is analysed without interpolating over what is missing. summarizeSeries reports min, max, average and trend over the measured points and counts the rest as missing; detectSeriesGaps finds where readings were due and did not arrive; thresholdCrossings lists each time a series changed level, including recovery.

describeSeriesForAssistiveTech writes the sentence a screen reader gets for a plot, from the same functions, so the text is covered by the same tests as the classification:

import { describeSeriesForAssistiveTech, summarizeSeries } from "@kinetixui/iot/functions";
 
const series = {
  deviceId: "soil-04",
  metric: "soil-moisture",
  points: [
    { timestamp: "2026-01-01T09:00:00Z", metric: "soil-moisture", value: 41, unit: "%" },
    { timestamp: "2026-01-01T10:00:00Z", metric: "soil-moisture", value: 37, unit: "%" },
    { timestamp: "2026-01-01T11:00:00Z", metric: "soil-moisture", value: 0, quality: "missing" as const },
    { timestamp: "2026-01-01T12:00:00Z", metric: "soil-moisture", value: 29, unit: "%" },
  ],
};
 
summarizeSeries(series).trend;            // "falling"
summarizeSeries(series).missingCount;     // 1 — the point marked missing is not averaged in
describeSeriesForAssistiveTech(series);
// "Soil moisture: 3 readings from 2026-01-01 09:00 UTC to 2026-01-01 12:00 UTC. Latest 29 %. Lowest 29 %,
//  highest 41 %, average 36 %. Overall falling. 1 point has no usable reading."

TelemetryMetric renders one reading with its state, TelemetryGrid lays several out, and TelemetryTrend draws the series with a broken line at every dropout, optional threshold bands, a summary row and a "View data" table as the plot's textual equivalent.

Pairing is UI state, not a transport#

This is worth being blunt about, because the module's positioning says "pair" and that could be read as a promise it does not make. Nothing here discovers, connects to, or authenticates a device. There is no Bluetooth, no Wi-Fi provisioning and no network call anywhere in this package.

What it gives you is the vocabulary and the local logic for a pairing screen, so the screen can be built, tested and reviewed before any transport exists — and so that whichever transport you use does not invent its own word for "connecting".

Stages, methods and events#

A pairing flow has seven stages (discover, identify, authenticate, configure, assign, verify, complete) and five methods — how a person is being asked to connect: bluetooth, network, qr, manual-code and cloud. The methods are labels so a screen can choose its copy; your transport decides what actually happens and tells the flow with events.

import {
  advancePairing,
  getPairingFailure,
  pairingFlowSteps,
  startPairingFlow,
  transitionPairing,
} from "@kinetixui/iot/functions";
 
let flow = startPairingFlow();                             // { status: "idle", stage: "discover", ... }
flow = advancePairing(flow, { type: "start", method: "qr" });
flow = advancePairing(flow, { type: "next" });             // your transport reported the device was found
pairingFlowSteps(flow).map((step) => `${step.id}:${step.status}`);
// ["discover:complete", "identify:active", "authenticate:pending", ...]
 
const failed = transitionPairing(flow, { type: "fail", code: "weak-signal" });
failed.ok;                                                 // true
failed.state.status;                                       // "failed" — and the stage it failed at is kept

transitionPairing never throws: an event that is not legal for the current state comes back as ok: false with the same state and a reason, so a screen fed by an unreliable source can ignore it and keep rendering.

The failure registry#

Thirteen failures are described once, each with what to say, where it happens, whether a retry makes sense, whether a half-finished setup needs cleaning up, and the recovery actions to offer:

import { getPairingFailure } from "@kinetixui/iot/functions";
 
getPairingFailure("weak-signal");
// {
//   code: "weak-signal", title: "Signal too weak",
//   description: "The connection to the device was too weak to continue.",
//   recovery: [{ id: "retry", label: "Try again", kind: "retry" }, { id: "cancel", label: "Cancel setup", kind: "cancel" }],
//   retryable: true, stage: "identify", needsCleanup: false
// }

PairingMethodPicker, PairingStepper and PairingFailure render these. PairingFailure is a role="alert", and its recovery actions report through onAction — they change nothing on their own.

Code entry#

import { normalizePairingCode, validatePairingCode } from "@kinetixui/iot/functions";
 
normalizePairingCode("a1b-2c3");   // "A1B2C3"
validatePairingCode("A1B2C3");     // true — shape only, never correctness

validatePairingCode checks a code's shape so a form can enable its submit button. It does not transmit the code, store it, or judge whether it is the right one — only the device can do that.

Alerts and activity#

An alert is a fact a person has to own: a severity, a message, when it was raised, and optionally what raised it (source), a machine-readable kind, and one suggested action. It has three moments a screen must keep apart — raised, acknowledged (someone has seen it) and resolved (the cause is gone). Acknowledging does not resolve, and an acknowledged alert stays visible as acknowledged rather than disappearing.

import { acknowledgeAlert, sortAlerts, summarizeAlerts } from "@kinetixui/iot/functions";
 
const alerts = [
  { id: "a1", deviceId: "d1", severity: "warning" as const, message: "Low battery", raisedAt: "2026-01-01T09:00:00Z" },
  { id: "a2", deviceId: "d2", severity: "critical" as const, message: "Pressure high", raisedAt: "2026-01-01T08:00:00Z" },
];
 
sortAlerts(alerts).map((a) => a.id);   // ["a2", "a1"] — unresolved first, then loudest, then unacknowledged
summarizeAlerts(alerts).description;
// "2 open alerts on 2 devices: 1 critical, 1 warning. 2 not yet acknowledged."
acknowledgeAlert(alerts[1]!, "2026-01-01T12:00:00Z"); // a copy with acknowledgedAt set; nothing is mutated

AlertList renders a triaged, optionally per-device list with a summary line; AlertCard renders one, with kind, source, action and an acknowledged or resolved state. Severity is a glyph and a word. The callbacks (onAcknowledge, onAction) report intent — your application decides what acknowledging does. ActivityTimeline renders what happened, grouped by day: commands (with their lifecycle status, so a requested entry reads “Requested, not yet confirmed” and cannot be mistaken for a result), state changes, alerts, automation and firmware. Live-region announcements are rare on purpose; a list is not announced when it changes.

Automation#

RoutineCard is unreleased — see Install. The automation model and its timing helpers ship with it; the rest of this section describes types that are already published.

An automation is a rule: one trigger, any number of conditions joined by and / or, and one or more actions. The module models the rule and edits it. It does not run it. There is no engine, no scheduler and no trigger evaluation in this package; your application owns execution.

import {
  summarizeAutomationRule,
  validateAutomationRule,
  type KinetixAutomationRule,
} from "@kinetixui/iot/functions";
 
const rule: KinetixAutomationRule = {
  id: "irrigate-zone-3",
  name: "Irrigate when dry",
  enabled: true,
  trigger: { type: "sensor", subject: "soil-moisture", scope: "zone-3", operator: "lt", value: 28, unit: "%" },
  conditions: [],
  actions: [{ id: "open-valve", target: "valve-03", command: "open", durationMinutes: 20 }],
};
 
validateAutomationRule(rule);   // [] — no issues
summarizeAutomationRule(rule);  // "When soil moisture falls below 28% in zone 3, open valve 03 for 20 minutes."
validateAutomationRule({ ...rule, name: "", actions: [] });
// [{ path: "name", code: "missing-name", ... }, { path: "actions", code: "missing-action", ... }]

Validation returns issues with a path and a code, never an exception, so a form can point at the field. summarizeAutomationRule reads a rule back as a sentence and takes a label callback so subjects and targets read as your product names them. AutomationRuleView shows a rule read-only. AutomationBuilder is a keyboard-operable form over the same structure: you pass the subjects and targets your product offers, and it reports a new rule through onChange and onSubmit. It runs its own validation, shows the issues inline and keeps one polite status region.

RoutineCard and the older KinetixAutomation type describe a scene, routine or schedule that your engine already populates — a name, a status, a last and next run. That is presentation of state, not a scheduler.

Grouping and hierarchy#

A connected product organises devices into places, and every product names the levels differently. So the levels are not modelled. There is one node type, a kind string your product fills in, and a parentId; depth is whatever the data says it is. The module's tests build all four of the shapes below, and none of the level names is built in:

ProductLevels
Smart spaceHome → Floor → Room
AgritechFarm → Field → Irrigation zone
OperationsOrganization → Site → Line → Machine
Commercial propertyPortfolio → Site → Building → Zone
import { buildSpaceTree, describeSpaceRollup, rollupSpaceHealth, spacePath } from "@kinetixui/iot/functions";
 
const tree = buildSpaceTree([
  { id: "farm", name: "Demo Farm", kind: "farm" },
  { id: "gh-a", name: "Greenhouse A", kind: "field", parentId: "farm" },
  { id: "zone-3", name: "Irrigation Zone 3", kind: "irrigation-zone", parentId: "gh-a", deviceIds: ["valve-03", "soil-04"] },
]);
 
spacePath(tree, "zone-3").map((p) => p.name);  // ["Demo Farm", "Greenhouse A", "Irrigation Zone 3"]
 
const rollups = rollupSpaceHealth(tree, [
  { id: "valve-03", name: "Valve 03", type: "valve", status: "online" },
  { id: "soil-04", name: "Soil sensor 04", type: "soil-sensor", status: "offline" },
]);
describeSpaceRollup(rollups.get("farm")!); // "1 of 2 healthy, 1 warning, 1 offline."

buildSpaceTree never throws on bad input: a duplicate id, an orphan or a cycle is worked around and reported in tree.issues. A rollup exists at every level and follows the exclusive-bucket rule above. SpaceBreadcrumb renders a path, SpaceRollup a rollup, and DeviceGroupCard takes path and rollup slots. The tree is a description to render and roll up. It is not a permissions model, a device registry or a schema.

Energy#

summarizeEnergy takes the per-device figures your application already has and returns a total, each item's share and rank, and an optional comparison with a baseline. energyTrend reads a list of daily totals as rising, falling, steady or unknown.

import { energyTrend, summarizeEnergy } from "@kinetixui/iot/functions";
 
const energy = summarizeEnergy([{ id: "heater", label: "Heater", value: 6 }, { id: "fridge", label: "Fridge", value: 2 }], { unit: "kWh" });
energy.total;                        // 8
energy.top[0]?.share;                // 0.75
energyTrend([3, 4, 5, 6]).direction; // "rising"

EnergySummary displays these. It is display only: no billing, no cost, no carbon estimate and no forecast, and every number comes from your application.

Camera pattern#

CameraDeviceCard is a device card for a camera, and it never shows or implies a live feed. There is no <video>, no stream, no player and no request. The picture is a poster slot your product fills (or a placeholder that says “no live feed”), the privacy and recording states are plain words, and lastEvent says what happened and when. It is the camera's state, not the camera's output.

Known limitations of the model#

  • status is one field, so a device has one state. A device that is reachable and reporting a fault is error or online, not both. That is deliberate — a single field is what a list row can sort and render — but it means product-level nuance belongs elsewhere: alerts carry severity independently, and needsAttention exists so a fleet view can emphasise without collapsing.
  • Statuses mix concerns on purpose. online/offline is a link fact, stale is about the data, syncing/updating/pairing are activities and disabled is configuration. They share one union because a device row shows one badge. If you need two axes, keep the second one yourself.

Utilities#

Every function is pure and deterministic, and every time-dependent one takes an injectable now.

import {
  classifyBatteryLevel,
  classifySignalStrength,
  compareFirmwareVersions,
  detectStaleReading,
  formatLastSeen,
  formatTelemetryValue,
  normalizeDeviceStatus,
} from "@kinetixui/iot/functions";
 
normalizeDeviceStatus("CONNECTED");        // "online"
normalizeDeviceStatus("ota");              // "updating"
normalizeDeviceStatus("something else");   // "offline"  (and isKnownDeviceStatus() is false)
 
classifyBatteryLevel(72);                  // "high"
classifyBatteryLevel(null);                // "unknown"  — not "critical"
 
classifySignalStrength(0);                 // "none"     — reported, and zero
classifySignalStrength(undefined);         // "unknown"  — not reported
 
formatLastSeen(fiveMinutesAgo, { now });   // "5m ago"
formatLastSeen(null);                      // "Never"
 
detectStaleReading(undefined, 60_000);     // true       — freshness is a claim
formatTelemetryValue({ value: 0, quality: "missing" }); // "Unknown" — never "0"
 
compareFirmwareVersions("1.9.9", "1.10.0"); // -1        — numeric, not lexical
compareFirmwareVersions("R3.2", "1.0.0");   // null      — not 0

"We do not know" survives#

That is the rule running through all of it. A device that does not report a battery is not a device with a flat one. An undated reading is not a fresh one. A firmware pair that cannot be compared is not up to date. Each of those is a place where a convenient default becomes a lie the interface tells about someone's hardware, so unknown is a first-class result rather than a fallback — and isKnownDeviceStatus exists so that the information normalizeDeviceStatus discards stays reachable.

Documented limitations#

  • Signal is a normalised 0–100 quality, not dBm. dBm ranges differ per radio — Wi-Fi, LTE, LoRa and BLE do not share a usable scale — so banding dBm would mean guessing the radio. That conversion belongs in the product, next to the radio it knows about.
  • Firmware comparison ignores prerelease tags. 1.4.0-rc.2 and 1.4.0 compare equal. Device firmware strings are rarely semver (1.04, 2.1.3.7, R3.2 all occur), so a strict parser would reject more real versions than the loose comparison gets wrong, and no semver dependency is added.
  • formatLastSeen stops at days. A year-old reading reads 400d ago, not 1y ago. Month arithmetic needs the locale rules this deliberately avoids.
  • buildDeviceCommand requires an id. Generating one would make the function non-deterministic, and products almost always have an id they need to correlate with.

React primitives#

import {
  BatteryIndicator,
  DeviceStatusBadge,
  LastSync,
  SensorReading,
  SignalStrength,
} from "@kinetixui/iot/react";
 
<DeviceStatusBadge status="online" />
<BatteryIndicator value={72} />
<SignalStrength value={84} />
<LastSync value={device.lastSeenAt} />
<SensorReading metric="Temperature" value={23.4} unit="°C" />

Eight primitives in all: those five, plus DeviceIcon and DeviceIdentity (a device's category, drawn with its state carried on the icon tile) and MetricStatus (a reading's state as a glyph and a word). Each accepts className and forwards the rest of its props, and each emits a data- attribute for the value it resolved — data-status, data-level, data-quality — so a product can restyle per state without this module growing a variant API.

There is no size-variant prop. These are single-line readings that inherit their type scale from the surrounding text, and a variant matrix would be eight APIs to maintain for a choice CSS already makes.

Four levels, and when each is the right one#

The React surface has three layers, and the website carries a fourth. Knowing which one you are reaching for saves rebuilding something that already exists — or importing something larger than the job.

LevelWhat it isWhere it livesReach for it when
PrimitivesOne fact, rendered@kinetixui/iot/reactYou are laying out your own card and need the status, battery, signal, timestamp, reading or identity inside it
ControlsOne thing a user can change@kinetixui/iot/reactThe user needs to operate the device, not just read it
PatternsA composition of the above, plus a rule or two@kinetixui/iot/reactYou are building the arrangement every device product builds and would otherwise rewrite it
ExamplesA whole screen, assembled from patternskinetixui.com/iot — copy, do not installYou want a starting layout for a fleet view, a detail panel or an alert list

Primitives, controls and patterns are all published API. Examples are not: they are source on the website, meant to be read and copied into your own codebase, because a screen layout is where your product's decisions live and importing someone else's is rarely the shortcut it looks like.

Controls#

Unreleased. Everything in this section is in main and not in the published package — see Install.

A control is the part that makes this a connected-device system rather than a dashboard. All four share one rule, and it is the rule most device UIs get wrong:

What the user asked for is drawn differently from what the device has confirmed.

A switch that slides over and fills in the instant you touch it is telling you the device is on. It does not know that. On a lamp the lie is harmless; on a door lock or an irrigation valve it is not, and the user finds out minutes later. So a requested state and a confirmed state never look the same:

ControlConfirmedRequested, not yet confirmed
DevicePowerControlTrack filled, knob solidTrack moves, knob stays hollow and dashed
DeviceLevelControlSolid fill to the valueHatched fill to the requested value, solid fill left at the confirmed one
DeviceSetpointControlTarget shown plainlyTarget tinted, with the current reading still visible beneath it
DeviceModeControlMode filledMode outlined and dashed, never filled

The same distinction reaches assistive technology rather than being a visual flourish: aria-checked on the power switch reports the confirmed state while a request is open, and the slider's aria-valuetext names both numbers.

resolveControlState#

Controls do not each decide what "offline" or "pending" means. resolveControlState takes a device status and an optional command status and returns one answer:

import { resolveControlState } from "@kinetixui/iot/functions";
 
const control = resolveControlState({
  deviceStatus: device.status,
  commandStatus: pendingCommand?.status,
});
 
<DevicePowerControl state={device.power} requested={requested} control={control} label={device.name} />;

Its precedence is fixed so that five controls on one screen cannot disagree:

  1. An explicit disabled wins over everything.
  2. Device status outranks command status — a command cannot be meaningfully pending on a device that is offline.
  3. A command in flight is pending, and pending is not interactive: a second press while the first is unresolved is how users end up toggling a device twice.
  4. stale stays interactive. Sending a command is exactly how you find out whether a quiet device is still there, so stale must not lock the control the way offline does.

An unreported status resolves to offline, inherited from normalizeDeviceStatus — a control for a device nothing is known about should not look live.

Composition over configuration#

DeviceControlCard takes slots — primaryControl, statusLine, meta, expanded — rather than a prop per control. The alternative is onToggle, level, onLevelChange, mode, modes, onModeChange, setpoint… and it would still not fit the fourth product that arrives with a control nobody predicted.

<DeviceControlCard
  device={device}
  active={device.power === "on"}
  control={control}
  statusLine="Warming to 21°"
  primaryControl={<DevicePowerControl state={device.power} control={control} label={device.name} />}
  expanded={<DeviceLevelControl value={device.level} control={control} label="Brightness" />}
/>

Expanded content is unmounted while collapsed, so a grid of twenty cards does not carry twenty sliders it is not showing.

The library owns no vocabulary#

Mode labels come from you. There is no built-in "heat" | "cool" | "auto", because the same control serves a thermostat, an irrigation valve and a conveyor, and a library that ships one domain's words is wrong for the other two. The same applies to groups: DeviceGroupCard takes a kind string, so a home calls it a room, a farm calls it a zone and an operator calls it a site.

Unavailable modes stay visible and aria-disabled rather than disappearing — a mode that vanishes cannot explain why it is gone.

Commands are sent on release#

DeviceLevelControl composes a real input[type="range"] rather than reimplementing a slider, so keyboard, pointer and assistive technology behave exactly as the platform does. It reports a live value through onPreview and commits through onCommit on release — one command per drag rather than one per animation frame, which on a real gateway is a real cost.

Patterns#

27 compositions, each built from the primitives and controls above rather than re-deriving their semantics — so what "stale" means is decided once and every one of them agrees.

import {
  AlertList,
  CommandLifecycle,
  DeviceCard,
  DeviceHealthSummary,
  TelemetryGrid,
  TelemetryTrend,
} from "@kinetixui/iot/react";
MomentPatternsAnswers
A deviceDeviceCard, DeviceListItem, DeviceControlCard, DeviceStateSummaryWhat is this device doing, and what can I do about it?
Connection and firmwareConnectionHealth, FirmwareStatusWhy can I not reach it, and what version is it on?
A change settlingCommandLifecycle, CommandStatusWhere did the command I sent get to?
A readingTelemetryMetric, TelemetryGrid, TelemetryCard, TelemetryTrendWhat is it, how much should I trust it, and what has it been doing, including where it stopped?
Alerts and historyAlertList, AlertCard, ActivityTimelineWhat is wrong, has anyone looked, and what happened?
Places and healthDeviceGroupCard, DeviceHealthSummary, SpaceBreadcrumb, SpaceRollupHow is this room, zone, line or site doing?
AutomationAutomationRuleView, AutomationBuilder, RoutineCardWhat will happen, and when? (The rule is edited here and run by you.)
Adding a devicePairingMethodPicker, PairingStepper, PairingFailureHow is setup going, and what next if it fails?
Two device familiesCameraDeviceCard, EnergySummaryA camera that is never a feed; energy that is never a bill.

Two rules the patterns add#

A device in transition does not show its last value. DeviceCard and DeviceListItem suppress the reading while a device is syncing, pairing or updating, showing the state where the number would be. A value rendered beside "Updating" is read as the current value, and it is not.

A dropout is drawn as a dropout. TelemetryTrend breaks the line at any point whose quality is missing or error and never interpolates across it. A straight line between two readings an hour apart is a claim that the sensor was answering in between, which is the claim quality exists to prevent. The bounds are printed as text beneath the plot, so the chart's textual equivalent is what everyone reads rather than a hidden description that can drift out of step.

Composition, not configuration#

DeviceCard takes an action node and decides where it goes; it has no onToggle, onPower or onRun. What a control does implies a transport, and this module has none — so the card owns the position and your product owns the behaviour.

Two candidates were evaluated and deliberately left out. A QuickAction would duplicate @kinetixui/ui's Button in a package that cannot depend on it. A DeviceDetailHeader is a layout arrangement rather than a semantic one, which makes it an example rather than an API.

Motion#

The patterns transition colour on state change, and an in-flight command carries a pulse. Both use Tailwind's own motion-reduce: variant, so a reader who asks for reduced motion gets the static version with no stylesheet to copy and no page-level rule to remember. Nothing loops, nothing moves a layout, and no animation carries information that is not also written down.

Accessibility#

Every primitive renders its fact as text, so the reading survives greyscale, forced-colors mode and a screen reader. Colour is always a second encoding of something already written down.

Where a visual shorthand is used — a battery bar, a signal meter, 5m ago — the visuals are hidden from assistive technology and the full sentence is supplied as the accessible name. Nothing is announced twice, and nothing is announced only as an abbreviation.

Online
Battery 72%, high
Signal 84%, excellent
Last seen 5 minutes ago
Temperature 23.4 °C

Those sentences come from the pure half — describeDeviceStatus, describeBattery, describeSignal, describeLastSeen — so the text a screen reader receives is covered by the same unit tests as the classification, and a non-React consumer can render it.

Missing values are stated rather than implied: Battery level unknown, Signal strength unknown, Never seen, No reading. LastSync renders a <time> whose dateTime is omitted rather than left blank when there is no instant to state.

No primitive animates, so there is nothing for prefers-reduced-motion to suppress — asserted in the test suite, not assumed. See Accessibility for how the rest of the library is verified.

State is never colour alone#

Status, severity and reading state are each a glyph with its own silhouette and a word, and offline is a dashed edge as well as a muted one. A requested value and a confirmed one differ by shape and text, not by hue.

Live regions are rare#

CommandLifecycle and AutomationBuilder each have one polite role="status"; PairingFailure is a role="alert". Nothing else announces, and a live region announces changes, so nothing is spoken on first render.

Charts and scroll regions#

TelemetryTrend prints its bounds and a summary as text and offers a "View data" table, so the plot is never the only way to reach a number. Any region that scrolls horizontally must be focusable and named; the website's examples use role="region", an accessible name and tabindex="0".

Right-to-left#

The components use logical properties (ms-, ps-, text-start) rather than left and right, so a right-to-left page mirrors without a second stylesheet, and DevicePowerControl reverses its knob travel with Tailwind's rtl: variant. Two deliberate exceptions keep their direction: the time axis of TelemetryTrend stays left-to-right, because time is drawn one way on a plot in every reading direction, and identifiers, code and units should be wrapped in dir="ltr" inside right-to-left text. If you set dir on a Radix-based wrapper of your own, pass it through so arrow keys follow the reading order.

Simulation#

The website's demos are driven by a small deterministic simulation that lives in the website's source, not in @kinetixui/iot. It exists so that a demo can show a command settling, a sensor drifting past a threshold or a device going quiet without a device. It is demo state, and every page that renders it says so with the same disclosure sentence:

Simulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.

What it is, and is not:

  • Deterministic. A scenario is data (devices, spaces, sensors, latencies, scripted events). Readings are a pure function of a seed, a sensor and elapsed time, from a seeded generator, so a given scenario and seed produce the same numbers every time.
  • An injected clock. The simulation never reads the time itself. Its state advances when a caller passes it a time, which is how the tests run it without waiting and how the React hook drives it from performance.now. The first render is at the scenario's fixed start time, so server and client agree, and time only advances after mount.
  • It models the honest lifecycle. A command runs through the real @kinetixui/iot lifecycle machine — requested, acknowledged, confirmed — with per-device latency, and a device can be flaky (fail its first commands), slow, or unreachable, in which case the command times out.
  • Not an automation engine. Where a scenario shows an automation firing, the event is a scripted entry that says “Scripted demo event — KinetixUI has no automation engine, so no rule was evaluated.”
  • Not a transport, and not a test double for one. It is not exported, not published and not something to build a product on. Your application supplies the real thing.
  • Reduced motion. Under prefers-reduced-motion ambient drift stops; a command you issued still settles.

Reference environments#

Three environments are published on the overview page as source to copy. Each is a fabricated scenario — nothing was measured and no device exists — and each carries the simulation disclosure.

EnvironmentLevelsWhat it exercises
Smart spaceHome → Floor → RoomLight, thermostat, plug and lock controls; air quality; a sample camera frame with no feed; energy; alerts; scenes
AgritechFarm → Field → Irrigation zoneSoil and climate readings; a soil-moisture trend against its threshold; valve and pump controls with a flaky valve; an irrigation rule
OperationsOrganization → Site → Line → MachineFleet and equipment health with a rollup at every level; energy; faults; command history

They are compositions, not components: read them, copy what fits, and replace the scenario with your own adapter. Alongside them the site publishes compositions for the moments above — a state-honesty demo, telemetry history, an alert centre, the automation builder, a pairing flow and a pump-station device detail — and the earlier fleet, dashboard, connected-space, telemetry-board, alert-inbox and connection-troubleshooting layouts.

Roadmap#

Nothing here is scheduled, and none of it is in the module today.

  • A grouped or scheduled command queue — genuinely useful and genuinely a product concern. Modelling it here risks becoming the engine this module refuses to be, so it waits for a real product's requirements.
  • Optimistic updates with rollback, as an opt-in — some products legitimately want it for low-stakes devices, and it needs a rollback story before it is safe to offer.

Transports, an automation engine and video are not roadmap items: they are outside what this module is.