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_app_subscriptions.pyskills/_scripts/

#!/usr/bin/env python3
"""Report an installed app's LIVE event subscriptions, and optionally assert that a device is
among them.

Grounded live on 2.5.1.169 (C-8 Pro, Hub Security off — see ../_reference/endpoints.md):

    GET /installedapp/statusJson/<appId>  -> {label, eventSubscriptions:[...], appSettings:[...], ...}

An installed app's configured-looking settings are not what it watches. A built-in app that
enumerates per-device subscriptions freezes them at its last **Done**, so a device added afterwards
is absent from `eventSubscriptions` and its handler never fires — while `appSettings` still reads
like blanket coverage. Measured on Hubitat Safety Monitor: `useAllWater = 'true'` and
`hasWater = "All leak and water detectors"` alongside 13 frozen per-device `water.wet`
subscriptions that did not include a newly added sensor (rules/app-lifecycle.md).

`eventSubscriptions[].typeId` is positive live evidence for a **subscription-driven** consumer. It
is NOT a complete account of what an app does: a command-only consumer legitimately holds no
subscription, and Rule Machine keeps a trigger in `state.trigDevs` while a false Required
Expression suspends the subscription (rules/device-lifecycle.md). Absence is conclusive only for an
app type known to consume by subscription — this script reports, and never infers inertness from a
missing row.

`--expect-device` asserts presence, which is the check to run after a Done: the device must be
there. It deliberately does NOT assert a count delta — one Done can legitimately add several
previously missing subscriptions, and one device can need several attribute subscriptions.

The deterministic pieces (projection, filtering, presence check) are pure functions unit-tested
without a hub; only the fetch touches the network via an injectable `transport`.

Usage:
    hub_app_subscriptions.py --ip 192.0.2.11 --app 61
    hub_app_subscriptions.py --ip 192.0.2.11 --app 61 --attribute water.wet
    hub_app_subscriptions.py --hub main --app 61 --attribute water.wet --expect-device 953
Output: one JSON object on stdout ({hub, app_id, app_label, attribute, count, subscriptions,
device_ids, by_attribute, scheduled_jobs, expected}). Exit 2 on a config or argument error, 1 on a
hub/fetch error or a missing --expect-device, 0 otherwise.

`scheduled_jobs` is reported alongside because it is the load-bearing check that a config commit
ran: a failed commit leaves the subscriptions and app state reading healthy while no schedule was
registered. This script does not judge it — an app that schedules nothing legitimately has none.
"""
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

STATUS_PATH = "/installedapp/statusJson/"


def subscriptions_of(status: dict) -> list:
    """Pure. The app's `eventSubscriptions[]` rows, each a dict."""
    rows = status.get("eventSubscriptions")
    return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []


def filter_by_attribute(subs: list, attribute: Optional[str]) -> list:
    """Pure. Rows whose `name` matches the attribute, or every row when attribute is None.

    The hub writes `name` as the subscribed event, which may be a bare attribute (`water`) or an
    attribute.value pair (`water.wet`). An exact match on either form is the caller's choice; a
    bare attribute also matches its dotted forms.
    """
    if not attribute:
        return list(subs)
    out = []
    for row in subs:
        name = str(row.get("name") or "")
        if name == attribute or name.startswith(attribute + "."):
            out.append(row)
    return out


def normalize_device_id(raw) -> Optional[int]:
    """Pure. Normalize a `typeId` to an int, or None when it is not a device id.

    The hub renders `typeId` as a bare integer and as its decimal string, and Rule Machine writes
    state keys of the form `1612:Motion`. Comparing the raw forms makes a valid `"1611"` read as
    absent, which would have this script emit a confident and wrong "re-Done the app" instruction.
    Same normalization as `hub_device_usage.py._device_id`.
    """
    if raw is None:
        return None
    token = str(raw).strip().split(":", 1)[0]
    try:
        return int(token)
    except ValueError:
        return None


def device_ids(subs: list) -> list:
    """Pure. The sorted distinct normalized device ids of DEVICE-type rows.

    Only `type == "DEVICE"` rows carry a device id; a LOCATION row's `typeId` is not a device.
    """
    ids = {normalize_device_id(r.get("typeId")) for r in subs
           if str(r.get("type") or "").upper() == "DEVICE"}
    return sorted(i for i in ids if i is not None)


def scheduled_jobs(status: dict) -> list:
    """Pure. The app's `scheduledJobs[]` rows, each a dict. `[]` when the app schedules nothing.

    This is the load-bearing check that a config commit actually ran. A `_action_update` sent with
    the wrong content type returns 200 and executes nothing, leaving a HALF-LIVE app: the
    pre-existing subscription survives and an event handler keeps stamping app state, so both of
    those surfaces read healthy, while `initialize()` never ran and no schedule was registered
    (../_reference/endpoints.md).
    """
    rows = status.get("scheduledJobs")
    return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []


def check_expected(subs: list, expected: Optional[int]) -> Optional[dict]:
    """Pure. Whether `expected` appears among the DEVICE rows. None when nothing was asserted.

    Presence is the assertion. A count delta is not: one Done can add several previously missing
    subscriptions, and one device can need several attribute subscriptions.
    """
    if expected is None:
        return None
    present = expected in device_ids(subs)
    return {"device_id": expected, "present": present}


def group_by_attribute(subs: list) -> dict:
    """Pure. `{event name: [device ids]}` for the DEVICE rows, for reading coverage at a glance.

    Drops rows whose `typeId` is not a device id and de-duplicates, matching `device_ids`. Emitting
    a `null` would put a value in the output that no caller can act on.
    """
    out: dict = {}
    for row in subs:
        if str(row.get("type") or "").upper() != "DEVICE":
            continue
        device = normalize_device_id(row.get("typeId"))
        if device is None:
            continue
        out.setdefault(str(row.get("name") or ""), set()).add(device)
    return {k: sorted(v) for k, v in sorted(out.items())}


def fetch_status(base: str, app_id, transport) -> dict:
    """Read the installed app's status. Answers a bare `{}` for an id that does not exist."""
    status, _, body = transport("GET", f"{base}{STATUS_PATH}{app_id}", None)
    if status != 200:
        raise HubError(f"GET {STATUS_PATH}{app_id} returned HTTP {status} — confirm the app id and "
                       f"that Hub Security is off for this client")
    try:
        parsed = json.loads(body)
    except ValueError as e:
        raise HubError(
            f"GET {STATUS_PATH}{app_id} did not return JSON ({e}) — the hub serves an HTML login "
            f"page when Hub Security is on for this client. Confirm the app id, confirm Hub "
            f"Security is off, then re-verify the route against skills/_reference/endpoints.md") from e
    if not isinstance(parsed, dict):
        raise HubError(f"GET {STATUS_PATH}{app_id} returned {type(parsed).__name__}, not an object "
                       f"— re-verify the route against skills/_reference/endpoints.md")
    if not parsed:
        raise HubError(f"app {app_id} returned an empty object — the hub answers `{{}}` for an app "
                       f"id that does not exist. Confirm the id against GET /hub2/appsList, walking "
                       f"`children[]` recursively")
    return parsed


def main(argv=None, transport=None) -> int:
    parser = argparse.ArgumentParser(
        description="Report an installed Hubitat app's live event subscriptions.")
    parser.add_argument("--ip")
    parser.add_argument("--port", type=int, default=8080)
    parser.add_argument("--hub")
    parser.add_argument("--hubs", help="path to hubs.json (default ./hubs.json when --hub is given)")
    parser.add_argument("--app", type=int, required=True)
    parser.add_argument("--attribute", help="filter to one event name, e.g. water.wet")
    parser.add_argument("--expect-device", type=int, dest="expect_device",
                        help="assert this device id is subscribed; exit 1 when it is not")
    args = parser.parse_args(argv)
    transport = transport or _urllib_transport

    try:
        base = resolve_base_from_args(args.ip, args.port, args.hub, args.hubs)
    except (HubError, OSError, ValueError) as e:
        print(str(e), file=sys.stderr)
        return 2

    try:
        status = fetch_status(base, args.app, transport)
        subs = filter_by_attribute(subscriptions_of(status), args.attribute)
        expected = check_expected(subs, args.expect_device)
    except HubError as e:
        print(str(e), file=sys.stderr)
        return 1

    result = {
        "hub": base,
        "app_id": args.app,
        "app_label": status.get("label") or status.get("name"),
        "attribute": args.attribute,
        "count": len(subs),
        "subscriptions": subs,
        "device_ids": device_ids(subs),
        "by_attribute": group_by_attribute(subs),
        "scheduled_jobs": scheduled_jobs(status),
        "expected": expected,
    }
    print(json.dumps(result, indent=2, default=str))
    if expected and not expected["present"]:
        print(f"device {args.expect_device} is NOT subscribed on app {args.app}"
              + (f" for {args.attribute}" if args.attribute else "")
              + " — the app's config was not committed, or it was committed without this device. "
                "Re-open the app's config page and press Done (rules/ui-automation.md)",
              file=sys.stderr)
        return 1
    return 0


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

README.md

tile.json