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

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"}.
        try:
            confirmed = json.loads(text).get("status") == "success"
        except (json.JSONDecodeError, AttributeError):
            confirmed = False
        if confirmed:
            return {"action": "update", "id": plan["id"], "version": plan["version"]}
        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]}")

README.md

tile.json