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

#!/usr/bin/env python3
"""Tail a Hubitat hub's live log or event websocket, filtered, as structured lines.

The hub exposes ws://<ip>/logsocket (logs) and ws://<ip>/eventsocket (device events) —
undocumented, no auth on a local hub with Hub Security off, server frames are unmasked
text (see ../_reference/endpoints.md, grounded on 2.5.1.125). No external library: a minimal
stdlib websocket client does the handshake and frame parsing.

The deterministic pieces — handshake bytes, frame parsing, the filter predicate, line
formatting — are pure functions and unit-tested; only the socket loop touches the network.

Usage:
    hub_logtail.py --ip 192.0.2.10 [--socket logsocket|eventsocket]
                   [--levels error,warn,info] [--type dev|app] [--name SUBSTR] [--id N]
                   [--seconds 30] [--max-frames N] [--follow] [--json]
Default: 30 seconds of the log socket, formatted lines. --follow runs until interrupted.
"""
import argparse
import base64
import json
import os
import socket
import sys
import time
from typing import Optional

_LEVEL_ORDER = ["error", "warn", "info", "debug", "trace"]


def build_handshake(host: str, path: str, key_bytes: Optional[bytes] = None):
    """Return (request_bytes, sec_websocket_key). key_bytes lets tests pin the key."""
    key = base64.b64encode(key_bytes if key_bytes is not None else os.urandom(16)).decode()
    req = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}\r\n"
        "Upgrade: websocket\r\n"
        "Connection: Upgrade\r\n"
        f"Sec-WebSocket-Key: {key}\r\n"
        "Sec-WebSocket-Version: 13\r\n\r\n"
    )
    return req.encode(), key


def iter_frames(buf: bytearray):
    """Pop complete websocket frames from buf (mutated in place). Yields (opcode, payload_bytes)
    for each complete frame; leaves any partial trailing frame in buf. Handles server→client
    (unmasked) and, defensively, masked frames."""
    while True:
        if len(buf) < 2:
            return
        b0, b1 = buf[0], buf[1]
        opcode = b0 & 0x0F
        masked = b1 & 0x80
        length = b1 & 0x7F
        offset = 2
        if length == 126:
            if len(buf) < 4:
                return
            length = int.from_bytes(buf[2:4], "big")
            offset = 4
        elif length == 127:
            if len(buf) < 10:
                return
            length = int.from_bytes(buf[2:10], "big")
            offset = 10
        mask = b""
        if masked:
            if len(buf) < offset + 4:
                return
            mask = buf[offset:offset + 4]
            offset += 4
        if len(buf) < offset + length:
            return
        payload = bytes(buf[offset:offset + length])
        if masked:
            payload = bytes(p ^ mask[i % 4] for i, p in enumerate(payload))
        del buf[:offset + length]
        yield opcode, payload


def matches(frame: dict, levels=None, type_=None, name_substr=None, dev_id=None) -> bool:
    """Pure filter predicate over a decoded frame dict."""
    if levels and frame.get("level") not in levels:
        return False
    if type_ and frame.get("type") != type_:
        return False
    if name_substr and name_substr.lower() not in (frame.get("name") or "").lower():
        return False
    if dev_id is not None and str(frame.get("id")) != str(dev_id):
        return False
    return True


def format_frame(frame: dict, as_json: bool = False) -> str:
    if as_json:
        return json.dumps(frame, sort_keys=True)
    t = frame.get("time", "")
    level = (frame.get("level") or "").upper()
    name = frame.get("name", "")
    msg = frame.get("msg", frame.get("descriptionText", ""))
    return f"{t} [{level:<5}] {name}: {msg}"


def expand_levels(levels_arg, min_level):
    if levels_arg:
        return set(x.strip() for x in levels_arg.split(",") if x.strip())
    if min_level:
        i = _LEVEL_ORDER.index(min_level)
        return set(_LEVEL_ORDER[:i + 1])
    return None


def _stream(sock, buf, filters, as_json, deadline, max_frames, follow, out):
    count = 0
    while True:
        if not follow and deadline is not None and time.monotonic() >= deadline:
            return count
        try:
            chunk = sock.recv(4096)
        except socket.timeout:
            continue
        if not chunk:
            return count
        buf.extend(chunk)
        for opcode, payload in iter_frames(buf):
            if opcode == 0x8:  # close
                return count
            if opcode not in (0x1, 0x2):  # ignore ping/pong/continuation
                continue
            try:
                frame = json.loads(payload.decode("utf-8", "replace"))
            except json.JSONDecodeError:
                continue
            if matches(frame, **filters):
                out.write(format_frame(frame, as_json) + "\n")
                out.flush()
                count += 1
                if max_frames and count >= max_frames:
                    return count


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description="Tail a Hubitat log/event websocket.")
    p.add_argument("--ip", required=True)
    p.add_argument("--socket", default="logsocket", choices=["logsocket", "eventsocket"])
    p.add_argument("--levels", help="comma list e.g. error,warn,info")
    p.add_argument("--min-level", choices=_LEVEL_ORDER, help="include this level and more severe")
    p.add_argument("--type", choices=["dev", "app"])
    p.add_argument("--name", help="substring match on the source name")
    p.add_argument("--id", type=int, help="match this device/app id")
    p.add_argument("--seconds", type=int, default=30)
    p.add_argument("--max-frames", type=int, default=0)
    p.add_argument("--follow", action="store_true", help="run until interrupted")
    p.add_argument("--json", action="store_true")
    args = p.parse_args(argv)

    filters = {"levels": expand_levels(args.levels, args.min_level),
               "type_": args.type, "name_substr": args.name, "dev_id": args.id}
    request, _ = build_handshake(args.ip, f"/{args.socket}")

    try:
        sock = socket.create_connection((args.ip, 80), timeout=10)
    except OSError as e:
        print(f"cannot connect to {args.ip}:80 — {e}", file=sys.stderr)
        return 1
    try:
        sock.sendall(request)
        sock.settimeout(5.0)
        # Read until the header terminator — a TCP read can return the handshake
        # response in several packets, so a single recv() may hold only part of it.
        raw = b""
        while b"\r\n\r\n" not in raw and len(raw) < 8192:
            chunk = sock.recv(4096)
            if not chunk:
                break
            raw += chunk
        sock.settimeout(1.0)
        sep = raw.find(b"\r\n\r\n")
        header = (raw[:sep] if sep >= 0 else raw).decode("latin1", "replace")
        if "101" not in header.split("\r\n", 1)[0]:
            print(f"websocket handshake failed: {header.splitlines()[0] if header else '(no response)'}",
                  file=sys.stderr)
            return 1
        # bytes already read past the header terminator are the start of the frame stream
        buf = bytearray(raw[sep + 4:]) if sep >= 0 else bytearray()
        deadline = None if args.follow else time.monotonic() + args.seconds
        _stream(sock, buf, filters, args.json, deadline, args.max_frames, args.follow, sys.stdout)
    except KeyboardInterrupt:
        return 0
    except OSError as e:
        print(f"log-tail connection to {args.ip} failed: {e}", file=sys.stderr)
        return 1
    finally:
        sock.close()
    return 0


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

README.md

tile.json