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.
74
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Radio-mesh diagnosis is a different axis from code debugging. The data comes from three
undocumented JSON endpoints (skills/_reference/endpoints.md); skills/_scripts/hub_mesh.py fetches and flags,
the mesh-health skill interprets. Hubitat publishes no numeric "bad" thresholds — flag
unambiguous signals and rank the rest; never assert an invented cutoff.
A clean radio is not a working device. Every metric here describes the radio path. Commands
reach a device over a separate command path (app → hub mesh → owning hub → radio), and a broken
command path leaves every radio metric green. Radio evidence can never conclude "the mesh is
healthy, so the devices are fine" — see The command path.
nodeState FAILED — the node is unreachable. Split it by deviceId (hub_mesh.py tags failure_kind): FAILED with a bound deviceId is a real device currently unreachable — may be transient (recover it, do NOT delete); FAILED with no deviceId is an orphan ghost (a pairing that never bound a device) — safe to remove. Never force-remove a real device thinking it is a ghost.lastTime — when the hub last heard the node; absent on a node never heard at all (zwave.never_heard[]). Such a node is reported nodeState:OK and passes every radio check, so no FAILED-keyed check sees it. A never-heard node with no deviceId is a ghost by the split above; with a deviceId it is a real device that has never spoken — judge it, don't assume either way.per — cumulative packet-error count ("accumulation of packet errors for a node"), not a percentage. Lower is better; 0 is ideal. Nonzero means errors are occurring; judge severity relative to the node's msgCount and to peers.per is not reliably corroborated by the wire. Do not rank a node on it alone. Confirm a suspected packet-error offender against the transmit log (hub_radiolog) before calling it one; read packet_errors[] in summary.warnings as a lead, not a verdict.averageRtt — round-trip time in ms; lower is better. No spec cutoff — rank, don't threshold.listening — the always-on indicator. true identifies a mains-powered classic-mesh node that
can repeat. false identifies a sleepy node.listening instead of guessing repeater capability from device type.beaming means the node requires beam wake-up. It does not mean the node sends beams or repeats.beaming field was false even for sampled FLiRS locks on 2.5.1.132.beaming key as repeater proof.routeChanges — classic-mesh stability indicator; frequent changes mean the Last Working Route keeps failing. Reported by the legacy backend; N/A on the zwaveJS backend.neighbors — how many nodes a classic-mesh node hears. 0 for Long Range nodes (a star has none — see below), not a backend artifact.deviceNetworkId is hex; nodeId and every node-facing surface are decimal (skills/_reference/endpoints.md).deviceNetworkId to anything taking a node id.hub_radiolog.py --dni, or filter by --device-id instead.zigbeeId is hex on both sides.route — hexadecimal node ids with hub (01) first, the destination itself last, and repeaters
only between them. 01 -> 57 reaches node 0x57 directly. 01 -> 1B -> 57 uses repeater
0x1B. hub_mesh.py reads intermediate hops into zwave.route_fan_in and a per-node hops
count (0 direct, N repeaters, null no readable route).lwrRssi is the last hop INTO the hub, as Zigbee's lastHopRssi is. On a routed node it is the final repeater→hub link, not the end device's own radio.hops. hub_mesh.py splits ranked.by_rssi_direct / by_rssi_routed / by_rssi_route_unknown. Compare within one list.weak_signal_heuristic[] entries carry hops too. A flag on a routed node names the repeater's link into the hub, not the device's own.shared_device_count, and read the same way: blast radius, not a fault. zwave.route_fan_in.repeaters[] ranks every repeater by dependent_count; each node also carries its own dependent_count.summary counter, and needs no remedy. Hubitat publishes no "too many dependents" cutoff — rank, never threshold.zwave.route_fan_in.load_bearing_concerns[] is that cross: a repeater that is never_heard, FAILED, weak, or carrying packet errors, ranked by how many nodes sit behind it. A never-heard leaf is one silent device; a never-heard repeater carrying 12 is 12 nodes whose path runs through something the hub has no evidence is alive. Both sit in never_heard[] and only dependent_count tells them apart.never_heard. Quote its dependent_count as the scope of that one warning — never add it up as extra faults.dependent_count: null — the question does not apply, which is not the same as zero. route_fan_in.anomalies[] holds routes that do not run hub → node (stale or malformed records) and unknown_node marks a hop absent from nodes[]; both are data gaps, not mesh faults.lastTime proves the radio works. An actuator (shade, outlet, lamp) transmits only after being commanded, so its lastTime is when a command last landed — silence is unknown, not broken.zwave.stalest / zigbee.stalest this way. hub_mesh.py ranks staleness and never flags it — the classification is yours.hub_mesh.problems[] is critical: a peer that cannot carry commands is as dead as a failed radio. peer_unreachable / peer_identity_mismatch come from probing the recorded address; peer_offline / peer_inactive / peer_warning are the hub's own claims.active:true, offline:false, warning:null with lastActive ticking. Never read those three as an all-clear; only the probe is evidence. The peer table is asymmetric — each hub keeps its own record, and one side can be right while the other is stale.shared_device_count is its blast radius: every shared device is a link an app can bind to, and removing the peer unbinds them. Quote the count before advising a re-add, and prefer editing the address over remove-and-re-add. A hub whose address drifts re-breaks the record — a DHCP reservation on the hub's current IP is the durable fix.lastTime is stamped differently per backend: legacy emits an explicit +0000 (true UTC); zwaveJS emits a naive stamp in the hub's local zone (timeZone in /hub/details/json). Reading naive as UTC ages every zwaveJS node by the hub's UTC offset. Zigbee's lastActivity carries +0000 on both.lwrRssi is reported on two different scales depending on the Z-Wave backend. Read zwaveJS first.zwaveJS:true — absolute dBm (negative, e.g. -78db); closer to 0 is stronger. Silicon Labs receiver sensitivity: −97 dBm (700-series, 100 kbps GFSK) and −110 dBm (800-series LR channel, 100 kbps O-QPSK); the classic GFSK floor on 800 is a few dB higher. A route near the floor is genuinely weak.zwaveJS:false (legacy) — dB above the noise floor (positive, e.g. 27dB); ≤ 0 is at/below noise.routeChanges is also N/A under zwaveJS — a backend limit, independent of topology.hub_mesh.py tags each node's topology.neighbors:0 and a direct route (01 -> <node>) are inherent to LR, not faults. LR uses per-transmission dynamic power control over a long link budget, so a distant LR node sitting at −85…−93 dBm can be normal.neighbors, multi-hop route, routeChanges, repeaters, and Z-Wave repair apply.GET /hub/zwaveRepair2, skills/_reference/endpoints.md), and its route rebuilds on its next wake, sitting in the rebuild's Pending list and completing async. For a marginal battery node the durable fix is RF/topology — a repeater near it — not a repair click.hears but near-zero heard by (/hub/zwaveTopology) is a hypothesis to verify, not a verdict. It may be an unpropagated neighbour table rather than a placement fault. Read heard by and corroborate before concluding a fresh repeater is badly placed. Unverified (n=1, one build, reboot confounded with a version change): a zwaveRepair2 rebuild may not refresh neighbour tables where a hub reboot did. Treat "reboot after adding repeaters" as a lead to test, not standing guidance.zigbeeDetails snapshot exposes no per-device LQI or RSSI — from it, judge liveness, not signal.GET /hub/zigbee/getChildAndRouteInfo (the neighbor/route table, text — LQI:<n> per router). Per-frame LQI+RSSI come from the live zigbeeLogsocket. Don't rank Zigbee signal off the snapshot; use one of those.active is not liveness. Long-dead devices can remain active:true.active value as a freshness verdict.lastActivity and rank its age.lastActivity timestamp is unknown, not fresh.name:"Device" of type:"Device" is a join that never initialized — the Zigbee ghost.networkState ≠ ONLINE, healthy:false, or weakChannel:true (interference on the current channel). Zigbee uses 2.4 GHz channels 11–26; weakChannel overlaps busy Wi-Fi.ws://<ip>/zwaveLogsocket and ws://<ip>/zigbeeLogsocket stream per-frame decoded traffic — distinct from the driver /logsocket. Tail via skills/_scripts/hub_radiolog.py; the snapshot says who is weak, the log shows it happening.lastHopLqi/lastHopRssi, Z-Wave per-frame RSSI: -NN dBm), sequence gaps (a soft missed-frame hint, not a hard drop count — the counter is shared across the device's traffic), and which cluster/command a device uses.lastHopLqi/lastHopRssi are the last hop into the hub — for a routed device that is the repeater→hub link, not the end device's own radio. ZCL cluster names for the common clusters; 0xFC00–0xFFFE is manufacturer-specific, 0xE000–0xEFFF is reserved space vendors (Tuya) use off-spec.hub_radiolog --summary → transmit_report) gives the noise floor and SNR at both ends. An elevated or spiky hub noise floor with hub_snr well below dest_snr means the hub's own receiver is the bottleneck, from its RF environment rather than the device or distance. Common culprits are co-located 900 MHz radios, USB3, and gear clusters. The fix gets the hub's receiver out of that noise by relocating the hub, fitting an external antenna, or separating co-located hubs. Never the device.transmit_report may be absent (observed 2.5.1.140: the transmit lines carried ACK RSSI only, no noise floor). Confirm transmit_report is present before relying on it for the hub-receiver split. The present transmit fields (transmit status, routing attempts, route speed, took) still separate a real drop from a slow S2/FLiRS exchange.transmit_report. A zwaveJS hub polls GetBackgroundRSSI unprompted (~30 s) and the raw serial response is already in zwaveLogsocket. hub_radiolog --summary decodes it into background_rssi: per-channel min/mean/max dBm, plus polls and samples. Read it against the receiver sensitivity above. A floor near sensitivity means the hub is noise-limited. No device-side work moves it.samples well below polls means the radio was busy, never that the hub lacks the measurement.zwaveRepair2 are mutually exclusive. Re-measure once neighbour-update traffic stops.ch1 and ch2 as one channel, not two.skills/_reference/zwave-lifecycle.md).RemoveFailedNode) applies only to a FAILED orphan ghost, never a recoverable real device — groundable via POST /hub/zwave/nodeRemove. A full Z-Wave network rebuild is groundable too, via GET /hub/zwaveRepair2 — both in skills/_reference/endpoints.md./hub/zwaveRepair2 is a trigger, never a status check. Called on an idle hub it starts a rebuild, and its {"success":false,"message":"Inclusion or rebuild is already running"} is a side effect of one already running. Poll GET /hub/zwaveRepair2Status or GET /hub/checkZwaveRepairRunning instead (skills/_reference/endpoints.md).skills/_reference/endpoints.md).