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.

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

High

Do not use without reviewing

Overview
Quality
Evals
Security
Files

hub_device_usage.pyskills/_scripts/

#!/usr/bin/env python3
"""Report a Hubitat device's reference blast radius and, optionally, its live consumers.

The hub computes a device's usage itself and exposes it on one undocumented endpoint
(verified live through 2.5.1.133, C-8 Pro, Hub Security off — see
../_reference/endpoints.md):

    GET /device/fullJson/<deviceId>  -> {device, appsUsing[], appsUsingCount,
                                         dashboards, parentApp, childDevices, hasChildren, ...}

`appsUsing[]` is the hub's own "in use by N apps" list — each entry
`{id, name, label, trueLabel, disabled}`. The `disabled` flag says only whether the app itself is
switched off. It does NOT prove that an enabled app actively consumes the device: Rule Machine can
retain withdrawn `tDev-N` / `trigDevsW` bookkeeping that still appears in `appsUsing`. Every entry
still belongs in the removal blast radius because deletion strands both live and inert references.
`dashboards` (a list), `parentApp` (the app that created the device, or null), and `childDevices` (a
dict of parentId -> [child device objects]) round out the references a delete would strand.

This is the CAPTURE half of safe removal (rules/device-lifecycle.md): it enumerates the references
so an agent can warn before deleting and, on a replacement, re-wire them onto the new device id.
It does NOT delete anything and does NOT judge whether removal is safe — device deletion is a
hub-UI + physical action (rules/zwave-zigbee-mesh.md, ../_reference/playwright-ui.md).

`--live` adds a second, deliberately separate audit over
`GET /installedapp/statusJson/<appId>`:
  - Rule Machine trigger liveness uses `state.trigDevs` / `appState.trigDevs`, which remains
    authoritative while a Required Expression is false. `eventSubscriptions` can disappear in that
    state.
  - Absence from `trigDevs` is negative only when `tDev*` settings or `trigDevsW` show that the
    device held a trigger role. Rule Machine action and condition references otherwise stay unknown.
  - A matching `eventSubscriptions[].typeId` is positive evidence that another app is subscribed.
  - `appSettings[]` identifies which configured setting contains the device even though the legacy
    top-level `settings` field is null.
  - No matching subscription is NOT proof that an arbitrary app is inert; command-only consumers do
    not subscribe. Such entries stay `unknown`, never false-flagged as not live.

The exact Rule Machine predicate and three-state result (`live`, `not_live`, `unknown`) live in
`analyze_liveness()`. The mode flags enabled Rule Machine trigger references that are absent from
`trigDevs`; this catches stale withdrawn trigger bookkeeping without false-flagging a valid trigger
whose Required Expression currently suppresses its subscription or an action-only consumer that
never belongs in the trigger map.

Grounding notes:
  - appsUsingCount is a STRING on the wire ("2"); parsed to int here.
  - statusJson is NOT the blast-radius enumerator. Its top-level `settings` field is null, while
    `appSettings[]` carries resolved `deviceIdsForDeviceList` / `deviceList` plus setting names.
    fullJson's appsUsing remains the complete hub-computed reference list.
  - The deterministic projections (`analyze_usage`, `analyze_liveness`) are pure functions of
    already-parsed JSON and are unit-tested without a hub. Only fetch functions touch the network.

Usage:
    hub_device_usage.py --ip 192.0.2.11 --device 252
    hub_device_usage.py --ip 192.0.2.11 --device 252 --live
    hub_device_usage.py --ip 192.0.2.11 --name "Alice Office Closet Motion Sensor"
    hub_device_usage.py --hub devices --device 252   # resolve via ./hubs.json (hub-config skill)
Exactly one of --device / --name is required; --name resolves the id from /hub2/devicesList (exact
name match, fails clearly on zero or multiple matches). Output: a single JSON object on stdout (see
analyze_usage()); `--live` adds `live_audit` (see analyze_liveness()). Non-zero exit on a fetch
failure.
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Optional

sys.path.insert(0, str(Path(__file__).resolve().parent))
# E402: this import must follow the sys.path insert above so hubclient resolves when run as a script.
from hubclient import HubError, _urllib_transport, resolve_base_from_args  # noqa: E402

DEVICE_PATH = "/device/fullJson/"
DEVICES_LIST_PATH = "/hub2/devicesList"
STATUS_PATH = "/installedapp/statusJson/"

LIVE = "live"
NOT_LIVE = "not_live"
UNKNOWN = "unknown"


def parse_count(raw) -> Optional[int]:
    """appsUsingCount arrives as a string ('2'); '', None -> None."""
    if raw is None:
        return None
    s = str(raw).strip()
    if not s:
        return None
    try:
        return int(s)
    except ValueError:
        return None


def normalize_app(app: dict) -> dict:
    """Project a raw appsUsing entry to the fields the blast-radius report reads.

    `disabled` is app switch state only. It is not a liveness verdict.
    """
    return {
        "id": app.get("id"),
        "name": app.get("name"),
        "label": app.get("label") or app.get("trueLabel") or app.get("name"),
        "disabled": bool(app.get("disabled")),
    }


def normalize_children(child_devices) -> list:
    """childDevices is a dict {parentId: [child objects]}. Flatten to the child devices a delete
    of the parent would take down. A non-dict (or empty) yields []."""
    out = []
    if isinstance(child_devices, dict):
        for group in child_devices.values():
            for c in group or []:
                if isinstance(c, dict):
                    out.append({
                        "id": c.get("id"),
                        "displayName": c.get("displayName") or c.get("name"),
                        "disabled": bool(c.get("disabled")),
                    })
    return out


def analyze_usage(full: dict) -> dict:
    """Pure. Project /device/fullJson into the device's reference blast radius.

    Apps remain split by enabled/disabled switch state. Both groups are references a delete can
    strand; neither group is a live-consumer verdict.
    """
    device = full.get("device") or {}
    apps = [normalize_app(a) for a in full.get("appsUsing") or []]
    enabled = [a for a in apps if not a["disabled"]]
    disabled = [a for a in apps if a["disabled"]]
    dashboards = list(full.get("dashboards") or [])
    children = normalize_children(full.get("childDevices"))
    parent = full.get("parentApp")

    return {
        "device_name": device.get("displayName") or device.get("label") or device.get("name"),
        "driver": device.get("name"),
        "apps_using_count": parse_count(full.get("appsUsingCount")),
        "apps": {"enabled": enabled, "disabled": disabled},
        "dashboards": dashboards,
        "parent_app": parent,
        "child_devices": children,
        "blast_radius": {
            # App switch state only. Both groups are part of the reference blast radius.
            "apps_enabled": len(enabled),
            "apps_disabled": len(disabled),
            "dashboards": len(dashboards),
            "child_devices": len(children),
            "has_parent_app": parent is not None,
        },
    }


def _device_id(raw) -> Optional[int]:
    """Normalize an id or an RM state key (`1612:Motion`) to a device id."""
    if raw is None:
        return None
    token = str(raw).strip().split(":", 1)[0]
    try:
        return int(token)
    except ValueError:
        return None


def _setting_device_ids(setting: dict) -> set:
    """Return device ids carried by one statusJson appSettings[] entry."""
    values = setting.get("deviceIdsForDeviceList")
    if isinstance(values, (list, tuple, set)):
        raw_ids = list(values)
    elif values is None:
        raw_ids = []
    else:
        raw_ids = [values]

    device_list = setting.get("deviceList")
    if isinstance(device_list, dict):
        raw_ids.extend(device_list.keys())

    return {device_id for raw in raw_ids if (device_id := _device_id(raw)) is not None}


def _configured_settings(status: dict, device_id: int) -> list:
    """Names of appSettings[] entries whose resolved device list contains device_id."""
    names = []
    for setting in status.get("appSettings") or []:
        if not isinstance(setting, dict) or device_id not in _setting_device_ids(setting):
            continue
        name = setting.get("name")
        names.append(str(name) if name is not None else "<unnamed>")
    return sorted(set(names))


def _state_value(status: dict, name: str):
    """Read a named state value from the statusJson shapes observed across built-in apps.

    Returns (found, value). A present empty dict is distinct from an absent state surface.
    """
    for root_name in ("appState", "state"):
        root = status.get(root_name)
        if isinstance(root, dict) and name in root:
            value = root[name]
            if isinstance(value, dict) and set(value) == {"value"}:
                value = value["value"]
            return True, value
        if isinstance(root, list):
            for entry in root:
                if not isinstance(entry, dict):
                    continue
                if entry.get("name") == name or entry.get("key") == name:
                    return True, entry.get("value")
    return False, None


def _state_device_ids(value) -> set:
    """Return device ids from an RM trigDevs/trigDevsW mapping or JSON string."""
    if isinstance(value, str):
        try:
            value = json.loads(value)
        except json.JSONDecodeError:
            return set()
    if not isinstance(value, dict):
        return set()
    return {device_id for raw in value if (device_id := _device_id(raw)) is not None}


def _matching_subscription_count(status: dict, device_id: int) -> int:
    """Count eventSubscriptions[] rows whose typeId is the device."""
    subscriptions = status.get("eventSubscriptions") or []
    if isinstance(subscriptions, dict):
        subscriptions = subscriptions.values()
    return sum(
        1 for subscription in subscriptions
        if isinstance(subscription, dict) and _device_id(subscription.get("typeId")) == device_id
    )


def _status_for_app(statuses: dict, app_id):
    if app_id in statuses:
        return statuses[app_id]
    text_id = str(app_id)
    if text_id in statuses:
        return statuses[text_id]
    return {}


def analyze_liveness(device_id: int, apps: list, statuses: dict) -> dict:
    """Pure. Classify each appsUsing reference as live, not_live, or unknown.

    Decision contract:
      - disabled app -> not_live (the app switch is off)
      - Rule Machine trigger present in trigDevs -> live
      - matching eventSubscriptions[].typeId -> live
      - Rule Machine reference absent from trigDevs -> not_live only when tDev* or trigDevsW proves
        that the reference held a trigger role
      - every other enabled reference -> unknown

    Only enabled Rule Machine trigger references with authoritative negative evidence are flagged in
    enabled_configured_not_live. A missing subscription or trigger-map entry cannot prove an
    action/condition consumer is inert.
    """
    audited = []
    for app in apps:
        entry = dict(app)
        entry.update({
            "status": UNKNOWN,
            "method": "no_authoritative_liveness_surface",
            "configured_settings": [],
            "matching_subscription_count": 0,
            "trigger_in_trig_devs": None,
            "trigger_in_trig_devs_w": None,
            "rule_machine_trigger_settings": [],
            "configured_not_live": False,
        })

        if app["disabled"]:
            entry.update({
                "status": NOT_LIVE,
                "method": "app_disabled",
                "configured_not_live": True,
            })
            audited.append(entry)
            continue

        status = _status_for_app(statuses, app.get("id"))
        entry["configured_settings"] = _configured_settings(status, device_id)
        entry["matching_subscription_count"] = _matching_subscription_count(status, device_id)
        entry["rule_machine_trigger_settings"] = [
            setting for setting in entry["configured_settings"] if setting.startswith("tDev")
        ]

        trig_found, trig_devs = _state_value(status, "trigDevs")
        withdrawn_found, withdrawn = _state_value(status, "trigDevsW")
        name = " ".join(str(app.get(field) or "") for field in ("name", "label")).lower()
        is_rule_machine = trig_found or withdrawn_found or "rule machine" in name

        if trig_found:
            in_triggers = device_id in _state_device_ids(trig_devs)
            in_withdrawn = (
                device_id in _state_device_ids(withdrawn) if withdrawn_found else False
            )
            entry.update({
                "trigger_in_trig_devs": in_triggers,
                "trigger_in_trig_devs_w": in_withdrawn,
            })

            if in_triggers:
                entry.update({
                    "status": LIVE,
                    "method": "rule_machine_trig_devs",
                })
            elif entry["matching_subscription_count"]:
                entry.update({
                    "status": LIVE,
                    "method": "event_subscription",
                })
            elif in_withdrawn or entry["rule_machine_trigger_settings"]:
                entry.update({
                    "status": NOT_LIVE,
                    "method": "rule_machine_trig_devs",
                    "configured_not_live": True,
                })
            else:
                entry["method"] = "rule_machine_non_trigger_reference"
        elif entry["matching_subscription_count"]:
            entry.update({
                "status": LIVE,
                "method": "event_subscription",
            })
        elif is_rule_machine:
            entry["method"] = "rule_machine_state_unavailable"

        audited.append(entry)

    counts = {state: sum(a["status"] == state for a in audited)
              for state in (LIVE, NOT_LIVE, UNKNOWN)}
    enabled_configured_not_live = [
        a for a in audited if not a["disabled"] and a["configured_not_live"]
    ]
    return {
        "apps": audited,
        "summary": {
            **counts,
            "enabled_configured_not_live": len(enabled_configured_not_live),
        },
        "enabled_configured_not_live": enabled_configured_not_live,
    }


def _walk_devices(entries) -> list:
    """Pure. Flatten a /hub2/devicesList `devices` forest to every entry, at any depth.

    The body is a TREE, not a flat list: a child device appears ONLY nested in its parent's
    `children[]`, never at the top level (`../_reference/parent-child-devices.md`, verified on
    2.5.1.128 — 151 top-level entries, 5 children reachable only by recursing). Iterating
    `devices[]` alone silently misses every child device.
    """
    out = []
    for entry in entries or []:
        if not isinstance(entry, dict):
            continue
        out.append(entry)
        out.extend(_walk_devices(entry.get("children")))
    return out


def resolve_device_id(devices_list: dict, name: str) -> int:
    """Pure. Match a device by display name against a /hub2/devicesList body (entries are
    `{data: {id, name, ...}}`; `data.name` is the friendly name). Case-insensitive exact match —
    raises HubError on zero matches, or on more than one (listing the colliding ids) so the caller
    never silently picks the wrong device. Name-to-id resolution is deterministic hub polling, so it
    lives here, not in the skill (script-delegation).

    Searches the whole tree via _walk_devices, so a child device resolves by name like any other.
    """
    target = name.strip().lower()
    matches = []
    for entry in _walk_devices(devices_list.get("devices")):
        data = entry.get("data") or {}
        dev_name = data.get("name")
        if dev_name is not None and str(dev_name).strip().lower() == target and data.get("id") is not None:
            matches.append((int(data["id"]), str(dev_name)))
    if not matches:
        raise HubError(f"no device named {name!r} on this hub — check the exact display name "
                       f"(the hub's Devices page), or pass --device <id>.")
    if len(matches) > 1:
        listed = ", ".join(f"{n!r} (id {i})" for i, n in matches)
        raise HubError(f"{len(matches)} devices match name {name!r}: {listed}. Pass --device <id> "
                       f"to disambiguate.")
    return matches[0][0]


def fetch_devices(base: str, transport=None) -> dict:
    """GET /hub2/devicesList. Raises HubError on non-200 or non-JSON."""
    transport = transport or _urllib_transport
    url = base.rstrip("/") + DEVICES_LIST_PATH
    status, _, text = transport("GET", url, None)
    if status != 200:
        raise HubError(f"{url} returned HTTP {status} — check that Hub Security is off on this hub "
                       f"(an authed hub returns a redirect/401 to the login page).")
    try:
        return json.loads(text)
    except json.JSONDecodeError as e:
        raise HubError(f"{url} did not return JSON (got {text[:80]!r}). Check that Hub Security is "
                       f"off on this hub.") from e


def fetch(base: str, device_id: int, transport=None) -> dict:
    """GET /device/fullJson/<id>. Raises HubError on non-200 or non-JSON."""
    transport = transport or _urllib_transport
    url = base.rstrip("/") + DEVICE_PATH + str(device_id)
    status, _, text = transport("GET", url, None)
    if status != 200:
        raise HubError(f"{url} returned HTTP {status} — check that {device_id} is a valid device id "
                       f"and that Hub Security is off on this hub (an authed hub returns a "
                       f"redirect/401 to the login page).")
    try:
        return json.loads(text)
    except json.JSONDecodeError as e:
        raise HubError(
            f"{url} did not return JSON (got {text[:80]!r}). Check that Hub Security is off on "
            f"this hub and that {device_id} is a device id.") from e


def fetch_app_status(base: str, app_id: int, transport=None) -> dict:
    """GET /installedapp/statusJson/<id>. Raises HubError on non-200 or non-JSON."""
    transport = transport or _urllib_transport
    url = base.rstrip("/") + STATUS_PATH + str(app_id)
    status, _, text = transport("GET", url, None)
    if status != 200:
        raise HubError(
            f"{url} returned HTTP {status} — live-consumer audit is incomplete. Check that app "
            f"{app_id} still exists and that Hub Security is off.")
    try:
        payload = json.loads(text)
    except json.JSONDecodeError as e:
        raise HubError(
            f"{url} did not return JSON (got {text[:80]!r}) — live-consumer audit is incomplete. "
            f"Check that Hub Security is off.") from e
    if not isinstance(payload, dict):
        raise HubError(
            f"{url} returned {type(payload).__name__}, expected a JSON object — live-consumer "
            f"audit is incomplete.")
    return payload


def fetch_app_statuses(base: str, apps: list, transport=None) -> dict:
    """Fetch statusJson for enabled apps. Disabled apps need no live-surface request."""
    statuses = {}
    for app in apps:
        if app["disabled"]:
            continue
        app_id = app.get("id")
        if _device_id(app_id) is None:
            raise HubError(
                f"appsUsing entry {app.get('label')!r} has no numeric app id — live-consumer "
                f"audit cannot inspect it.")
        statuses[app_id] = fetch_app_status(base, int(app_id), transport)
    return statuses


def main(argv=None, transport=None) -> int:
    p = argparse.ArgumentParser(
        description="Report where a Hubitat device is used (blast radius) before removing it.")
    sel = p.add_mutually_exclusive_group(required=True)
    sel.add_argument("--device", type=int, help="device id to inspect")
    sel.add_argument("--name", help="device display name to resolve to an id (exact match)")
    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(
        "--live", action="store_true",
        help="also audit authoritative live-consumer surfaces (three-state: live/not_live/unknown)")
    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:
        device_id = args.device if args.device is not None else resolve_device_id(
            fetch_devices(base, transport), args.name)
        full = fetch(base, device_id, transport)
        apps = [normalize_app(app) for app in full.get("appsUsing") or []]
        statuses = fetch_app_statuses(base, apps, transport) if args.live else {}
    except HubError as e:
        print(str(e), file=sys.stderr)
        return 1

    result = analyze_usage(full)
    if args.live:
        result["live_audit"] = analyze_liveness(device_id, apps, statuses)
    result["hub"] = base
    result["device_id"] = device_id
    print(json.dumps(result, indent=2, default=str))
    return 0


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

README.md

tile.json