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

hubclient.pyskills/_scripts/

#!/usr/bin/env python3
"""Shared Hubitat hub client: config resolution + code enumerate/pull/deploy over the
undocumented editor endpoints (see ../_reference/endpoints.md, grounded on 2.5.1.125).

Not a CLI — imported by hub_pull.py and hub_deploy.py. The HTTP layer is a single
injectable `transport` callable so the deterministic logic (URL building, deploy
create-vs-update decision, version handling, response parsing) is unit-testable
without a live hub. Local network, Hub Security off — no auth. If a hub enables
Hub Security every call needs a session cookie first; this client does not do that.

Config (hubs.json, owned by the hub-config skill) holds only IPs — never secrets:
    {"schema_version": 1, "default": "main",
     "hubs": {"main": {"ip": "192.0.2.10", "port": 8080}, ...}}
Maker API credentials, if used elsewhere, come from the environment, not this file.
"""
import json
import urllib.request
import urllib.error
from typing import Optional
from urllib.parse import urlencode

SCHEMA_VERSION = 1

# kind -> endpoint paths. `enumerate` lists code entries; `code` reads source+version;
# `create` makes a new entry; `update` saves an existing one (needs current version).
# Only app and driver are exposed: their code endpoints were verified live (see
# ../_reference/endpoints.md). Library create/update endpoints are not grounded on real
# hardware, so `library` deploy/pull is deliberately out of the contract until it is.
_PATHS = {
    "app": {"enumerate": "/hub2/userAppTypes", "code": "/app/ajax/code",
            "create": "/app/save", "update": "/app/ajax/update", "editor": "/app/editor/"},
    "driver": {"enumerate": "/hub2/userDeviceTypes", "code": "/driver/ajax/code",
               "create": "/driver/save", "update": "/driver/ajax/update", "editor": "/driver/editor/"},
}

KINDS = tuple(_PATHS)


class DeployConflict(Exception):
    """Raised when the hub rejects an update because the version is stale — a newer edit
    exists on the hub. Re-pull and reconcile; never blindly retry with a bumped number."""


class HubError(Exception):
    """A hub call failed or returned an unusable response."""


def base_url(ip: str, port: int = 8080) -> str:
    return f"http://{ip}:{port}"


def resolve_hub(hubs_config: dict, name: Optional[str] = None) -> dict:
    """Return {name, ip, port, base} for the named hub, or the config default.
    Pure — takes the already-loaded config dict."""
    hubs = hubs_config.get("hubs") or {}
    if not hubs:
        raise HubError("hubs.json has no 'hubs' entries")
    chosen = name or hubs_config.get("default")
    if not chosen:
        if len(hubs) == 1:
            chosen = next(iter(hubs))
        else:
            raise HubError("no hub name given and no 'default' set in hubs.json")
    if chosen not in hubs:
        raise HubError(f"hub '{chosen}' not in hubs.json (have: {', '.join(sorted(hubs))})")
    entry = hubs[chosen]
    port = int(entry.get("port", 8080))
    return {"name": chosen, "ip": entry["ip"], "port": port, "base": base_url(entry["ip"], port)}


def resolve_base_from_args(ip: Optional[str] = None, port: int = 8080,
                           hub: Optional[str] = None, hubs_path: Optional[str] = None) -> str:
    """Resolve a hub base URL from CLI-style inputs: an explicit --ip wins; otherwise read
    hubs.json and pick the named hub (or its default)."""
    if ip:
        return base_url(ip, port)
    if hubs_path is None and hub:
        hubs_path = "hubs.json"  # a named hub with no explicit path resolves against ./hubs.json
    if hubs_path:
        return resolve_hub(load_hubs(hubs_path), hub)["base"]
    raise HubError("provide --ip <addr>, --hub <name> with a hubs.json in the working directory, "
                   "or --hubs <path>")


def load_hubs(path) -> dict:
    try:
        with open(path) as f:
            cfg = json.load(f)
    except FileNotFoundError as e:
        raise HubError(
            f"hub config {path} not found — create it with hubs_config.py "
            f"(init/add), or pass --ip <addr> instead of --hub.") from e
    except (OSError, json.JSONDecodeError) as e:
        raise HubError(f"hub config {path} could not be read as JSON: {e}") from e
    ver = cfg.get("schema_version")
    if ver != SCHEMA_VERSION:
        raise HubError(f"hub config {path} schema_version {ver} != {SCHEMA_VERSION} (this client's version)")
    return cfg


def _urllib_transport(method: str, url: str, body: Optional[str], content_type: Optional[str] = None):
    """Default transport. Returns (status, headers_dict, text). The create POST 302-redirects
    to the new entry's editor URL (/app|driver/editor/<id>); urllib follows it, so the new id
    is read from the final URL, surfaced here as a synthetic 'Location' header.

    `content_type` overrides the request Content-Type for a body; it defaults to
    form-urlencoded (the code endpoints), and callers that POST JSON (e.g. /device/runmethod)
    pass "application/json"."""
    data = body.encode() if body is not None else None
    headers = {"Content-Type": content_type or "application/x-www-form-urlencoded"} if data else {}
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        resp = urllib.request.urlopen(req, timeout=15)
        h = dict(resp.headers)
        h.setdefault("Location", resp.geturl())  # final URL after any redirect
        return resp.status, h, resp.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.code, dict(e.headers or {}), (e.read().decode("utf-8", "replace") if e.fp else "")
    except urllib.error.URLError as e:
        raise HubError(f"cannot reach {url}: {e.reason}") from e


def _id_from_location(headers: dict, kind: str):
    loc = headers.get("Location") or headers.get("location") or ""
    marker = _PATHS[kind]["editor"]
    if marker in loc:
        tail = loc.split(marker, 1)[1].strip("/").split("/")[0].split("?")[0]
        if tail.isdigit():
            return int(tail)
    return None


def decide_deploy_action(existing_id, existing_version):
    """Pure. Given the resolved existing entry (or None), decide create vs update."""
    if existing_id is None:
        return {"action": "create"}
    return {"action": "update", "id": existing_id, "version": existing_version}


class HubClient:
    def __init__(self, base: str, transport=None):
        self.base = base.rstrip("/")
        self._t = transport or _urllib_transport

    def _get(self, path: str):
        return self._t("GET", self.base + path, None)

    def _post_form(self, path: str, fields: dict):
        return self._t("POST", self.base + path, urlencode(fields))

    def _json(self, text: str, path: str):
        """Parse a hub response as JSON, or raise an actionable HubError. A hub with Hub
        Security on (or a changed endpoint) returns an HTML login/error page, not JSON."""
        try:
            return json.loads(text)
        except json.JSONDecodeError as e:
            raise HubError(
                f"{self.base}{path} did not return JSON (got {text[:80]!r}). Check that Hub "
                f"Security is off on this hub and that the endpoint is valid on its firmware.") from e

    def enumerate(self, kind: str) -> list:
        path = _PATHS[kind]["enumerate"]
        status, _, text = self._get(path)
        if status != 200:
            raise HubError(f"enumerate {kind} returned HTTP {status} from {self.base}{path}")
        return self._json(text, path)

    def pull(self, kind: str, code_id: int) -> dict:
        path = f"{_PATHS[kind]['code']}?id={code_id}"
        status, _, text = self._get(path)
        if status != 200:
            raise HubError(f"pull {kind} id={code_id} returned HTTP {status} from {self.base}{path}")
        data = self._json(text, path)
        return {"id": data.get("id", code_id), "name": data.get("name"),
                "version": data.get("version"), "source": data.get("source", "")}

    def find_id(self, kind: str, name: str):
        """Match an existing code entry by its declared name. Returns id or None."""
        for entry in self.enumerate(kind):
            if entry.get("name") == name:
                return entry.get("id")
        return None

    def deploy(self, kind: str, source: str, code_id=None) -> dict:
        """Create a new entry or update an existing one. When code_id is None the current
        version is fetched first (optimistic concurrency). Returns {action, id, version?}."""
        version = None
        if code_id is not None:
            version = self.pull(kind, code_id)["version"]
        plan = decide_deploy_action(code_id, version)

        if plan["action"] == "create":
            status, headers, _ = self._post_form(
                _PATHS[kind]["create"], {"id": "", "version": "", "create": "", "source": source})
            if status not in (200, 302):
                raise HubError(f"create {kind} returned HTTP {status}")
            new_id = _id_from_location(headers, kind)
            if new_id is None:
                raise HubError(
                    f"create {kind} returned HTTP {status} but no new id was found in the "
                    f"redirect (Location: {headers.get('Location')!r}). The create may have "
                    f"failed — re-enumerate the hub before deploying again.")
            return {"action": "create", "id": new_id}

        status, _, text = self._post_form(
            _PATHS[kind]["update"], {"id": plan["id"], "version": plan["version"], "source": source})
        # Confirm via the parsed JSON status field, exactly "success" — a substring match
        # would wrongly accept {"status":"unsuccessful"} or an HTML page mentioning "success".
        # The hub's /ajax/update returns {"status":"success"} on save.
        try:
            payload = json.loads(text)
        except json.JSONDecodeError:
            # Non-JSON response (an HTML page). This is the ONLY case the version heuristic runs
            # in — the legacy tell for a stale-version rejection is the word "version" in the page.
            # A parsed JSON body's echoed "version" field never reaches here, so a compile error
            # (status "error" with the field) can no longer be misread as an optimistic-concurrency
            # conflict. The genuine-conflict JSON shape is unconfirmed, so nothing is invented for it.
            if "version" in text.lower():
                raise DeployConflict(
                    f"hub rejected the update for {kind} id={plan['id']} — the hub has a newer "
                    f"version than {plan['version']}. Re-pull and reconcile before deploying.")
            raise HubError(
                f"update {kind} id={plan['id']} did not confirm success (HTTP {status}): {text[:200]}")
        # Parsed JSON. A save returns {"status":"success"}; a rejection carries the reason in
        # errorMessage. Surface that message verbatim — a Groovy compile error and a stale-version
        # rejection both come back as status "error", and errorMessage is the only thing that tells
        # them apart. A JSON body without an errorMessage (or a non-object JSON value) is reported
        # plainly rather than guessed at — never as a conflict off the echoed "version" field.
        if isinstance(payload, dict):
            if payload.get("status") == "success":
                return {"action": "update", "id": plan["id"], "version": plan["version"]}
            message = payload.get("errorMessage")
            if message:
                raise HubError(f"hub rejected the save for {kind} id={plan['id']}: {message}")
        raise HubError(
            f"update {kind} id={plan['id']} did not confirm success (HTTP {status}): {text[:200]}")

README.md

tile.json