piphi-runtime-kit-python 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ """Helpers for standardizing PiPhi runtime event payload handling."""
4
+
5
+ import datetime as dt
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+ from typing import Any, Iterable, Mapping
9
+ from uuid import uuid4
10
+
11
+ import httpx
12
+
13
+ from ..schemas import (
14
+ CoreEventPayload,
15
+ EventSeverity,
16
+ EventTransport,
17
+ IntegrationEventIngestResponse,
18
+ IntegrationEventListResponse,
19
+ IntegrationEventRequest,
20
+ )
21
+ from .auth import RuntimeAuthContext, build_runtime_auth_headers
22
+ from .errors import classify_core_delivery_error
23
+ from .state import RuntimeProcessState
24
+
25
+ DEFAULT_CORE_BASE_URL = "http://127.0.0.1:31419"
26
+ DEFAULT_EVENTS_INGEST_PATH = "/api/v2/events/ingest"
27
+ DEFAULT_TIMEOUT_SECONDS = 3.0
28
+
29
+
30
+ def normalize_event_payload(
31
+ payload: IntegrationEventRequest | Mapping[str, Any],
32
+ ) -> IntegrationEventRequest:
33
+ """Return a validated event payload regardless of input representation."""
34
+ if isinstance(payload, IntegrationEventRequest):
35
+ return payload
36
+ return IntegrationEventRequest.model_validate(payload)
37
+
38
+
39
+ def build_event_list_response(
40
+ events: Iterable[Mapping[str, Any] | dict[str, Any]],
41
+ ) -> IntegrationEventListResponse:
42
+ """Build a standard event list response from stored runtime events."""
43
+ return IntegrationEventListResponse(events=[dict(event) for event in events])
44
+
45
+
46
+ def build_event_ingest_response(
47
+ event: Mapping[str, Any] | dict[str, Any],
48
+ *,
49
+ status: str = "accepted",
50
+ ) -> IntegrationEventIngestResponse:
51
+ """Build the standard response returned after an event is ingested."""
52
+ return IntegrationEventIngestResponse(status=status, event=dict(event))
53
+
54
+
55
+ def format_event_log(payload: IntegrationEventRequest | Mapping[str, Any]) -> str:
56
+ """Build a compact, consistent log message for ingested runtime events."""
57
+ event = normalize_event_payload(payload)
58
+ return (
59
+ "event_ingested "
60
+ f"event_type={event.event_type} "
61
+ f"source={event.source or 'unknown'}"
62
+ )
63
+
64
+
65
+ def build_core_event_payload(
66
+ *,
67
+ event_type: str,
68
+ integration_id: str,
69
+ config_id: str,
70
+ container_id: str,
71
+ device_id: str | None = None,
72
+ payload: Mapping[str, Any] | None = None,
73
+ source: str | None = None,
74
+ severity: EventSeverity | str = EventSeverity.info,
75
+ topic: str | None = None,
76
+ transport: EventTransport | str = EventTransport.rest,
77
+ event_id: str | None = None,
78
+ ts: str | dt.datetime | None = None,
79
+ ) -> CoreEventPayload:
80
+ """Build the canonical Core event payload from runtime event details."""
81
+ event_data = dict(payload or {})
82
+ if source:
83
+ event_data.setdefault("source", source)
84
+
85
+ if isinstance(ts, str):
86
+ parsed_ts = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
87
+ else:
88
+ parsed_ts = ts
89
+
90
+ return CoreEventPayload(
91
+ event_id=event_id or uuid4().hex,
92
+ type=event_type,
93
+ ts=parsed_ts or dt.datetime.now(dt.timezone.utc),
94
+ integration_id=integration_id,
95
+ config_id=config_id,
96
+ container_id=container_id,
97
+ device_id=device_id,
98
+ severity=severity,
99
+ transport=transport,
100
+ topic=topic,
101
+ data=event_data,
102
+ )
103
+
104
+
105
+ @dataclass(slots=True)
106
+ class EventClient:
107
+ """Thin Core events client for runtime integrations.
108
+
109
+ This mirrors the telemetry client pattern so integrations can post discrete
110
+ events back to Core using the same shared runtime auth and HTTP client flow.
111
+ """
112
+
113
+ process_state: RuntimeProcessState
114
+ core_base_url: str = DEFAULT_CORE_BASE_URL
115
+ events_ingest_path: str = DEFAULT_EVENTS_INGEST_PATH
116
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
117
+
118
+ @property
119
+ def events_ingest_url(self) -> str:
120
+ """Return the fully qualified Core event ingest endpoint URL."""
121
+ return f"{self.core_base_url.rstrip('/')}{self.events_ingest_path}"
122
+
123
+ @asynccontextmanager
124
+ async def borrow_http_client(self):
125
+ """Yield a shared Core client when present, otherwise a temporary client."""
126
+ if self.process_state.core_http_client is not None:
127
+ yield self.process_state.core_http_client
128
+ return
129
+
130
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
131
+ yield client
132
+
133
+ async def send_event(
134
+ self,
135
+ *,
136
+ event_type: str,
137
+ integration_id: str | None,
138
+ config_id: str | None,
139
+ auth_context: RuntimeAuthContext,
140
+ container_id: str | None = None,
141
+ device_id: str | None = None,
142
+ payload: Mapping[str, Any] | None = None,
143
+ source: str | None = None,
144
+ severity: EventSeverity | str = EventSeverity.info,
145
+ topic: str | None = None,
146
+ event_id: str | None = None,
147
+ ts: str | dt.datetime | None = None,
148
+ ) -> bool:
149
+ """Send one runtime event to PiPhi Core.
150
+
151
+ Returns `False` when the runtime does not yet have enough scope
152
+ information to publish a canonical Core event.
153
+ """
154
+ resolved_container_id, resolved_internal_token = auth_context.resolve(
155
+ container_id=container_id,
156
+ )
157
+ if not resolved_container_id or not integration_id or not config_id:
158
+ return False
159
+
160
+ event = build_core_event_payload(
161
+ event_type=event_type,
162
+ integration_id=integration_id,
163
+ config_id=config_id,
164
+ container_id=resolved_container_id,
165
+ device_id=device_id,
166
+ payload=payload,
167
+ source=source,
168
+ severity=severity,
169
+ topic=topic,
170
+ event_id=event_id,
171
+ ts=ts,
172
+ )
173
+ headers = build_runtime_auth_headers(
174
+ container_id=resolved_container_id,
175
+ internal_token=resolved_internal_token,
176
+ )
177
+
178
+ async with self.borrow_http_client() as client:
179
+ try:
180
+ response = await client.post(
181
+ self.events_ingest_url,
182
+ json=event.model_dump(mode="json"),
183
+ headers=headers,
184
+ timeout=self.timeout_seconds,
185
+ )
186
+ response.raise_for_status()
187
+ return True
188
+ except (httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout) as exc:
189
+ raise classify_core_delivery_error(
190
+ exc=exc,
191
+ operation="event_delivery",
192
+ url=self.events_ingest_url,
193
+ timeout_seconds=self.timeout_seconds,
194
+ ) from exc
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ """Helpers for standard runtime health and diagnostics responses."""
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from ..schemas import RuntimeDiagnosticsResponse, RuntimeHealthResponse
8
+ from .context import RuntimeContext
9
+
10
+
11
+ def _build_runtime_snapshot(runtime_context: RuntimeContext) -> dict[str, Any]:
12
+ process_state = runtime_context.process_state
13
+ return {
14
+ "container_id": runtime_context.auth.container_id or None,
15
+ "core_auth_present": bool(runtime_context.auth.internal_token),
16
+ "core_http_client_bound": process_state.core_http_client is not None,
17
+ "current_generation": process_state.current_generation,
18
+ "pending_task_count": len(
19
+ [task for task in process_state.background_tasks if not task.done()]
20
+ ),
21
+ }
22
+
23
+
24
+ def build_runtime_health_response(
25
+ runtime_context: RuntimeContext,
26
+ *,
27
+ integration: Mapping[str, Any] | None = None,
28
+ metadata: Mapping[str, Any] | None = None,
29
+ status: str = "ok",
30
+ ) -> RuntimeHealthResponse:
31
+ """Build a compact health response for a PiPhi runtime integration."""
32
+ return RuntimeHealthResponse(
33
+ status=status,
34
+ integration=dict(integration or {}),
35
+ runtime=_build_runtime_snapshot(runtime_context),
36
+ metadata=dict(metadata or {}),
37
+ )
38
+
39
+
40
+ def build_runtime_diagnostics_response(
41
+ runtime_context: RuntimeContext,
42
+ *,
43
+ integration: Mapping[str, Any] | None = None,
44
+ diagnostics: Mapping[str, Any] | None = None,
45
+ status: str = "ok",
46
+ ) -> RuntimeDiagnosticsResponse:
47
+ """Build a more detailed diagnostics response for support and debugging."""
48
+ payload = _build_runtime_snapshot(runtime_context)
49
+ payload.update(dict(diagnostics or {}))
50
+ return RuntimeDiagnosticsResponse(
51
+ status=status,
52
+ integration=dict(integration or {}),
53
+ diagnostics=payload,
54
+ )
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ """Helpers for bootstrapping runtime auth, shared clients, and shutdown cleanup."""
4
+
5
+ import os
6
+ from contextlib import asynccontextmanager
7
+ from typing import AsyncIterator, Awaitable, Callable
8
+
9
+ import httpx
10
+
11
+ from .context import RuntimeContext
12
+ from .tasks import shutdown_background_tasks
13
+
14
+
15
+ DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME = "PIPHI_CONTAINER_ID"
16
+ DEFAULT_RUNTIME_INTERNAL_TOKEN_ENV_NAME = "PIPHI_INTEGRATION_INTERNAL_TOKEN"
17
+ DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS = 10.0
18
+
19
+
20
+ def bootstrap_runtime_auth_from_env(
21
+ runtime_context: RuntimeContext,
22
+ *,
23
+ container_env_name: str = DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
24
+ token_env_name: str = DEFAULT_RUNTIME_INTERNAL_TOKEN_ENV_NAME,
25
+ ) -> tuple[str, str]:
26
+ """Load runtime auth values from environment variables into the context."""
27
+ container_id = (os.getenv(container_env_name) or "").strip()
28
+ internal_token = (os.getenv(token_env_name) or "").strip()
29
+ runtime_context.auth.update(
30
+ container_id=container_id,
31
+ internal_token=internal_token,
32
+ )
33
+ return container_id, internal_token
34
+
35
+
36
+ @asynccontextmanager
37
+ async def bind_core_http_client(
38
+ runtime_context: RuntimeContext,
39
+ *,
40
+ timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
41
+ ) -> AsyncIterator[httpx.AsyncClient]:
42
+ """Bind a shared Core HTTP client to the runtime context for the duration of the scope."""
43
+ async with httpx.AsyncClient(timeout=timeout_seconds) as client:
44
+ runtime_context.set_core_http_client(client)
45
+ try:
46
+ yield client
47
+ finally:
48
+ runtime_context.set_core_http_client(None)
49
+
50
+
51
+ @asynccontextmanager
52
+ async def runtime_lifespan(
53
+ runtime_context: RuntimeContext,
54
+ *,
55
+ on_startup: Callable[[RuntimeContext, httpx.AsyncClient], Awaitable[None]] | None = None,
56
+ on_shutdown: Callable[[RuntimeContext], Awaitable[None]] | None = None,
57
+ container_env_name: str = DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
58
+ token_env_name: str = DEFAULT_RUNTIME_INTERNAL_TOKEN_ENV_NAME,
59
+ core_client_timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
60
+ ) -> AsyncIterator[RuntimeContext]:
61
+ """Manage a runtime's shared Core client, env auth bootstrap, and task cleanup."""
62
+ bootstrap_runtime_auth_from_env(
63
+ runtime_context,
64
+ container_env_name=container_env_name,
65
+ token_env_name=token_env_name,
66
+ )
67
+
68
+ async with bind_core_http_client(
69
+ runtime_context,
70
+ timeout_seconds=core_client_timeout_seconds,
71
+ ) as client:
72
+ if on_startup is not None:
73
+ await on_startup(runtime_context, client)
74
+ try:
75
+ yield runtime_context
76
+ finally:
77
+ await shutdown_background_tasks(runtime_context.process_state)
78
+ if on_shutdown is not None:
79
+ await on_shutdown(runtime_context)
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ """Optional MQTT helpers for shared source-oriented data streams."""
4
+
5
+ import asyncio
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ import json
10
+ import logging
11
+ from typing import Any, AsyncIterator, Awaitable, Callable, Iterable
12
+
13
+ logger = logging.getLogger("piphi_runtime_kit_python.mqtt")
14
+
15
+ DEFAULT_SOURCE_TOPIC_PREFIX = "piphi/sources"
16
+
17
+
18
+ @dataclass(slots=True, frozen=True)
19
+ class MqttBrokerConfig:
20
+ hostname: str
21
+ port: int = 1883
22
+ username: str | None = None
23
+ password: str | None = None
24
+ client_id: str | None = None
25
+ keepalive: int = 60
26
+ transport: str = "tcp"
27
+ qos: int = 0
28
+
29
+
30
+ def build_source_topic_root(
31
+ source: str,
32
+ *,
33
+ prefix: str = DEFAULT_SOURCE_TOPIC_PREFIX,
34
+ ) -> str:
35
+ return f"{prefix.rstrip('/')}/{_sanitize_topic_segment(source)}"
36
+
37
+
38
+ def build_source_packets_topic(
39
+ source: str,
40
+ *,
41
+ prefix: str = DEFAULT_SOURCE_TOPIC_PREFIX,
42
+ ) -> str:
43
+ return f"{build_source_topic_root(source, prefix=prefix)}/packets"
44
+
45
+
46
+ def build_source_model_packets_topic(
47
+ source: str,
48
+ model: str,
49
+ *,
50
+ prefix: str = DEFAULT_SOURCE_TOPIC_PREFIX,
51
+ ) -> str:
52
+ return (
53
+ f"{build_source_topic_root(source, prefix=prefix)}"
54
+ f"/models/{_sanitize_topic_segment(model)}/packets"
55
+ )
56
+
57
+
58
+ def build_source_status_topic(
59
+ source: str,
60
+ *,
61
+ prefix: str = DEFAULT_SOURCE_TOPIC_PREFIX,
62
+ ) -> str:
63
+ return f"{build_source_topic_root(source, prefix=prefix)}/status"
64
+
65
+
66
+ def build_source_errors_topic(
67
+ source: str,
68
+ *,
69
+ prefix: str = DEFAULT_SOURCE_TOPIC_PREFIX,
70
+ ) -> str:
71
+ return f"{build_source_topic_root(source, prefix=prefix)}/errors"
72
+
73
+
74
+ def build_source_packet_envelope(
75
+ *,
76
+ source: str,
77
+ packet: dict[str, Any],
78
+ received_at: str | None = None,
79
+ metadata: dict[str, Any] | None = None,
80
+ ) -> dict[str, Any]:
81
+ model = _coerce_optional_string(packet.get("model"))
82
+ device_hint = {
83
+ "model": model,
84
+ "id": _coerce_optional_string(_first_present(packet, ("id", "device_id", "device", "sid", "unit"))),
85
+ "channel": _coerce_optional_string(_first_present(packet, ("channel", "subtype"))),
86
+ }
87
+ return {
88
+ "source": source,
89
+ "received_at": received_at or datetime.now(timezone.utc).isoformat(),
90
+ "model": model,
91
+ "device_hint": device_hint,
92
+ "packet": dict(packet),
93
+ "metadata": dict(metadata or {}),
94
+ }
95
+
96
+
97
+ class MqttJsonClient:
98
+ """Thin JSON-oriented MQTT helper around aiomqtt."""
99
+
100
+ def __init__(self, config: MqttBrokerConfig):
101
+ self.config = config
102
+
103
+ @asynccontextmanager
104
+ async def session(self) -> AsyncIterator["_MqttJsonSession"]:
105
+ aiomqtt = _load_aiomqtt()
106
+ async with aiomqtt.Client(
107
+ hostname=self.config.hostname,
108
+ port=self.config.port,
109
+ username=self.config.username,
110
+ password=self.config.password,
111
+ identifier=self.config.client_id,
112
+ keepalive=self.config.keepalive,
113
+ transport=self.config.transport,
114
+ ) as client:
115
+ yield _MqttJsonSession(client=client, config=self.config)
116
+
117
+ async def run_subscription_forever(
118
+ self,
119
+ *,
120
+ topics: Iterable[str],
121
+ handler: Callable[[str, dict[str, Any]], Awaitable[None]],
122
+ retry_delay_seconds: float = 5.0,
123
+ ) -> None:
124
+ while True:
125
+ try:
126
+ async with self.session() as session:
127
+ await session.consume_json(topics=topics, handler=handler)
128
+ except Exception as exc:
129
+ logger.warning(
130
+ "mqtt_subscription_failed error=%s retrying_in=%ss",
131
+ exc,
132
+ retry_delay_seconds,
133
+ )
134
+ await asyncio.sleep(retry_delay_seconds)
135
+
136
+
137
+ class _MqttJsonSession:
138
+ def __init__(self, *, client: Any, config: MqttBrokerConfig):
139
+ self.client = client
140
+ self.config = config
141
+
142
+ async def publish_json(
143
+ self,
144
+ topic: str,
145
+ payload: dict[str, Any],
146
+ *,
147
+ retain: bool = False,
148
+ qos: int | None = None,
149
+ ) -> None:
150
+ await self.client.publish(
151
+ topic,
152
+ payload=json.dumps(payload),
153
+ retain=retain,
154
+ qos=self.config.qos if qos is None else qos,
155
+ )
156
+
157
+ async def consume_json(
158
+ self,
159
+ *,
160
+ topics: Iterable[str],
161
+ handler: Callable[[str, dict[str, Any]], Awaitable[None]],
162
+ ) -> None:
163
+ topic_list = list(topics)
164
+ for topic in topic_list:
165
+ await self.client.subscribe(topic, qos=self.config.qos)
166
+ async for message in self.client.messages:
167
+ payload = _decode_json_payload(message.payload)
168
+ if payload is None:
169
+ logger.debug("mqtt_message_skipped topic=%s", message.topic)
170
+ continue
171
+ await handler(str(message.topic), payload)
172
+
173
+
174
+ def _decode_json_payload(payload: bytes | str | Any) -> dict[str, Any] | None:
175
+ if isinstance(payload, bytes):
176
+ text = payload.decode("utf-8", errors="replace")
177
+ else:
178
+ text = str(payload)
179
+ try:
180
+ parsed = json.loads(text)
181
+ except json.JSONDecodeError:
182
+ return None
183
+ if not isinstance(parsed, dict):
184
+ return None
185
+ return parsed
186
+
187
+
188
+ def _sanitize_topic_segment(value: str) -> str:
189
+ return str(value).strip().replace("/", "_").replace(" ", "_")
190
+
191
+
192
+ def _coerce_optional_string(value: Any) -> str | None:
193
+ if value in (None, ""):
194
+ return None
195
+ return str(value)
196
+
197
+
198
+ def _first_present(payload: dict[str, Any], keys: tuple[str, ...]) -> Any:
199
+ for key in keys:
200
+ value = payload.get(key)
201
+ if value not in (None, ""):
202
+ return value
203
+ return None
204
+
205
+
206
+ def _load_aiomqtt() -> Any:
207
+ try:
208
+ import aiomqtt
209
+ except ImportError as exc:
210
+ raise RuntimeError(
211
+ "aiomqtt is not installed. Install piphi-runtime-kit-python with the 'mqtt' extra."
212
+ ) from exc
213
+ return aiomqtt
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ """Typed in-memory registry helpers for runtime-managed device/config state."""
4
+
5
+ import datetime as dt
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Generic, TypeVar
8
+
9
+ TEntry = TypeVar("TEntry", bound=dict[str, Any])
10
+ TState = TypeVar("TState", bound=dict[str, Any])
11
+ TEvent = TypeVar("TEvent", bound=dict[str, Any])
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class RuntimeRegistry(Generic[TEntry, TState, TEvent]):
16
+ """Small in-memory registry for runtime-managed entries, state, and events.
17
+
18
+ Core remains the source of truth for configs. This registry only tracks the
19
+ runtime working set currently loaded in memory.
20
+ """
21
+
22
+ entries: dict[str, TEntry] = field(default_factory=dict)
23
+ state_snapshots: dict[str, dict[str, Any]] = field(default_factory=dict)
24
+ recent_events: list[TEvent] = field(default_factory=list)
25
+ max_recent_events: int = 100
26
+
27
+ def get(self, entry_id: str) -> TEntry | None:
28
+ """Return one active entry by id when present."""
29
+ return self.entries.get(entry_id)
30
+
31
+ def set(self, entry_id: str, entry: TEntry) -> TEntry:
32
+ """Upsert one active entry and return the stored value."""
33
+ self.entries[entry_id] = entry
34
+ return entry
35
+
36
+ def remove(self, entry_id: str) -> TEntry | None:
37
+ """Remove one active entry and return the removed value when present."""
38
+ removed = self.entries.pop(entry_id, None)
39
+ self.state_snapshots.pop(entry_id, None)
40
+ return removed
41
+
42
+ def ids(self) -> list[str]:
43
+ """Return the active entry ids in insertion order."""
44
+ return list(self.entries.keys())
45
+
46
+ def primary_entry(self) -> TEntry | None:
47
+ """Return the first active entry when one exists."""
48
+ if not self.entries:
49
+ return None
50
+ first_entry_id = next(iter(self.entries))
51
+ return self.entries[first_entry_id]
52
+
53
+ def update_state(self, entry_id: str, state: dict[str, Any]) -> dict[str, Any]:
54
+ """Store the latest state snapshot and mirror it onto the active entry."""
55
+ timestamp = dt.datetime.now(dt.timezone.utc).isoformat()
56
+ latest_state = {
57
+ "device_id": entry_id,
58
+ "state": state,
59
+ "last_updated": timestamp,
60
+ }
61
+ self.state_snapshots[entry_id] = latest_state
62
+ if entry_id in self.entries:
63
+ self.entries[entry_id]["latest_state"] = state
64
+ self.entries[entry_id]["last_updated"] = timestamp
65
+ return latest_state
66
+
67
+ def append_event(self, event: TEvent) -> TEvent:
68
+ """Append one local event record and keep the recent-event window bounded."""
69
+ record = {
70
+ "received_at": dt.datetime.now(dt.timezone.utc).isoformat(),
71
+ **event,
72
+ }
73
+ typed_record = record # satisfy generic return without runtime coercion
74
+ self.recent_events.append(typed_record) # type: ignore[arg-type]
75
+ if len(self.recent_events) > self.max_recent_events:
76
+ del self.recent_events[:-self.max_recent_events]
77
+ return typed_record # type: ignore[return-value]