Context for developing and debugging Hubitat Elevation apps, drivers, and hub environment — sandbox constraints, lifecycle idioms, capability contracts, plus grounded deploy/log-tail/lint mechanisms.
—
—
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
High
Do not use without reviewing
Verified 2026-07-14 against three C-8 Pro hubs on platform 2.5.1.125, local network, Hub Security off. These endpoints are undocumented and version-sensitive — Hubitat does not support them and they can shift between firmware releases. Only the Maker API and the /management/* token API are officially supported. Re-verify after a platform update; the _meta.verified_platform in skills/_reference/capabilities.json tracks the baseline.
Base is http://<hub-ip>:8080 unless noted. Websockets are on port 80 (ws://<hub-ip>/...). With Hub Security off, no authentication is needed — no login, no cookie. If a hub ever enables Hub Security, every call below needs a session cookie from POST /login.
Confirmed returning clean JSON on 2.5.1.125:
| Endpoint | Returns |
|---|---|
GET /hub2/userAppTypes | Array of user app code entries: {id, name, namespace, oauth, lastModified, ...} |
GET /hub2/userDeviceTypes | Array of user driver code entries: {id, name, namespace, capabilities, ...} |
GET /hub2/userLibraries | Array of library code entries: {id, version, author, category, description, ...} |
The id from these lists is the <codeId> used in the code round-trip and update endpoints below. (These /hub2/user* endpoints supersede the older /app/list/... HTML pages the community catalogs list.)
| Endpoint | Returns |
|---|---|
GET /app/ajax/code?id=<codeId> | {id, name, version, source, status} |
GET /driver/ajax/code?id=<codeId> | {id, version, source, status} |
GET /library/list/single/data/<libId> | Library source |
version is an integer bumped on every save. It is the optimistic-concurrency token — see below.
All Content-Type: application/x-www-form-urlencoded.
| Action | Endpoint | Body | Notes |
|---|---|---|---|
| Create app | POST /app/save | id= (empty), version= (empty), create=, source=<groovy> | New id comes back in the Location redirect: /app/editor/<id> |
| Update app | POST /app/ajax/update | id, version, source | Returns JSON {status:"success"}. Must send the current version |
| Create driver | POST /driver/save | id=, version=, create=, source=<groovy> | New id from Location: /driver/editor/<id> |
| Update driver | POST /driver/ajax/update | id, version, source | Same version rule as apps |
| Enable OAuth (app) | POST /app/edit/update | id, version, oauthEnabled=true, _action_update=Update | OAuth cannot be enabled from source alone |
Optimistic concurrency: the hub rejects an update whose version is not the current one. This is the "don't clobber a newer hub edit" guard. The deploy flow: read current version via /…/ajax/code, send it with the update; on rejection, re-pull and reconcile — never blindly retry with a bumped number. Save == compile: a Groovy compile error is returned inline and the code does not save.
Both confirmed on 2.5.1.125 — GET upgrade returns HTTP 101 Switching Protocols, server frames are unmasked text (opcode 1), no external library required.
| Socket | Frame shape (JSON per message) |
|---|---|
ws://<hub-ip>/logsocket | {name, msg, id, time, type, level} — level ∈ error|warn|info|debug|trace; type ∈ dev|app |
ws://<hub-ip>/eventsocket | {source, name, displayName, value, type, unit, deviceId, hubId, installedAppId, descriptionText} |
Verified /logsocket frame captured live: {"name":"mZone-Butler Pantry Zone","msg":"...is inactive","id":1199,"time":"2026-07-14 08:05:53.760","type":"dev","level":"info"}.
REST log pulls also exist: GET /logs/json, /logs/eventsJson, /logs/past/json.
Both verified on 2.5.1.128. These answer "when did this actually change?", which no status field can.
| Endpoint | Returns |
|---|---|
GET /device/eventsJson/<deviceId> | Device event history: date, name, value, descriptionText, source, type, producedBy, triggered, isStateChange, physical, digital, unit. [] for a device that has never evented (measured: 23 of 156 devices) |
GET /hub/eventsJson | Hub events — systemStart, update, manualReboot, cloudBackup. value on update/systemStart is the build number, so this is the hub's firmware timeline |
Commands are events too, and they name their caller. /device/eventsJson carries command-<name> entries (type: "command") alongside attribute changes, so a command being issued is visible separately from the attribute moving — that gap is the whole diagnosis in a silent-failure case. producedBy names the app that issued it. Verified frame:
{"name": "command-on", "value": null, "type": "command", "date": "2026-07-14T11:01:54.455-0500",
"descriptionText": "Command called: on()", "isStateChange": false, "deviceId": 442,
"producedBy": "<a href='/installedapp/configure/583' target='_blank' class='text-base'>HomeKit Integration</a>"}/hub/eventsJson is how you correlate "it broke around Tuesday" with a platform update, and it pairs with the version-sensitivity warning at the top of this file: it is how you find out when the platform moved.
HTML rides inside JSON string fields. producedBy above is an anchor, not a name. So is ipAddress in /hub/details/json (<a href="http://192.0.2.12">192.0.2.12</a> (Ethernet)), and app names in the log endpoints carry status markup (Ecobee Suite Manager<span style="color:green"> Online</span> — 3570 of 8205 past-log lines held markup on the measured hub). Strip tags before matching on any of these; a name compared raw will not match.
Measured on one C-7, one moment, hub TZ US/Central (-0500):
| Source | Sample | Shape |
|---|---|---|
GET /hub/details/json → currentTime | 2026-07-16T20:14:07+0000 | UTC, explicit offset |
GET /logs/eventsJson → date | 2026-07-16T12:00:06.874-0500 | hub-local, explicit offset |
GET /logs/past/json → stamp | 2026-07-16 20:12:40.424 | UTC, and naive — no offset to warn you |
Correlating an app's log line against an event across these two silently mis-orders by the hub's offset — you conclude a handler never fired when it fired five hours "earlier". Same trap as the zwaveJS lastTime note above, on two endpoints a debugger uses together constantly.
They are also ordered oppositely — /logs/past/json is oldest-first, /logs/eventsJson is newest-first. And /logs/past/json returns a JSON array of raw pre-formatted strings, not objects: three tab-separated fields, the third pipe-delimited.
"2026-07-16 20:12:40.424\tTRACE\tapp|4|Ecobee Suite Manager|Updates sent (132 / 2095ms)"| Endpoint | Returns |
|---|---|
GET /hub/details/json | Hub identity: platformVersion, hardwareVersion, hubName, hubUID, ipAddress, macAddress, timeZone, ... (confirmed ~49 KB on 2.5.1.125) |
GET /hub2/hubData | Newer JSON hub backend |
GET /hub2/devicesList | Devices: {suggestBackup, devices:[{key, data:{id, name, ...}, children[], parent, child}]} — a tree: parent/child are bools ("is a parent" / "is a child"), and children appear only nested in children[], never at the top level. Iterating devices[] flat misses every child device (skills/_reference/parent-child-devices.md) |
GET /hub2/appsList | Installed apps + systemAppTypes |
GET /modes/json | Real hub mode ids and names. Room Lighting's setting value "0" is an All Modes sentinel, not an id from this list |
GET /hub/edit | The Settings page (UI). Not /hub/settings, which 404s — the nav link is the authority |
GET /installedapp/direct/<builtInAppType> | Opens a built-in app, redirecting to a transient instance at /installedapp/configure/<newId>/mainPage (e.g. swapDevice → Settings → Swap Device). The instance takes the next app id and is not a persistent install: its Cancel discards it, after which /installedapp/statusJson/<id> returns {} and it is absent from /hub2/appsList. Verified 2.5.1.128 |
GET /device/fullJson/<deviceId> returns the hub's own computed "in use by" list for a device — verified live on 2.5.1.128 (C-8 Pro, Hub Security off). This is the removal blast radius, straight from the hub; skills/_scripts/hub_device_usage.py projects it and the device-removal skill reads it.
| Field | Shape |
|---|---|
appsUsing | Array of {id, name, label, trueLabel, disabled} — every app referencing the device. disabled is only the app's switch state; enabled does not prove the reference is live |
appsUsingCount | String on the wire ("2") |
appsUsingForDialog / appsUsingForDialogMore | The same list shaped for the "in use by N apps" confirm dialog |
dashboards | Array of dashboards showing the device ([] when none) |
parentApp | The app that created the device, or null (non-null for app-managed integrations like CoCoHue / HubiThings Replica) |
childDevices / hasChildren | childDevices is a dict {parentId: [child device objects]}; a delete of the parent takes the children with it |
appsUsing is the delete blast radius, not a liveness list. Rule Machine can leave a stale
tDev-N setting and state.trigDevsW entry after its live trigger moved; the old device remains in
appsUsing even though state.trigDevs points elsewhere. The inert reference still belongs in a
delete warning.
fullJson, grounded 2.5.1.134–135)The same GET /device/fullJson/<id> that carries the blast radius also carries the device's command surface and both persistence stores — read them here instead of reading driver source or inferring from the declared capability.
| Field | Holds |
|---|---|
commands[] | The authoritative command list: each {name, parameters:[{type, defaultValue}], arguments, relatedAttribute, capability:<bool>}. capability:true is a capability-required command; false is a driver custom command — inferring commands from the declared capability via skills/_reference/capabilities.json misses every custom command. parameters[].type is what runmethod's args must match (setZoneWaterTime takes a number; a string fails oddly) |
deviceState (top level) | Groovy state — the driver's internal bookkeeping. {} for drivers that keep none (rules/state-vs-attributes.md) |
device.currentStates (nested) | Attributes / Current States, a dict keyed by attribute name. Each entry: value, date, typed variants (numberValue, floatValue, stringValue, jsonValue), dataType — so a device's full attribute set is discoverable without driver source (useful for lint-review, scaffold) |
deviceState and device.currentStates are easy to reach for backwards — Groovy state is top-level deviceState, attributes are nested under device. currentStates[attr].date is the last change, not the last report — it sits right beside value and reads like a freshness stamp but is not one (the rules/state-vs-attributes.md trap, embodied in a field). Measure a real gap distribution with GET /device/eventsJson/<id>.
Defensive-parse these — each bit during the irrigation recon:
GET /hub2/appsList returns a dict with an apps key, not a list. Built-in app entries populate name and leave label null.GET /installedapp/statusJson/<id> can return a bare {} — another instance of this route being unreliable for reading app config (below).GET /installedapp/configure/json/<appId>/mainPage can return non-JSON — parse defensively, do not assume a JSON body.GET /device/edit/<id> is a 6.8 KB SPA shell — no state, no routes, nothing in the served HTML. It is the natural first thing to try for a device and it is a dead end; drive the page with Playwright and capture the request instead (skills/_reference/playwright-ui.md).GET /installedapp/statusJson/<appId> carries several distinct surfaces:
| Field | Meaning |
|---|---|
settings | Null for device/capability inputs on the verified builds; do not use it to inventory them |
appSettings[] | Resolved settings, including name, deviceIdsForDeviceList, and deviceList; best one-call answer to "which input holds this device?" |
eventSubscriptions[] | Current subscriptions; match the device against typeId for positive live evidence |
state.trigDevs / appState.trigDevs | Rule Machine's authoritative trigger-device map |
state.trigDevsW / appState.trigDevsW | Rule Machine withdrawn trigger bookkeeping; not a live trigger |
scheduledJobs[].prevRunTime | Null until that schedule has fired since creation/reset; null does not mean disabled |
GET /installedapp/configure/json/<appId>/<page>.settings remains the page-specific configured-input
view and also carries removeButton. A page value is a device-id→label map, not a {value: ...}
wrapper. Configuration alone does not prove liveness.
For Rule Machine, read trigDevs even when eventSubscriptions is empty: a false Required
Expression temporarily removes subscriptions while the trigger remains correctly configured.
Absence from trigDevs is a negative only for a trigger-role reference. A Rule Machine action or
condition device may be live without appearing in that trigger map.
For arbitrary apps, a missing subscription is not a safe negative because command-only consumers
do not subscribe. skills/_scripts/hub_device_usage.py --live applies this three-state audit and
leaves unsupported negative cases unknown.
Prefer Maker API for exercising devices in a test loop. Local: http://<hub-ip>/apps/api/<makerAppId>/<path>?access_token=<token>. The <token> is a secret — read it from an environment variable or the operator's secrets store at call time; never hardcode it, commit it, echo it into agent output, or write it to a log (a token carried in a query string otherwise leaks into request logs). The undocumented local endpoints above are the preferred path precisely because they need no token on a Hub-Security-off hub. Key paths: /devices (list), /devices/all (full JSON: capabilities, attributes, commands), /devices/<id>, /devices/<id>/<command>/<secondaryValue> (send command), /devices/<id>/events. Multi-hub note: with hubs meshed, one Maker API instance can expose devices from secondary hubs too — but code endpoints are per-hub and have no mesh.
Several operations documented as "UI-only" are ordinary HTTP requests the UI fires. Drive the UI once with Playwright, read the request the button fires (browser_network_requests), then replay it directly thereafter — the UI is the discovery tool, not the runtime. Baseline for this section: C-8 Pro, 2.5.1.x, zwaveJS backend, local network, Hub Security off; re-verify after a platform update. Still expanding as findings accumulate.
| Endpoint | Body / params | Effect |
|---|---|---|
POST /hub/zwave/nodeRemove | zwaveNodeId=<decimalNodeId> (application/x-www-form-urlencoded, no CSRF token) | Force-removes a FAILED Z-Wave orphan → 302 to /hub/zwaveInfo; the node drops out of /hub/zwaveDetails/json. Removal is async — poll the census, don't assume instant |
GET /hub/zwaveRepair2?resetStats=false&maxHealth=10 | UI defaults; other values untested | Starts a full Z-Wave network rebuild (zwaveJS) → 200. Poll with the two below (2026-07-22) |
GET /hub/zwaveRepair2Status | — | Rebuild progress {stage, html}; html lists Pending / Skipped node ids in hex (57 = node 87) (2026-07-22) |
GET /hub/checkZwaveRepairRunning | — | {"isZWaveNetworkHealRunning":"true"} — whether a rebuild is in progress (2026-07-22) |
POST /device/runmethod | JSON {"id":<deviceId>,"method":"<command>","args":[<secondaryValues>]} | Sends a device command without a Maker API app or token → 200 {"success":<bool>,"message":null}. args is the ordered command params (setLevel → [level, duration]). success:true means the method was dispatched, not that anything changed — verify by observation (below) |
POST /device/update | form-urlencoded, the full device field set (see note) | Renames / edits a device → 200. Omitting any field clears it. Carries a version concurrency token; the mesh-boolean pair is destructive if mis-encoded (see note) |
POST /installedapp/disable | JSON {"id":<appId>,"disable":<bool>} | Enables (false) / disables (true) any app instance → 200 {"result":<bool>} (verified 2026-07-21) |
GET /installedapp/createchild/hubitat/<ChildAppName>/parent/<parentAppId> | path-encoded <ChildAppName> (e.g. Room%20Lights) | Creates a parent/child app instance → 302 to /installedapp/configure/<newId>/mainPage (2026-07-22) |
GET /installedapp/create/<appTypeId> | <appTypeId> from /hub2/appsList userAppTypes[].id | Creates a standalone user-app instance → 302 to the transient configure page (2026-07-22) |
GET /device/listJson?capability=<capability.foo[,capability.bar]> | capabilities comma-joined | Capability-filtered device list [{id, displayName, …}] — the list the classic .btn-device picker fetches; enumerate an input's candidate devices without the UI (2026-07-22) |
GET /device/addToMesh/<deviceId> | — | Hub Mesh: share a local device to the mesh (run on the source hub) → 200; the device joins sharedDevices[] (2026-07-22) |
GET /device/createLinked/<sourceHubId>/<sourceDeviceId> | <sourceHubId> = peer hub UUID (hubMeshJson hubId / sharedDevices[].sourceHubId) | Hub Mesh: link a peer-shared device (run on the destination hub) → 200; mints a new local linked device bound to the source (2026-07-22) |
GET /device/hubMeshFullRefreshNow | — | Hub Mesh full resync (either hub) → 200; does not by itself link available devices (2026-07-22) |
Hub Mesh sharing is two-sided. The source hub shares a device (addToMesh); the destination hub must then explicitly link it (createLinked) — a shared device does not auto-appear on the destination, and neither a Linked-devices refresh nor hubMeshFullRefreshNow links it. The Hub Mesh UI lives at /device/hubMesh (not /hub2/hubMesh, which 404s). Un-share / un-link are not yet captured — removeFromMesh and a removeLinked counterpart are likely but unverified; do not assume the path. Read side is /hub2/hubMeshJson (Hub mesh section below); this grounds the cross-hub re-home in skills/device-migration/SKILL.md.
Instance creation is transient. createchild (parent/child, e.g. Room Lighting) and create (standalone user app) both land on /installedapp/configure/<newId>/mainPage that persists only on Done (_action_update) and is discarded on Cancel — the parent/child and standalone companions to GET /installedapp/direct/<builtInAppType> for built-in apps (above). The UI-drive mechanics for filling and committing those config pages are in skills/_reference/playwright-ui.md (gotchas 16, 27–28).
nodeRemove is guarded to FAILED orphans only. Verified 23× live on nodes with no bound deviceId, each confirmed by census diff against /hub/zwaveDetails/json. Behavior on a healthy/OK node (strict removeFailedNode vs. general remove) is untested — gate every call on present + no deviceId + nodeState:FAILED, and never POST a real device id.
runmethod is the "flash a stale device to wake it" primitive — verified {"id":389,"method":"on","args":[]} turned a plug on and flipped its Z-Wave nodeState FAILED→OK.
A command return code is not evidence it executed. runmethod returns {"success":true} when the Groovy method is dispatched, not when the device moved — a method that throws, or a command to the state the device is already in, returns the identical payload, and in the already-in-state case the platform's change filter suppresses the event too (rules/state-vs-attributes.md), so "no event" does not distinguish worked from failed. This is the same success-shaped-lie as /device/delete returning 302 for a nonexistent id. Confirm through the command's relatedAttribute moving in currentStates, or through GET /device/eventsJson/<id>. The command surface (fullJson.commands[]) is in Device command & state surfaces above.
POST /device/update (device rename/edit) — verified 9 devices, 2.5.1.135. Same /device/ namespace as runmethod, entirely different conventions — do not assume one shape from the other:
POST /device/update | POST /device/runmethod | |
|---|---|---|
| encoding | form-urlencoded | JSON |
| payload | full field set, omissions clear values | minimal three keys |
| concurrency | version token required | none |
| response | HTML / redirect | {"success":<bool>,"message":null} |
The full field set: name, label, zigbeeId, maxEvents, maxStates, spammyThreshold, deviceNetworkId, deviceTypeId, deviceTypeReadableType, roomId, meshEnabled, retryEnabled, meshFullSync, homeKitEnabled, locationId, hubId, groupId, dashboardIds, tags, defaultIcon, notes, id, version, controllerType. Three traps:
version is an optimistic-concurrency token, the same pattern as /app/ajax/code (rules/multi-hub-topology.md) — read it fresh from fullJson immediately before each POST; a successful update bumps it.meshEnabled / meshFullSync are checkbox-semantic — sent as on when true, omitted entirely when false. retryEnabled / homeKitEnabled, right beside them, are literal true / false strings. Mis-encoding the mesh pair is destructive: it disables hub-mesh sharing, removing the mirrored device on the consuming hub and breaking every app bound to the mirror. A naive "serialize all booleans the same way" replay triggers it.skills/_reference/playwright-ui.md.Retention fields are display-tuned defaults. maxEvents defaults to 11 per attribute — 11 stored changes, not reports (eventsJson is change-filtered), so on a slow-moving signal it is a few hours and on a fast-changing one under an hour, rolled silently either way. maxStates 30, spammyThreshold 300. Raise maxEvents through this endpoint to use the hub as a short-horizon data buffer — 1000 covers ~two weeks of a slow-moving signal (rules/data-collection.md).
Hub-mesh mirrors follow a rename automatically — all nine mirror name values updated on the consuming hub with no action there. This is the opposite of delete, where mirrors survive the source's removal and need cleanup on both hubs (rules/device-lifecycle.md): rename propagates, delete does not.
Z-Wave rebuild is gated by node type. The per-node "Rebuild route" action is offered only for mains / always-listening nodes (repeaters, plugs, lamps); sleepy battery nodes (e.g. a door lock) show only Refresh · State — no on-demand route rebuild. The global rebuild (zwaveRepair2) is the only lever that touches a sleepy node, and its route rebuilds on its next wake — it sits in the status Pending list and completes async. zwaveJS backend only (the "Rebuild network" label); legacy uses different wording. For a marginal battery node the durable fix is RF/topology — a repeater — not a repair click (rules/zwave-zigbee-mesh.md).
Both return clean JSON through 2.5.1.132, no auth with Hub Security off. Drive them for mesh
diagnostics; the mesh-health skill reads them via skills/_scripts/hub_mesh.py.
| Endpoint | Returns |
|---|---|
GET /hub/zwaveDetails/json | {enabled, healthy, zwaveJS, firmwareVersion, region, longRangeChannel, nodes:[...]} |
GET /hub/zigbeeDetails/json | {enabled, networkState, healthy, inJoinMode, channel, weakChannel, panId, extendedPanId, powerLevel, devices:[...]} |
GET /hub/zigbee/getChildAndRouteInfo | text/plain — Child Data + Neighbor Table ([name, shortId], LQI:<n>, age:...) + Route Table. The per-device (router) LQI the JSON snapshot lacks |
GET /hub/zwaveTopology | Routing matrix as an HTML <table> (not JSON) |
Z-Wave nodes[] per-node fields: nodeId, deviceId (Hubitat device id), deviceName,
nodeState (OK | FAILED — FAILED is a failed/ghost node), msgCount (int — traffic volume;
weigh per against it), per (cumulative packet-error count, not a %), averageRtt (ms, string),
lwrRssi (string — see scale note), neighbors (int), routeChanges (int or N/A), route,
security, listening, beaming, batteryPercent, lastTime (when the hub last heard the node —
see the timestamp trap below; absent on a node never heard, which is reported nodeState:OK).
listening:true is the always-on / classic-mesh repeater indicator. beaming means the node
requires beam wake-up, not that it beams for others; its JSON value was false for every sampled
node on 2.5.1.132, including FLiRS locks, so read the Z-Wave Details UI Beaming column when that
status matters. route uses hexadecimal ids and includes hub plus destination: 01 -> 57 is direct
to node 0x57; only intermediate ids are repeaters.
Zigbee devices[] liveness trap: active is not freshness. Devices silent for years still
reported active:true on 2.5.1.132. Use the offset-bearing lastActivity timestamp; a missing
timestamp is unknown. A generic name:"Device" / type:"Device" remains the unfinished-join
signature.
Timestamp trap (grounded 2026-07-16, 2.5.1.128): lastTime carries a different shape per Z-Wave
backend. The legacy backend emits an explicit offset — 2026-07-16T00:49:14+0000, true UTC. The
zwaveJS backend emits a naive stamp in the hub's local zone — 2026-07-16T08:28:30.081.
Reading a naive stamp as UTC ages every zwaveJS node by the hub's offset (measured: a 70-second-old
node read as 5.02 h on America/Chicago). The zone is timeZone in GET /hub/details/json. Zigbee's
lastActivity carries +0000 on both. A second backend split beside the lwrRssi scale.
GET /hub2/hubMeshJson — the hub's own peer table (read side). Hub mesh carries commands between hubs, so a
peer with a stale record drops them while every radio metric stays green; skills/_scripts/hub_mesh.py
analyzes it and the mesh-health skill reads it. The write side — share (addToMesh) and link
(createLinked) a device — is in the UI-fired requests section above.
| Field | Shape |
|---|---|
hubList[] | Peers: {name, hubId, ipAddress, active, offline, warning, deviceIds[], lastActive, uiSSLOnly, uiSecurityEnabled, hubVarNames[]} |
hubList[].deviceIds | Devices shared over that link — the blast radius of removing the peer (each is a link an app can bind to) |
hubList[].lastActive | Epoch milliseconds (not an ISO string like everything else here) |
sharedDevices[] | {id, name, appsUsing[], childCount, sourceHubId} — sourceHubId: null means the device is local to this hub |
modeHubId | The hub that owns mode, or null |
hubId == hubUID: the hubId here is the same identifier as hubUID in GET /hub/details/json
(verified across three hubs). Fetching a peer's ipAddress and comparing its hubUID to the recorded
hubId is what distinguishes a live peer, a dead address, and an address reassigned to another hub.
The peer fields do not detect a stale record. A peer whose ipAddress pointed at a long-dead
address on another subnet reported active:true, offline:false, warning:null, with lastActive
refreshing every few seconds, while every command to it was silently dropped for 13.7 h. Only probing
the address finds it. The table is asymmetric — each hub keeps its own record of the others, and one
side can be correct while the other is stale.
Zigbee devices[] per-device fields: id, name, type, active (bool), ping,
messageCount, lastActivity, lastMessage, shortZigbeeId (16-bit), zigbeeId (64-bit IEEE).
No per-device LQI or RSSI is exposed here — per-device (router) LQI is in getChildAndRouteInfo
above; per-frame LQI+RSSI in the radio log sockets below. Use lastActivity for snapshot liveness;
active is not a freshness field.
Live radio log websockets (verified 2026-07-15 on 2.5.1.128, HTTP 101, unmasked text frames,
case-sensitive paths) — the per-frame decoded traffic, distinct from the driver /logsocket. Tail via skills/_scripts/hub_radiolog.py:
| Socket | Frame shape (JSON per message) |
|---|---|
ws://<hub-ip>/zwaveLogsocket | {sourceLabel, plainTextMessage, deviceId, time} — sourceLabel ∈ SERIAL|CNTRLR|DRIVER; node id and per-frame RSSI: -NN dBm live inside the decoded plainTextMessage text (deviceId is -999 for hub-level lines) |
ws://<hub-ip>/zigbeeLogsocket | {name, id, deviceId, profileId, clusterId, sourceEndpoint, destinationEndpoint, groupId, sequence, lastHopLqi, lastHopRssi, type, payload, time} — lastHopLqi (0–255) and lastHopRssi (dBm) of the last hop into the hub (the repeater→hub link for a routed device) |
Backend vs topology — two independent axes, both verified live (the load-bearing gotcha):
zwaveJS true/false) sets the lwrRssi scale — absolute dBm (negative, e.g. -78db) on zwaveJS vs dB above the noise floor (positive, e.g. 27dB) on legacy — and whether routeChanges is reported (N/A on zwaveJS, an int on legacy). Higher RSSI is better on both; a fixed numeric cutoff does not transfer.neighbors and routing: node id ≥ 256 = Z-Wave Long Range (a star — neighbors:0, a direct 01 -> <node> route, no repeaters, dynamic power); id ≤ 232 = classic mesh (neighbors + multi-hop routes). Verified: a classic node and LR nodes on the same zwaveJS hub show neighbors:5 vs 0, so neighbors:0 is LR topology, not the backend.Field meanings and the LR-vs-mesh remediation split: rules/zwave-zigbee-mesh.md.
GET /hub/advanced/getManagementToken → token, then /management/reboot?token=, /management/firmwareUpdate?token=. The Hub Information Driver (HPM) wraps reboot/update as device commands over Maker API.