CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/hubitat-dev

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

Quality

93%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

hub_fw_update.pyskills/_scripts/

#!/usr/bin/env python3
"""Batch-flash Z-Wave device firmware via Hubitat's native zwaveJS updater — safely.

The hub's built-in "Device firmware updater" (Settings -> Z-Wave Details -> Maintenance)
drives OTA over the same radio the devices already use — no driver swap, no self-hosting,
and it handles LR + S2 that the community driver-swap updaters stall on. Its HTTP surface
(verified live 2026-07-22 on 2.5.1.132, C-8 Pro, zwaveJS backend, Hub Security off — see
../_reference/endpoints.md):

    POST /hub/fileManager/upload/firmware              multipart .gbl/.otz/.ota (persists on hub, reusable per model)
    GET  /hub/zwave/deviceFirmware/files               uploaded firmware files
    GET  /hub/zwave/deviceFirmware/details?nodeId=N     {targets:[{target,version,firmwareIdHex}]}  (verify source)
    POST /hub/zwave/deviceFirmware/start               {"nodeId":N,"target":0,"fileName":"X.gbl"}   begins OTA
    GET  /hub/zwave/deviceFirmware/progress?nodeId=N    {progress:{percent,stage,status}}  stage PROCESS->SENDING->DONE

Flow per node: verify current version (skip if already at target) -> start -> poll to DONE ->
wait for NVM-commit+reboot -> poll /details until targets[0].version == target (the hub caches
the old version until the post-reboot re-interview). Firmware for a model is reusable across every
node of that model (upload once). Match the file to the device's HARDWARE REVISION (700 vs 800LR
share a model name but need different images) — the wrong image bricks; the hub rejects a mismatch
at /start, but do not rely on that.

THE HAZARD THIS SCRIPT EXISTS FOR (grounded the hard way, 2026-07-22):
A FAILED **or STALLED** OTA can hang the entire zwaveJS controller — not just skip that node.
Symptom: the hub keeps returning success:true to commands but TRANSMITS NOTHING; every Z-Wave
node freezes at once (Zigbee is unaffected — Zigbee sensors keep reporting, which is the tell that
it is the Z-Wave *controller*, not the mesh or the device). The Hub-Mesh peer still shows
active/reachable. Observed: a mid-transfer stall on a -99 dBm LR node hung the main hub's radio and,
downstream, staleness-poisoned every lux/temperature-gated automation until a reboot.

Two independent guards, both required (one is not enough — learned when a canary-only guard missed
a mid-transfer stall):
  1. NO-PROGRESS WATCHDOG. Abort a flash if `progress.percent` stops advancing for --stall-secs at
     ANY percent (not only at 0%). A frozen transfer never emits FAILED/DONE, so a plain
     start->wait-for-DONE loop hangs forever and takes the radio with it.
  2. CANARY radio-health probe, run ONLY after a FAILED flash (a verified success already proved the
     radio transmits, and the flashed device's own re-interview would else busy the radio and
     false-trigger a reboot). Refresh a known-healthy MAINS node and confirm its Z-Wave `lastTime`
     advances; if it does not, the controller is hung -> reboot and re-probe; abort if it stays hung.

Plus an RSSI FLOOR, applied PER HOP COUNT. `lwrRssi` is the last hop INTO the hub, so it is the
node's own radio only on a DIRECT link. On a routed node it measures the final repeater's link and
says nothing about the device — read as the device's signal it runs both ways wrong: a node routed
through a repeater beside the hub reads strong and PASSES a gate that exists to stop a hang-prone
flash, while a node behind a weak repeater is skipped on a number that is not about it. So:
  - DIRECT (hops == 0, every LR node included) with a readable dBm reading: this is the ONLY shape
    with a measured own-link. At/below --rssi-floor dBm (default -95; the Silicon Labs 700-series RX
    floor is -97, 800-series LR -110) is hang-prone and rarely flashes — skipped as `skipped_weak`
    unless --flash-weak.
  - EVERY OTHER SHAPE — routed (hops >= 1), no readable route, or direct with no readable lwrRssi —
    leaves this node's own link UNMEASURED, and the floor cannot clear an unmeasured link for a
    flash. Skipped as `skipped_unknown` unless --flash-unmeasured.
Not worth risking a whole-hub Z-Wave blackout to update one bathroom plug. The two overrides are
independent: --flash-weak lifts the floor on a direct measured link, --flash-unmeasured lifts the
unmeasured-link skip; an attended flash-everything run passes both.

Reboot recovery: GET /hub/advanced/getManagementToken -> GET /management/reboot?token=<token>
(~2-3 min; zwaveJS re-interviews all nodes; verify a fresh systemStart in /hub/eventsJson).

Worklist JSON (--worklist): [{"nodeId":N,"fileName":"X.gbl","target":"2.6","name":"..."}, ...]
Progress goes to stderr; a JSON summary {ok,skipped,skipped_weak,skipped_unknown,failed,rebooted} to stdout.
Idempotent (skips nodes already at target); MAX_CONSEC_FAIL circuit breaker; optional --wait-pid
chains this run after another batch on the same radio finishes.
"""
import sys
import os
import json
import time
import argparse
import urllib.request
import urllib.error

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# E402: import must follow the sys.path insert above so hubclient resolves when run as a script.
from hubclient import HubError, resolve_base_from_args  # noqa: E402
from hub_mesh import parse_rssi, route_hop_count  # noqa: E402

POLL_SECS = 15
STALL_SECS = 240          # abort a flash if percent has not advanced for this long (at ANY %)
XFER_TIMEOUT = 35 * 60    # hard backstop per flash; the no-progress watchdog is the real hang guard,
#                           so this stays generous — a slow-but-still-advancing transfer (marginal
#                           link) must not be killed at an artificial ceiling (a ZEN04/Leviton at
#                           ~-90 dBm hit 93% at the old 20 min ceiling and was failed one step short)
VERIFY_TIMEOUT = 4 * 60   # post-reboot re-interview ceiling
CANARY_WINDOW = 75        # seconds to watch for ANY node's lastTime to advance (radio-alive)
REBOOT_SETTLE = 150       # seconds for zwaveJS to re-init (interview all nodes) after a reboot
RSSI_FLOOR_DEFAULT = -95
MAX_CONSEC_FAIL = 3


def log(msg):
    print(f"[{time.strftime('%H:%M:%S')}] {msg}", file=sys.stderr, flush=True)


def _get(base, path):
    with urllib.request.urlopen(base + path, timeout=20) as r:
        return json.load(r)


def _post(base, path, body):
    req = urllib.request.Request(
        base + path, data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req, timeout=20) as r:
        return json.load(r)


def cur_version(base, node):
    tg = _get(base, f"/hub/zwave/deviceFirmware/details?nodeId={node}").get("targets") or []
    return tg[0].get("version") if tg else None


def node_link(base, node):
    """(rssi, hops) for a zwaveJS node, from one /hub/zwaveDetails/json read.

    rssi is lwrRssi in dBm (negative), or None when absent or on the legacy dB-above-noise
    scale — this gate reads only the zwaveJS absolute-dBm scale, and a positive number is
    the other one. hops is how many REPEATERS the hub's route passes through: 0 direct,
    N routed, None no readable route (hub_mesh.route_hop_count owns that definition).
    A node absent from the payload answers (None, None) — nothing is known about its link.
    """
    for n in _get(base, "/hub/zwaveDetails/json").get("nodes", []):
        if n.get("nodeId") == node:
            rssi = parse_rssi(n.get("lwrRssi"))
            if rssi is not None:
                rssi = int(rssi) if rssi < 0 else None
            return rssi, route_hop_count(n.get("route"), node)
    return None, None


def rssi_gate(rssi, hops, floor, flash_weak, flash_unmeasured):
    """Decide whether a node's link permits a flash. Pure — no I/O, no clock.

    Only ONE shape of node has a measured own-link: a DIRECT one (hops == 0) carrying a
    readable absolute-dBm lwrRssi. That node is gated on the floor. Every other shape —
    routed, no readable route, or direct with no readable reading — leaves this node's own
    link UNMEASURED, and the floor cannot clear an unmeasured link for a flash. See the
    RSSI FLOOR note in the module docstring.

    Returns (verdict, reason). verdict is 'flash' | 'skipped_weak' | 'skipped_unknown';
    reason is the operator-facing explanation, empty when the verdict is 'flash'.
    """
    if hops == 0 and rssi is not None:
        if rssi <= floor and not flash_weak:
            return "skipped_weak", (f"rssi {rssi}dBm <= floor {floor} on a direct link — "
                                    "hang-prone; use --flash-weak to force")
        return "flash", ""
    if flash_unmeasured:
        return "flash", ""
    if hops == 0:
        why = "direct link but no readable lwrRssi"
    elif hops is None:
        why = "no readable route, so direct-vs-routed is itself unknown"
    else:
        reading = f"rssi {rssi}dBm is" if rssi is not None else "the only reading is"
        why = f"{reading} the last hop into the hub via {hops} repeater(s), not this node"
    return "skipped_unknown", (f"{why} — this node's own link is UNMEASURED and the RSSI floor "
                               "cannot clear it; use --flash-unmeasured to force")


def link_note(rssi, hops):
    """Trailing ' rssi -61dBm direct' / ' link via 2 repeater(s)' for a START line. '' if nothing known."""
    where = "direct" if hops == 0 else ("route unknown" if hops is None else f"via {hops} repeater(s)")
    if rssi is not None:
        return f" rssi {rssi}dBm {where}"
    return "" if hops is None else f" link {where}"


def _all_lasttimes(base):
    """{nodeId: lastTime} for every node. {} if the hub is momentarily unreachable (mid-reboot)."""
    try:
        return {n.get("nodeId"): n.get("lastTime") for n in _get(base, "/hub/zwaveDetails/json").get("nodes", [])}
    except Exception:
        return {}


def canary_transmits(base, canary_dev, canary_node):
    """Radio-alive check, robust against a single quiet/slow node and a freshly-rebooted hub.

    A HUNG controller freezes EVERY node's lastTime (it transmits nothing); a healthy hub always
    has some node reporting, and a rebooting hub advances lastTimes as it re-interviews. So nudge
    the canary node, then over a wide window return True if ANY node's lastTime advances — do not
    hang the verdict on one node answering a refresh inside a tight window (that false-positived
    both ways on a 75-LR-node hub: needless reboot, then a false "still hung")."""
    if not canary_dev:
        return True
    before = _all_lasttimes(base)
    try:
        _post(base, "/device/runmethod", {"id": int(canary_dev), "method": "refresh"})
    except Exception as e:
        log(f"  canary refresh error: {e}")
    deadline = time.time() + CANARY_WINDOW
    while time.time() < deadline:
        time.sleep(6)
        after = _all_lasttimes(base)
        if any(after.get(k) not in (None, v) for k, v in before.items()):
            return True
        if before and not after:  # hub unreachable right now (still rebooting) — keep waiting
            continue
    return False


def reboot_hub(base):
    log(f"!!! Z-Wave controller appears HUNG on {base} — rebooting to recover")
    try:
        # NB: /hub/advanced/getManagementToken returns a BARE token string, not JSON — read as text.
        with urllib.request.urlopen(base + "/hub/advanced/getManagementToken", timeout=15) as r:
            tok = r.read().decode("utf-8", "replace").strip().strip('"')
        urllib.request.urlopen(f"{base}/management/reboot?token={tok}", timeout=15).read()
    except Exception as e:
        log(f"  reboot request error: {e}")
    time.sleep(30)
    for _ in range(40):  # wait for the web server to come back
        try:
            urllib.request.urlopen(f"{base}/hub/details/json", timeout=4).read()
            break
        except Exception:
            time.sleep(5)
    time.sleep(REBOOT_SETTLE)  # let zwaveJS finish interviewing
    log("  hub back up; re-checking radio")


def ensure_radio_ok(base, canary_dev, canary_node, allow_reboot, results=None):
    """Called ONLY after a failed flash: if the radio is hung, reboot once and re-check.
    True if healthy. (A verified success already proves the radio transmits, and the flashed
    device's own re-interview would else busy the radio and false-trigger a reboot.)"""
    if not canary_dev:
        return True
    if canary_transmits(base, canary_dev, canary_node):
        return True
    if not allow_reboot:
        log("  radio hung and --no-reboot set — aborting")
        return False
    reboot_hub(base)
    if results is not None:
        results["rebooted"] += 1
    if canary_transmits(base, canary_dev, canary_node):
        log("  radio recovered after reboot")
        return True
    log("  radio STILL hung after reboot — aborting")
    return False


def flash(base, node, name, fname, target, rssi_floor, flash_weak, flash_unmeasured):
    v = cur_version(base, node)
    if v == target:
        log(f"SKIP node {node} ({name}) already {target}")
        return "skipped"
    rssi, hops = node_link(base, node)
    verdict, reason = rssi_gate(rssi, hops, rssi_floor, flash_weak, flash_unmeasured)
    if verdict != "flash":
        label = "SKIP-WEAK" if verdict == "skipped_weak" else "SKIP-UNKNOWN"
        log(f"{label} node {node} ({name}) {reason}")
        return verdict
    log(f"START node {node} ({name}) {v} -> {target} [{fname}]" + link_note(rssi, hops))
    resp = _post(base, "/hub/zwave/deviceFirmware/start",
                 {"nodeId": node, "target": 0, "fileName": fname})
    if not resp.get("success", False):
        log(f"ERROR node {node}: start rejected: {resp}")
        return "failed"
    t0 = time.time()
    last_pct = None
    last_change = time.time()
    while True:
        now = time.time()
        if now - t0 > XFER_TIMEOUT:
            log(f"ERROR node {node}: transfer exceeded {XFER_TIMEOUT}s ceiling")
            return "failed"
        # NO-PROGRESS WATCHDOG: a frozen transfer at ANY % hangs the radio and never emits DONE/FAILED.
        if now - last_change > STALL_SECS:
            log(f"ERROR node {node}: no progress for {STALL_SECS}s (stuck at {last_pct}%) — aborting (radio-hang risk)")
            return "failed"
        try:
            p = _get(base, f"/hub/zwave/deviceFirmware/progress?nodeId={node}").get("progress", {})
        except urllib.error.URLError as e:
            log(f"  node {node}: progress read hiccup ({e})")
            time.sleep(POLL_SECS)
            continue
        stage, pct = p.get("stage"), p.get("percent")
        if pct != last_pct:
            log(f"  node {node}: {pct}% {stage}")
            last_pct = pct
            last_change = now
        if stage == "DONE":
            break
        if stage in ("ERROR", "FAILED", "ABORTED"):
            log(f"ERROR node {node}: stage={stage} status={p.get('status')}")
            return "failed"
        time.sleep(POLL_SECS)
    log(f"  node {node}: DONE, awaiting reboot/re-interview")
    t1 = time.time()
    while time.time() - t1 < VERIFY_TIMEOUT:
        time.sleep(20)
        try:
            v = cur_version(base, node)
        except urllib.error.URLError:
            continue
        if v == target:
            log(f"OK node {node} ({name}) verified {target}")
            return "ok"
    log(f"ERROR node {node} ({name}): not verified, still {v}")
    return "failed"


def pid_alive(pid):
    try:
        os.kill(pid, 0)
        return True
    except (OSError, ProcessLookupError):
        return False


def run(base, work, canary_dev, canary_node, rssi_floor, flash_weak, flash_unmeasured, allow_reboot, wait_pid):
    if wait_pid:
        log(f"waiting for pid {wait_pid} (prior batch on same radio) to finish…")
        while pid_alive(wait_pid):
            time.sleep(30)
    log(f"=== fw batch: {len(work)} nodes on {base} "
        f"(canary {canary_dev}:{canary_node}, rssi_floor {rssi_floor}) ===")
    results = {"ok": [], "skipped": [], "skipped_weak": [], "skipped_unknown": [], "failed": [], "rebooted": 0}
    consec = 0
    for w in work:
        node, name = w["nodeId"], w.get("name", "?")
        try:
            r = flash(base, node, name, w["fileName"], w["target"], rssi_floor, flash_weak, flash_unmeasured)
        except Exception as e:
            log(f"ERROR node {node} ({name}): unhandled {type(e).__name__}: {e}")
            r = "failed"
        results[r].append(f"{node} {name}")
        consec = consec + 1 if r == "failed" else 0
        if consec >= MAX_CONSEC_FAIL:
            log(f"ABORT: {consec} consecutive failures — stopping to avoid cascading damage")
            break
        # Only a FAILED/stalled flash can have hung the controller; a verified success just
        # proved the radio transmits (and the flashed device's re-interview would false-trigger
        # the canary). Check — and possibly reboot-recover — only after a failure.
        if r == "failed" and not ensure_radio_ok(base, canary_dev, canary_node, allow_reboot, results):
            log("ABORT: Z-Wave controller hung and did not recover")
            break
        time.sleep(5)
    return results


def main(argv=None):
    p = argparse.ArgumentParser(description="Safely batch-flash Z-Wave firmware via the native zwaveJS updater.")
    p.add_argument("--worklist", required=True,
                   help='JSON: [{"nodeId":N,"fileName":"X.gbl","target":"2.6","name":"…"}, …]')
    p.add_argument("--ip")
    p.add_argument("--port", type=int, default=8080)
    p.add_argument("--hub", help="named hub from hubs.json (when no --ip)")
    p.add_argument("--hubs", help="path to hubs.json (default ./hubs.json when --hub is given)")
    p.add_argument("--canary", help="devId:nodeId of a known-healthy MAINS node for the radio-health check")
    p.add_argument("--rssi-floor", type=int, default=RSSI_FLOOR_DEFAULT,
                   help=f"skip a DIRECT, MEASURED node with lwrRssi <= this dBm (default {RSSI_FLOOR_DEFAULT}); hang-prone. This floor does not apply to routed or unmeasured links, which skip by default unless --flash-unmeasured")
    p.add_argument("--flash-weak", action="store_true",
                   help="flash a DIRECT, MEASURED node below the RSSI floor (attended only); does not lift --flash-unmeasured")
    p.add_argument("--flash-unmeasured", action="store_true",
                   help="flash a node whose OWN link is unmeasured — routed, no readable route, or no "
                        "readable lwrRssi (attended only)")
    p.add_argument("--no-reboot", action="store_true", help="do not auto-reboot on a hung radio (abort instead)")
    p.add_argument("--wait-pid", type=int, help="block until this pid exits (chain after another batch)")
    args = p.parse_args(argv)

    try:
        base = resolve_base_from_args(ip=args.ip, port=args.port, hub=args.hub, hubs_path=args.hubs)
    except HubError as e:
        print(str(e), file=sys.stderr)
        return 2
    try:
        work = json.load(open(args.worklist))
    except (OSError, ValueError) as e:
        print(f"cannot read worklist: {e}", file=sys.stderr)
        return 2
    canary_dev, canary_node = (args.canary.split(":") if args.canary else (None, None))

    results = run(base, work, canary_dev, canary_node, args.rssi_floor,
                  args.flash_weak, args.flash_unmeasured, not args.no_reboot, args.wait_pid)

    log("=== SUMMARY ===")
    for k in ("ok", "skipped", "skipped_weak", "skipped_unknown", "failed"):
        log(f"{k}: {len(results[k])}")
        for x in results[k]:
            log(f"   {x}")
    print(json.dumps(results, indent=1))
    return 1 if results["failed"] else 0


if __name__ == "__main__":
    sys.exit(main())

README.md

tile.json