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.
- piphi_runtime_kit_python/__init__.py +180 -0
- piphi_runtime_kit_python/adapters/__init__.py +13 -0
- piphi_runtime_kit_python/adapters/fastapi.py +73 -0
- piphi_runtime_kit_python/fastapi.py +13 -0
- piphi_runtime_kit_python/runtime/__init__.py +124 -0
- piphi_runtime_kit_python/runtime/auth.py +134 -0
- piphi_runtime_kit_python/runtime/config_sync.py +157 -0
- piphi_runtime_kit_python/runtime/configuration.py +112 -0
- piphi_runtime_kit_python/runtime/context.py +29 -0
- piphi_runtime_kit_python/runtime/discovery.py +71 -0
- piphi_runtime_kit_python/runtime/dispatch.py +197 -0
- piphi_runtime_kit_python/runtime/errors.py +131 -0
- piphi_runtime_kit_python/runtime/events.py +194 -0
- piphi_runtime_kit_python/runtime/health.py +54 -0
- piphi_runtime_kit_python/runtime/lifespan.py +79 -0
- piphi_runtime_kit_python/runtime/mqtt.py +213 -0
- piphi_runtime_kit_python/runtime/registry.py +77 -0
- piphi_runtime_kit_python/runtime/starter.py +96 -0
- piphi_runtime_kit_python/runtime/state.py +22 -0
- piphi_runtime_kit_python/runtime/tasks.py +42 -0
- piphi_runtime_kit_python/runtime/telemetry.py +130 -0
- piphi_runtime_kit_python/schemas.py +145 -0
- piphi_runtime_kit_python-0.3.0.dist-info/METADATA +875 -0
- piphi_runtime_kit_python-0.3.0.dist-info/RECORD +27 -0
- piphi_runtime_kit_python-0.3.0.dist-info/WHEEL +4 -0
- piphi_runtime_kit_python-0.3.0.dist-info/entry_points.txt +4 -0
- piphi_runtime_kit_python-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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]
|