"""embedda-integration — stdlib-only client for the frozen integration.v1 contract. Contract (single source): contracts/openapi/integration.v1.yaml, pinned in contracts/FROZEN.sha256. Method-for-method mirror of the TypeScript client (sdk/integration/ts/client.mjs); the anti-drift test parses the YAML and fails if this module and the contract diverge. Error policy: any non-2xx response raises EmbeddaApiError carrying status, parsed body, method and path — never swallowed. Timeouts raise EmbeddaTimeoutError after ``timeout_s`` (explicit default below). Zero third-party dependencies: ``urllib.request`` only. Local packaging only — publishing to PyPI is an explicit project STOP. """ from __future__ import annotations import json import socket import urllib.error import urllib.parse import urllib.request DEFAULT_TIMEOUT_S = 10.0 #: Operation table, 1:1 with integration.v1.yaml (method + templated path). #: Exported so tests can assert client<->contract parity mechanically. OPERATIONS = ( ("post_webhook", "post", "/events/gateway/webhook", "event_gateway"), ("gateway_status", "get", "/events/gateway/status", "api"), ("mqtt_publish", "post", "/events/gateway/mqtt/publish", "api"), ("mqtt_status", "get", "/events/gateway/mqtt/status", "api"), ("get_mappings", "get", "/events/gateway/mappings", "api"), ("put_mappings", "put", "/events/gateway/mappings", "admin"), ("test_mapping", "post", "/events/gateway/mappings/test", "admin"), ("list_incidents", "get", "/incidents", "api"), ("ack_incident", "post", "/admin/incidents/{id}/acknowledge", "admin"), ("unack_incident", "post", "/admin/incidents/{id}/unacknowledge", "admin"), ("resolve_incident", "post", "/admin/incidents/{id}/resolve", "admin"), ("status", "get", "/status", "api"), ("capabilities", "get", "/capabilities", "api"), ) class EmbeddaApiError(Exception): """Typed error for any non-2xx response (status, body, method, path).""" def __init__(self, status: int, body: object, method: str, path: str): super().__init__(f"{method} {path} -> HTTP {status}: {_truncate(body)}") self.status = status self.body = body self.method = method self.path = path class EmbeddaTimeoutError(Exception): """Typed error for a request that exceeded ``timeout_s``.""" def __init__(self, method: str, path: str, timeout_s: float): super().__init__(f"{method} {path} timed out after {timeout_s}s") self.method = method self.path = path self.timeout_s = timeout_s def _truncate(body: object) -> str: s = body if isinstance(body, str) else json.dumps(body) return s[:200] + "…" if s and len(s) > 200 else s class EmbeddaClient: """Minimal client for the frozen integration.v1 surface of an Embedda node. Tokens mirror the contract's security schemes: ``api`` (NodeApiBearer read surface), ``admin`` (admin-scoped lifecycle ops; falls back to ``api``), ``event_gateway`` (EventGatewayBearer for webhook ingest; falls back to ``api``). The fallbacks are documented, visible defaults — not magic. """ def __init__( self, base_url: str, api_token: str | None = None, *, admin_token: str | None = None, event_gateway_token: str | None = None, timeout_s: float = DEFAULT_TIMEOUT_S, ): if not base_url: raise ValueError( 'EmbeddaClient: base_url is required (e.g. "http://127.0.0.1:9095")' ) self.base_url = base_url.rstrip("/") self.tokens = { "api": api_token, "admin": admin_token or api_token, "event_gateway": event_gateway_token or api_token, } self.timeout_s = timeout_s # ── event ingest ───────────────────────────────────────────────────── def post_webhook(self, payload: dict) -> object: """POST /events/gateway/webhook — ingest one external (VMS/NVR) event.""" return self._req("post", "/events/gateway/webhook", body=payload, auth="event_gateway") def gateway_status(self) -> object: """GET /events/gateway/status — ingest counters + inbox health.""" return self._req("get", "/events/gateway/status", auth="api") def mqtt_publish(self, payload: dict) -> object: """POST /events/gateway/mqtt/publish — inject one MQTT-style event.""" return self._req("post", "/events/gateway/mqtt/publish", body=payload, auth="api") def mqtt_status(self) -> object: """GET /events/gateway/mqtt/status — honest subscriber state.""" return self._req("get", "/events/gateway/mqtt/status", auth="api") # ── normalization mappings ─────────────────────────────────────────── def get_mappings(self) -> object: """GET /events/gateway/mappings — current vendor→normalized mappings.""" return self._req("get", "/events/gateway/mappings", auth="api") def put_mappings(self, mappings: dict) -> object: """PUT /events/gateway/mappings — replace the mapping set (admin).""" return self._req("put", "/events/gateway/mappings", body=mappings, auth="admin") def test_mapping(self, sample: dict) -> object: """POST /events/gateway/mappings/test — dry-run, creates no incident.""" return self._req("post", "/events/gateway/mappings/test", body=sample, auth="admin") # ── incident lifecycle ─────────────────────────────────────────────── def list_incidents(self, query: dict | None = None) -> object: """GET /incidents — list incidents.""" return self._req("get", "/incidents", query=query, auth="api") def ack_incident(self, incident_id: str) -> object: """POST /admin/incidents/{id}/acknowledge.""" return self._req( "post", "/admin/incidents/{id}/acknowledge", path_id=incident_id, auth="admin" ) def unack_incident(self, incident_id: str) -> object: """POST /admin/incidents/{id}/unacknowledge.""" return self._req( "post", "/admin/incidents/{id}/unacknowledge", path_id=incident_id, auth="admin" ) def resolve_incident(self, incident_id: str) -> object: """POST /admin/incidents/{id}/resolve.""" return self._req( "post", "/admin/incidents/{id}/resolve", path_id=incident_id, auth="admin" ) # ── node surface ───────────────────────────────────────────────────── def status(self) -> object: """GET /status — node identity + detected accelerator.""" return self._req("get", "/status", auth="api") def capabilities(self) -> object: """GET /capabilities — the node's honest capability declaration.""" return self._req("get", "/capabilities", auth="api") # ── transport ──────────────────────────────────────────────────────── def _req( self, method: str, template: str, *, body: dict | None = None, query: dict | None = None, path_id: str | None = None, auth: str = "api", ) -> object: path = template if "{id}" in template: if not path_id: raise ValueError(f"{template}: id is required") path = template.replace("{id}", urllib.parse.quote(path_id, safe="")) url = f"{self.base_url}/api/v1{path}" if query: url += "?" + urllib.parse.urlencode(query) headers = {"Accept": "application/json"} token = self.tokens.get(auth) if token: headers["Authorization"] = f"Bearer {token}" data = None if body is not None: data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=headers, method=method.upper()) try: with urllib.request.urlopen(req, timeout=self.timeout_s) as res: return _parse_body(res.read()) except urllib.error.HTTPError as err: raise EmbeddaApiError( err.code, _parse_body(err.read()), method.upper(), path ) from err except TimeoutError as err: # raised by the socket layer on timeout raise EmbeddaTimeoutError(method.upper(), path, self.timeout_s) from err except urllib.error.URLError as err: if isinstance(err.reason, (socket.timeout, TimeoutError)): raise EmbeddaTimeoutError(method.upper(), path, self.timeout_s) from err raise # network error: propagate, never mask def _parse_body(raw: bytes) -> object: text = raw.decode("utf-8", errors="replace") if text == "": return None try: return json.loads(text) except ValueError: return text # non-JSON body: keep raw text (visible, not discarded)