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,96 @@
1
+ from __future__ import annotations
2
+
3
+ """Opinionated beginner-friendly starter helpers for PiPhi runtimes."""
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from .config_sync import ConfigSyncCoordinator
9
+ from .context import RuntimeContext
10
+ from .events import DEFAULT_CORE_BASE_URL as DEFAULT_EVENTS_CORE_BASE_URL, EventClient
11
+ from .health import build_runtime_diagnostics_response, build_runtime_health_response
12
+ from .registry import RuntimeRegistry
13
+ from .telemetry import DEFAULT_CORE_BASE_URL as DEFAULT_TELEMETRY_CORE_BASE_URL, TelemetryClient
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class RuntimeStarter:
18
+ """Small “golden path” object for beginner-friendly runtime setup.
19
+
20
+ This intentionally bundles the runtime context, in-memory registry, config
21
+ sync coordinator, and Core delivery clients into one obvious object so new
22
+ integration authors have fewer moving parts to assemble by hand.
23
+ """
24
+
25
+ integration_id: str
26
+ integration_name: str
27
+ version: str = "0.1.0"
28
+ core_base_url: str = DEFAULT_TELEMETRY_CORE_BASE_URL
29
+ max_recent_events: int = 100
30
+ runtime: RuntimeContext = field(init=False)
31
+ registry: RuntimeRegistry[dict[str, Any], dict[str, Any], dict[str, Any]] = field(init=False)
32
+ telemetry_client: TelemetryClient = field(init=False)
33
+ event_client: EventClient = field(init=False)
34
+ config_sync: ConfigSyncCoordinator = field(init=False)
35
+
36
+ def __post_init__(self) -> None:
37
+ self.runtime = RuntimeContext()
38
+ self.registry = RuntimeRegistry(
39
+ max_recent_events=self.max_recent_events
40
+ )
41
+ self.telemetry_client = TelemetryClient(
42
+ process_state=self.runtime.process_state,
43
+ core_base_url=self.core_base_url or DEFAULT_TELEMETRY_CORE_BASE_URL,
44
+ )
45
+ self.event_client = EventClient(
46
+ process_state=self.runtime.process_state,
47
+ core_base_url=self.core_base_url or DEFAULT_EVENTS_CORE_BASE_URL,
48
+ )
49
+ self.config_sync = ConfigSyncCoordinator(process_state=self.runtime.process_state)
50
+
51
+ @property
52
+ def integration_metadata(self) -> dict[str, Any]:
53
+ """Return the standard integration metadata shape used in responses."""
54
+ return {
55
+ "id": self.integration_id,
56
+ "name": self.integration_name,
57
+ "version": self.version,
58
+ }
59
+
60
+ def health_response(self, *, metadata: dict[str, Any] | None = None):
61
+ """Build a health response using the starter's shared runtime state."""
62
+ return build_runtime_health_response(
63
+ self.runtime,
64
+ integration=self.integration_metadata,
65
+ metadata=metadata or {"active_configs": len(self.registry.ids())},
66
+ )
67
+
68
+ def diagnostics_response(self, *, diagnostics: dict[str, Any] | None = None):
69
+ """Build a diagnostics response using the starter's shared runtime state."""
70
+ return build_runtime_diagnostics_response(
71
+ self.runtime,
72
+ integration=self.integration_metadata,
73
+ diagnostics=diagnostics
74
+ or {
75
+ "active_config_ids": self.registry.ids(),
76
+ "recent_event_count": len(self.registry.recent_events),
77
+ },
78
+ )
79
+
80
+
81
+ def create_runtime_starter(
82
+ *,
83
+ integration_id: str,
84
+ integration_name: str,
85
+ version: str = "0.1.0",
86
+ core_base_url: str = DEFAULT_TELEMETRY_CORE_BASE_URL,
87
+ max_recent_events: int = 100,
88
+ ) -> RuntimeStarter:
89
+ """Return the recommended beginner-friendly runtime starter object."""
90
+ return RuntimeStarter(
91
+ integration_id=integration_id,
92
+ integration_name=integration_name,
93
+ version=version,
94
+ core_base_url=core_base_url,
95
+ max_recent_events=max_recent_events,
96
+ )
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ """Shared mutable runtime state used by the thin PiPhi runtime helpers."""
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class RuntimeProcessState:
14
+ """Process-scoped runtime state.
15
+
16
+ This keeps non-device-specific runtime concerns in one place:
17
+ the active Core HTTP client, the latest config generation, and tracked
18
+ background tasks that should be cleaned up on shutdown.
19
+ """
20
+ current_generation: int | None = None
21
+ core_http_client: httpx.AsyncClient | None = None
22
+ background_tasks: set[asyncio.Task[Any]] = field(default_factory=set)
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ """Helpers for creating and shutting down tracked background tasks."""
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from typing import Any
8
+
9
+ from .state import RuntimeProcessState
10
+
11
+
12
+ def track_background_task(
13
+ task: asyncio.Task[Any],
14
+ *,
15
+ process_state: RuntimeProcessState,
16
+ ) -> asyncio.Task[Any]:
17
+ """Register an already-created task so it can be cleaned up later."""
18
+ process_state.background_tasks.add(task)
19
+ task.add_done_callback(process_state.background_tasks.discard)
20
+ return task
21
+
22
+
23
+ def create_tracked_task(
24
+ coro,
25
+ *,
26
+ process_state: RuntimeProcessState,
27
+ ) -> asyncio.Task[Any]:
28
+ """Create a task and automatically register it with the process state."""
29
+ return track_background_task(
30
+ asyncio.create_task(coro),
31
+ process_state=process_state,
32
+ )
33
+
34
+
35
+ async def shutdown_background_tasks(process_state: RuntimeProcessState) -> None:
36
+ """Cancel and await all tracked background tasks still running."""
37
+ pending_tasks = [task for task in process_state.background_tasks if not task.done()]
38
+ for task in pending_tasks:
39
+ task.cancel()
40
+ for task in pending_tasks:
41
+ with contextlib.suppress(asyncio.CancelledError):
42
+ await task
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ """Helpers for sending PiPhi telemetry back to Core."""
4
+
5
+ import datetime as dt
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from ..schemas import TelemetryPayload
13
+ from .auth import RuntimeAuthContext, build_runtime_auth_headers
14
+ from .errors import classify_core_delivery_error
15
+ from .state import RuntimeProcessState
16
+
17
+
18
+ DEFAULT_CORE_BASE_URL = "http://127.0.0.1:31419"
19
+ DEFAULT_TELEMETRY_PATH = "/api/v2/integrations/telemetry"
20
+ DEFAULT_TIMEOUT_SECONDS = 3.0
21
+
22
+ def build_core_auth_headers(
23
+ *,
24
+ container_id: str,
25
+ internal_token: str | None,
26
+ ) -> dict[str, str]:
27
+ """Build outbound Core auth headers using the standard PiPhi header names."""
28
+ return build_runtime_auth_headers(
29
+ container_id=container_id,
30
+ internal_token=internal_token,
31
+ )
32
+
33
+
34
+ def build_telemetry_payload(
35
+ *,
36
+ device_id: str,
37
+ metrics: dict[str, Any],
38
+ units: dict[str, Any] | None = None,
39
+ timestamp: str | None = None,
40
+ ) -> TelemetryPayload:
41
+ """Build the standard telemetry payload expected by PiPhi Core."""
42
+ return TelemetryPayload(
43
+ device_id=device_id,
44
+ metrics=metrics,
45
+ timestamp=timestamp or dt.datetime.now(dt.timezone.utc).isoformat(),
46
+ units=units or {},
47
+ )
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class TelemetryClient:
52
+ """Thin Core telemetry client for runtime integrations.
53
+
54
+ This helper reuses a shared Core HTTP client when one is available and
55
+ otherwise creates a short-lived client with the configured timeout.
56
+ """
57
+ process_state: RuntimeProcessState
58
+ core_base_url: str = DEFAULT_CORE_BASE_URL
59
+ telemetry_path: str = DEFAULT_TELEMETRY_PATH
60
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
61
+
62
+ @property
63
+ def telemetry_url(self) -> str:
64
+ """Return the fully qualified Core telemetry endpoint URL."""
65
+ return f"{self.core_base_url.rstrip('/')}{self.telemetry_path}"
66
+
67
+ @asynccontextmanager
68
+ async def borrow_http_client(self):
69
+ """Yield a shared Core client when present, otherwise a temporary client."""
70
+ if self.process_state.core_http_client is not None:
71
+ yield self.process_state.core_http_client
72
+ return
73
+
74
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
75
+ yield client
76
+
77
+ async def send_metrics(
78
+ self,
79
+ *,
80
+ device_id: str,
81
+ metrics: dict[str, Any],
82
+ auth_context: RuntimeAuthContext,
83
+ container_id: str | None = None,
84
+ units: dict[str, Any] | None = None,
85
+ timestamp: str | None = None,
86
+ ) -> None:
87
+ """Send a telemetry payload for one device to PiPhi Core.
88
+
89
+ Typical usage:
90
+ await telemetry_client.send_metrics(
91
+ device_id="plug-1",
92
+ metrics={"is_on": True, "current_power_w": 12.4},
93
+ units={"current_power_w": "W"},
94
+ auth_context=runtime_context.auth,
95
+ container_id="runtime-123",
96
+ )
97
+ """
98
+ resolved_container_id, resolved_internal_token = auth_context.resolve(
99
+ container_id=container_id,
100
+ )
101
+ if not resolved_container_id:
102
+ return
103
+
104
+ payload = build_telemetry_payload(
105
+ device_id=device_id,
106
+ metrics=metrics,
107
+ units=units,
108
+ timestamp=timestamp,
109
+ )
110
+ headers = build_core_auth_headers(
111
+ container_id=resolved_container_id,
112
+ internal_token=resolved_internal_token,
113
+ )
114
+
115
+ async with self.borrow_http_client() as client:
116
+ try:
117
+ response = await client.post(
118
+ self.telemetry_url,
119
+ json=payload.model_dump(),
120
+ headers=headers,
121
+ timeout=self.timeout_seconds,
122
+ )
123
+ response.raise_for_status()
124
+ except (httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout) as exc:
125
+ raise classify_core_delivery_error(
126
+ exc=exc,
127
+ operation="telemetry_delivery",
128
+ url=self.telemetry_url,
129
+ timeout_seconds=self.timeout_seconds,
130
+ ) from exc
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ """Framework-agnostic schema models shared by PiPhi runtime integrations."""
4
+
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+
12
+ class RuntimeConfig(BaseModel):
13
+ """Base runtime config model with a required config id and extensible fields."""
14
+ id: str
15
+ model_config = ConfigDict(extra="allow")
16
+
17
+
18
+ class RuntimeConfigSnapshot(BaseModel):
19
+ """Snapshot payload sent from PiPhi Core to an integration runtime."""
20
+ container_id: str
21
+ integration_id: str | None = None
22
+ driver_pid: int | None = None
23
+ reason: str | None = None
24
+ generation: int | None = None
25
+ configs: list[RuntimeConfig] = Field(default_factory=list)
26
+
27
+
28
+ class RuntimeConfigSyncResponse(BaseModel):
29
+ """Standard response body returned by runtime config sync endpoints."""
30
+ status: str
31
+ container_id: str
32
+ reason: str | None = None
33
+ generation: int | None = None
34
+ applied: list[str] = Field(default_factory=list)
35
+ removed: list[str] = Field(default_factory=list)
36
+ active_config_ids: list[str] = Field(default_factory=list)
37
+ metadata: dict[str, Any] = Field(default_factory=dict)
38
+
39
+
40
+ class RuntimeConfigApplyResponse(BaseModel):
41
+ """Standard response body returned after one config is applied."""
42
+ status: str = "configured"
43
+ config_id: str
44
+ container_id: str | None = None
45
+ metadata: dict[str, Any] = Field(default_factory=dict)
46
+
47
+
48
+ class RuntimeConfigRemoveResponse(BaseModel):
49
+ """Standard response body returned after one config is removed."""
50
+ status: str = "deconfigured"
51
+ config_id: str
52
+ removed: bool = True
53
+ metadata: dict[str, Any] = Field(default_factory=dict)
54
+
55
+
56
+ class IntegrationDiscoveryRequest(BaseModel):
57
+ """Standard discovery request sent from PiPhi Core to a runtime."""
58
+ container_id: str | None = None
59
+ driver_pid: int | None = None
60
+ inputs: dict[str, Any] = Field(default_factory=dict)
61
+
62
+
63
+ class IntegrationDiscoveryResponse(BaseModel):
64
+ """Standard discovery response returned by a runtime."""
65
+ devices: list[dict[str, Any]] = Field(default_factory=list)
66
+
67
+
68
+ class IntegrationCommandRequest(BaseModel):
69
+ """Standard command payload forwarded from PiPhi Core to a runtime."""
70
+ command: str
71
+ capability_id: str | None = None
72
+ device_id: str | None = None
73
+ entity_id: str | None = None
74
+ args: dict[str, Any] = Field(default_factory=dict)
75
+
76
+
77
+ class IntegrationEventRequest(BaseModel):
78
+ """Generic event payload emitted by a runtime integration."""
79
+ event_type: str
80
+ source: str | None = None
81
+ payload: dict[str, Any] = Field(default_factory=dict)
82
+
83
+
84
+ class IntegrationEventListResponse(BaseModel):
85
+ """Standard response body for listing previously captured runtime events."""
86
+ events: list[dict[str, Any]] = Field(default_factory=list)
87
+
88
+
89
+ class IntegrationEventIngestResponse(BaseModel):
90
+ """Standard response body returned after a runtime ingests an event."""
91
+ status: str = "accepted"
92
+ event: dict[str, Any] = Field(default_factory=dict)
93
+
94
+
95
+ class EventSeverity(str, Enum):
96
+ """Supported severity values for events sent back to PiPhi Core."""
97
+ info = "info"
98
+ warning = "warning"
99
+ error = "error"
100
+ critical = "critical"
101
+
102
+
103
+ class EventTransport(str, Enum):
104
+ """Supported transport labels for events sent back to PiPhi Core."""
105
+ rest = "rest"
106
+ mqtt = "mqtt"
107
+
108
+
109
+ class CoreEventPayload(BaseModel):
110
+ """Canonical event payload posted from a runtime back to PiPhi Core."""
111
+ event_id: str
112
+ type: str
113
+ ts: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
114
+ integration_id: str | None = None
115
+ config_id: str | None = None
116
+ container_id: str | None = None
117
+ device_id: str | None = None
118
+ severity: EventSeverity = Field(default=EventSeverity.info)
119
+ transport: EventTransport = Field(default=EventTransport.rest)
120
+ topic: str | None = None
121
+ data: dict[str, Any] = Field(default_factory=dict)
122
+ received_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
123
+
124
+
125
+ class RuntimeHealthResponse(BaseModel):
126
+ """Standard health response body returned by runtime integrations."""
127
+ status: str = "ok"
128
+ integration: dict[str, Any] = Field(default_factory=dict)
129
+ runtime: dict[str, Any] = Field(default_factory=dict)
130
+ metadata: dict[str, Any] = Field(default_factory=dict)
131
+
132
+
133
+ class RuntimeDiagnosticsResponse(BaseModel):
134
+ """Standard diagnostics response body returned by runtime integrations."""
135
+ status: str = "ok"
136
+ integration: dict[str, Any] = Field(default_factory=dict)
137
+ diagnostics: dict[str, Any] = Field(default_factory=dict)
138
+
139
+
140
+ class TelemetryPayload(BaseModel):
141
+ """Standard telemetry request body posted back to PiPhi Core."""
142
+ device_id: str
143
+ metrics: dict[str, Any] = Field(default_factory=dict)
144
+ timestamp: str
145
+ units: dict[str, Any] = Field(default_factory=dict)