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_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