piphi-runtime-kit-python 0.4.1__tar.gz → 0.4.2__tar.gz

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.
Files changed (56) hide show
  1. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/PKG-INFO +4 -4
  2. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/README.md +3 -3
  3. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/pyproject.toml +1 -1
  4. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/__init__.py +8 -0
  5. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/dispatch.py +50 -17
  6. piphi_runtime_kit_python-0.4.2/src/piphi_runtime_kit_python/runtime/identity.py +80 -0
  7. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/registry.py +17 -5
  8. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/schemas.py +66 -1
  9. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_dispatch_matrix.py +74 -0
  10. piphi_runtime_kit_python-0.4.2/tests/test_runtime_identity.py +29 -0
  11. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_registry.py +10 -0
  12. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_starter.py +5 -0
  13. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_schemas_matrix.py +19 -0
  14. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/LICENSE +0 -0
  15. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/adapters/__init__.py +0 -0
  16. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/adapters/fastapi.py +0 -0
  17. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/fastapi.py +0 -0
  18. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/__init__.py +0 -0
  19. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/auth.py +0 -0
  20. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/config_sync.py +0 -0
  21. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/configuration.py +0 -0
  22. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/context.py +0 -0
  23. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/discovery.py +0 -0
  24. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/errors.py +0 -0
  25. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/events.py +0 -0
  26. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/health.py +0 -0
  27. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/lifespan.py +0 -0
  28. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/mqtt.py +0 -0
  29. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/starter.py +0 -0
  30. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/state.py +0 -0
  31. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/tasks.py +0 -0
  32. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/src/piphi_runtime_kit_python/runtime/telemetry.py +0 -0
  33. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/__init__.py +0 -0
  34. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_config_sync.py +0 -0
  35. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_fastapi_adapter.py +0 -0
  36. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_auth.py +0 -0
  37. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_auth_matrix.py +0 -0
  38. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_config_sync_matrix.py +0 -0
  39. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_configuration.py +0 -0
  40. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_configuration_matrix.py +0 -0
  41. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_delivery_errors.py +0 -0
  42. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_discovery.py +0 -0
  43. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_discovery_matrix.py +0 -0
  44. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_dispatch.py +0 -0
  45. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_errors_matrix.py +0 -0
  46. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_events.py +0 -0
  47. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_events_matrix.py +0 -0
  48. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_health.py +0 -0
  49. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_lifespan.py +0 -0
  50. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_mqtt.py +0 -0
  51. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_mqtt_matrix.py +0 -0
  52. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_registry_matrix.py +0 -0
  53. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_support_matrix.py +0 -0
  54. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_tasks.py +0 -0
  55. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_telemetry.py +0 -0
  56. {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.2}/tests/test_runtime_telemetry_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: piphi-runtime-kit-python
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: PiPhi Network runtime integration helpers
5
5
  Keywords: piphi,runtime,integration,iot
6
6
  Author-Email: KelvinSan <support@piphi.network>
@@ -362,15 +362,15 @@ async def health():
362
362
  async def config(payload: DemoConfig, request: Request):
363
363
  sync_runtime_auth_from_fastapi_payload(starter.runtime, request, payload)
364
364
  typed_payload = validate_typed_config(payload, DemoConfig)
365
+ identity = build_runtime_identity(typed_payload)
365
366
  starter.registry.set(
366
367
  typed_payload.id,
367
368
  {
368
- "config_id": typed_payload.config_id or typed_payload.id,
369
- "device_id": typed_payload.device_id or typed_payload.id,
369
+ **identity,
370
370
  "host": typed_payload.host,
371
371
  },
372
372
  )
373
- return build_config_apply_response(config_id=typed_payload.config_id or typed_payload.id)
373
+ return build_config_apply_response(config_id=identity["config_id"])
374
374
 
375
375
 
376
376
  @app.post("/telemetry/example")
@@ -336,15 +336,15 @@ async def health():
336
336
  async def config(payload: DemoConfig, request: Request):
337
337
  sync_runtime_auth_from_fastapi_payload(starter.runtime, request, payload)
338
338
  typed_payload = validate_typed_config(payload, DemoConfig)
339
+ identity = build_runtime_identity(typed_payload)
339
340
  starter.registry.set(
340
341
  typed_payload.id,
341
342
  {
342
- "config_id": typed_payload.config_id or typed_payload.id,
343
- "device_id": typed_payload.device_id or typed_payload.id,
343
+ **identity,
344
344
  "host": typed_payload.host,
345
345
  },
346
346
  )
347
- return build_config_apply_response(config_id=typed_payload.config_id or typed_payload.id)
347
+ return build_config_apply_response(config_id=identity["config_id"])
348
348
 
349
349
 
350
350
  @app.post("/telemetry/example")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "piphi-runtime-kit-python"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "PiPhi Network runtime integration helpers"
5
5
  authors = [
6
6
  { name = "KelvinSan", email = "support@piphi.network" },
@@ -49,6 +49,7 @@ from .runtime.events import (
49
49
  normalize_event_payload,
50
50
  )
51
51
  from .runtime.health import build_runtime_diagnostics_response, build_runtime_health_response
52
+ from .runtime.identity import build_runtime_identity, resolve_config_id, resolve_device_id
52
53
  from .runtime.lifespan import (
53
54
  DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
54
55
  DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
@@ -80,6 +81,8 @@ from .schemas import (
80
81
  IntegrationCommandRequest,
81
82
  IntegrationDiscoveryRequest,
82
83
  IntegrationDiscoveryResponse,
84
+ RuntimeEntityCommandFieldResponse,
85
+ RuntimeEntityCommandResponse,
83
86
  RuntimeEntityDashboardResponse,
84
87
  RuntimeEntitiesResponse,
85
88
  RuntimeEntityResponse,
@@ -113,6 +116,8 @@ __all__ = [
113
116
  "IntegrationCommandRequest",
114
117
  "IntegrationDiscoveryRequest",
115
118
  "IntegrationDiscoveryResponse",
119
+ "RuntimeEntityCommandFieldResponse",
120
+ "RuntimeEntityCommandResponse",
116
121
  "RuntimeEntityDashboardResponse",
117
122
  "RuntimeEntitiesResponse",
118
123
  "RuntimeEntityResponse",
@@ -144,6 +149,7 @@ __all__ = [
144
149
  "build_event_ingest_response",
145
150
  "build_event_list_response",
146
151
  "build_local_event_record",
152
+ "build_runtime_identity",
147
153
  "build_core_auth_headers",
148
154
  "build_config_apply_response",
149
155
  "build_config_remove_response",
@@ -175,6 +181,8 @@ __all__ = [
175
181
  "schedule_telemetry_delivery",
176
182
  "shutdown_background_tasks",
177
183
  "track_background_task",
184
+ "resolve_config_id",
185
+ "resolve_device_id",
178
186
  "validate_typed_config",
179
187
  "validate_typed_configs",
180
188
  "DEFAULT_SOURCE_TOPIC_PREFIX",
@@ -2,8 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  """Helpers for background dispatch of telemetry and event delivery."""
4
4
 
5
+ import asyncio
5
6
  from typing import Any, Callable, Mapping
6
7
 
8
+ from .errors import CoreDeliveryError
7
9
  from .events import EventClient
8
10
  from .tasks import create_tracked_task
9
11
  from .telemetry import TelemetryClient
@@ -14,6 +16,9 @@ EventRecorder = Callable[[dict[str, Any]], Any]
14
16
  SkipHandler = Callable[[str, dict[str, Any]], Any]
15
17
  ErrorHandler = Callable[[Exception, dict[str, Any]], Any]
16
18
 
19
+ DEFAULT_RETRYABLE_DELIVERY_ATTEMPTS = 5
20
+ DEFAULT_RETRYABLE_DELIVERY_INITIAL_DELAY_SECONDS = 0.5
21
+
17
22
 
18
23
  def build_local_event_record(
19
24
  *,
@@ -36,6 +41,30 @@ def build_local_event_record(
36
41
  }
37
42
 
38
43
 
44
+ async def _run_with_retryable_core_delivery_backoff(
45
+ delivery: Callable[[], Any],
46
+ *,
47
+ max_attempts: int = DEFAULT_RETRYABLE_DELIVERY_ATTEMPTS,
48
+ initial_delay_seconds: float = DEFAULT_RETRYABLE_DELIVERY_INITIAL_DELAY_SECONDS,
49
+ ) -> Any:
50
+ """Retry transient Core delivery failures with a short exponential backoff."""
51
+
52
+ attempt = 1
53
+ delay_seconds = initial_delay_seconds
54
+ while True:
55
+ try:
56
+ result = delivery()
57
+ if asyncio.iscoroutine(result):
58
+ return await result
59
+ return result
60
+ except CoreDeliveryError as exc:
61
+ if not exc.retryable or attempt >= max_attempts:
62
+ raise
63
+ await asyncio.sleep(delay_seconds)
64
+ attempt += 1
65
+ delay_seconds *= 2
66
+
67
+
39
68
  async def dispatch_telemetry_delivery(
40
69
  *,
41
70
  telemetry_client: TelemetryClient,
@@ -59,13 +88,15 @@ async def dispatch_telemetry_delivery(
59
88
  return False
60
89
 
61
90
  try:
62
- await telemetry_client.send_metrics(
63
- device_id=device_id,
64
- metrics=metrics,
65
- auth_context=auth_context,
66
- container_id=resolved_container_id,
67
- units=units,
68
- timestamp=timestamp,
91
+ await _run_with_retryable_core_delivery_backoff(
92
+ lambda: telemetry_client.send_metrics(
93
+ device_id=device_id,
94
+ metrics=metrics,
95
+ auth_context=auth_context,
96
+ container_id=resolved_container_id,
97
+ units=units,
98
+ timestamp=timestamp,
99
+ )
69
100
  )
70
101
  return True
71
102
  except Exception as exc:
@@ -119,16 +150,18 @@ async def dispatch_event_delivery(
119
150
  ) -> bool:
120
151
  """Send one event to Core and return whether delivery succeeded."""
121
152
  try:
122
- event_sent = await event_client.send_event(
123
- event_type=event_type,
124
- integration_id=device.get("integration_id"),
125
- config_id=device.get("config_id") or device.get("device_id"),
126
- auth_context=auth_context,
127
- container_id=device.get("container_id"),
128
- device_id=device.get("device_id"),
129
- payload=payload,
130
- source=source,
131
- severity=severity,
153
+ event_sent = await _run_with_retryable_core_delivery_backoff(
154
+ lambda: event_client.send_event(
155
+ event_type=event_type,
156
+ integration_id=device.get("integration_id"),
157
+ config_id=device.get("config_id"),
158
+ auth_context=auth_context,
159
+ container_id=device.get("container_id"),
160
+ device_id=device.get("device_id"),
161
+ payload=payload,
162
+ source=source,
163
+ severity=severity,
164
+ )
132
165
  )
133
166
  if not event_sent and on_skipped is not None:
134
167
  on_skipped(
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ """Helpers for keeping runtime config and device identity explicit."""
4
+
5
+ from typing import Any, Mapping
6
+
7
+
8
+ def _coerce_optional_string(value: Any) -> str | None:
9
+ if value is None:
10
+ return None
11
+ text = str(value).strip()
12
+ return text or None
13
+
14
+
15
+ def _first_present(payload: Mapping[str, Any], *keys: str) -> str | None:
16
+ for key in keys:
17
+ resolved = _coerce_optional_string(payload.get(key))
18
+ if resolved:
19
+ return resolved
20
+ return None
21
+
22
+
23
+ def resolve_config_id(payload: Mapping[str, Any] | Any) -> str:
24
+ """Resolve the Core-owned config id from a config-like payload."""
25
+
26
+ if isinstance(payload, Mapping):
27
+ return _first_present(payload, "config_id", "configId", "id") or "missing"
28
+ return (
29
+ _coerce_optional_string(getattr(payload, "config_id", None))
30
+ or _coerce_optional_string(getattr(payload, "configId", None))
31
+ or _coerce_optional_string(getattr(payload, "id", None))
32
+ or "missing"
33
+ )
34
+
35
+
36
+ def resolve_device_id(payload: Mapping[str, Any] | Any) -> str:
37
+ """Resolve the integration-owned device id from a config-like payload."""
38
+
39
+ if isinstance(payload, Mapping):
40
+ return _first_present(payload, "device_id", "deviceId", "id") or "missing"
41
+ return (
42
+ _coerce_optional_string(getattr(payload, "device_id", None))
43
+ or _coerce_optional_string(getattr(payload, "deviceId", None))
44
+ or _coerce_optional_string(getattr(payload, "id", None))
45
+ or "missing"
46
+ )
47
+
48
+
49
+ def build_runtime_identity(
50
+ payload: Mapping[str, Any] | Any,
51
+ *,
52
+ container_id: str | None = None,
53
+ integration_id: str | None = None,
54
+ ) -> dict[str, Any]:
55
+ """Build a canonical runtime identity record for one config-backed device."""
56
+
57
+ resolved = {
58
+ "config_id": resolve_config_id(payload),
59
+ "device_id": resolve_device_id(payload),
60
+ }
61
+ payload_container_id = (
62
+ _first_present(payload, "container_id", "containerId")
63
+ if isinstance(payload, Mapping)
64
+ else (
65
+ _coerce_optional_string(getattr(payload, "container_id", None))
66
+ or _coerce_optional_string(getattr(payload, "containerId", None))
67
+ )
68
+ )
69
+ payload_integration_id = (
70
+ _first_present(payload, "integration_id", "integrationId")
71
+ if isinstance(payload, Mapping)
72
+ else (
73
+ _coerce_optional_string(getattr(payload, "integration_id", None))
74
+ or _coerce_optional_string(getattr(payload, "integrationId", None))
75
+ )
76
+ )
77
+
78
+ resolved["container_id"] = _coerce_optional_string(container_id) or payload_container_id
79
+ resolved["integration_id"] = _coerce_optional_string(integration_id) or payload_integration_id
80
+ return resolved
@@ -50,18 +50,30 @@ class RuntimeRegistry(Generic[TEntry, TState, TEvent]):
50
50
  first_entry_id = next(iter(self.entries))
51
51
  return self.entries[first_entry_id]
52
52
 
53
- def update_state(self, entry_id: str, state: dict[str, Any]) -> dict[str, Any]:
53
+ def update_state(
54
+ self,
55
+ entry_id: str,
56
+ state: dict[str, Any],
57
+ *,
58
+ device_id: str | None = None,
59
+ ) -> dict[str, Any]:
54
60
  """Store the latest state snapshot and mirror it onto the active entry."""
55
61
  timestamp = dt.datetime.now(dt.timezone.utc).isoformat()
62
+ entry = self.entries.get(entry_id)
63
+ resolved_device_id = (
64
+ device_id
65
+ or (str(entry.get("device_id")) if entry and entry.get("device_id") else None)
66
+ or entry_id
67
+ )
56
68
  latest_state = {
57
- "device_id": entry_id,
69
+ "device_id": resolved_device_id,
58
70
  "state": state,
59
71
  "last_updated": timestamp,
60
72
  }
61
73
  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
74
+ if entry is not None:
75
+ entry["latest_state"] = state
76
+ entry["last_updated"] = timestamp
65
77
  return latest_state
66
78
 
67
79
  def append_event(self, event: TEvent) -> TEvent:
@@ -11,8 +11,26 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field
11
11
 
12
12
  class RuntimeConfig(BaseModel):
13
13
  """Base runtime config model with a required config id and extensible fields."""
14
+
15
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
16
+
14
17
  id: str
15
- model_config = ConfigDict(extra="allow")
18
+ config_id: str | None = Field(
19
+ default=None,
20
+ validation_alias=AliasChoices("config_id", "configId"),
21
+ )
22
+ container_id: str | None = Field(
23
+ default=None,
24
+ validation_alias=AliasChoices("container_id", "containerId"),
25
+ )
26
+ integration_id: str | None = Field(
27
+ default=None,
28
+ validation_alias=AliasChoices("integration_id", "integrationId"),
29
+ )
30
+ device_id: str | None = Field(
31
+ default=None,
32
+ validation_alias=AliasChoices("device_id", "deviceId"),
33
+ )
16
34
 
17
35
 
18
36
  class RuntimeConfigSnapshot(BaseModel):
@@ -94,6 +112,44 @@ class RuntimeEntityDashboardResponse(BaseModel):
94
112
  metadata: dict[str, Any] = Field(default_factory=dict)
95
113
 
96
114
 
115
+ class RuntimeEntityCommandFieldResponse(BaseModel):
116
+ """Optional per-command argument metadata returned by a runtime entity."""
117
+
118
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
119
+
120
+ type: str
121
+ label: str | None = None
122
+ description: str | None = None
123
+ required: bool = False
124
+ default: Any | None = None
125
+ minimum: float | None = Field(
126
+ default=None,
127
+ validation_alias=AliasChoices("minimum", "min"),
128
+ )
129
+ maximum: float | None = Field(
130
+ default=None,
131
+ validation_alias=AliasChoices("maximum", "max"),
132
+ )
133
+ step: float | None = None
134
+ unit: str | None = None
135
+ options: list[Any] = Field(default_factory=list)
136
+
137
+
138
+ class RuntimeEntityCommandResponse(BaseModel):
139
+ """One device-scoped command exposed by a runtime entity."""
140
+
141
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
142
+
143
+ id: str
144
+ label: str | None = None
145
+ description: str | None = None
146
+ kind: str | None = None
147
+ args_schema: dict[str, RuntimeEntityCommandFieldResponse] = Field(
148
+ default_factory=dict,
149
+ validation_alias=AliasChoices("args_schema", "argsSchema"),
150
+ )
151
+
152
+
97
153
  class RuntimeEntityResponse(BaseModel):
98
154
  """One live runtime entity tied to a configured device/account."""
99
155
 
@@ -122,6 +178,15 @@ class RuntimeEntityResponse(BaseModel):
122
178
  default=None,
123
179
  validation_alias=AliasChoices("entity_type", "entityType"),
124
180
  )
181
+ available_commands: list[RuntimeEntityCommandResponse | str] = Field(
182
+ default_factory=list,
183
+ validation_alias=AliasChoices(
184
+ "available_commands",
185
+ "availableCommands",
186
+ "supported_commands",
187
+ "supportedCommands",
188
+ ),
189
+ )
125
190
  dashboard: RuntimeEntityDashboardResponse | None = None
126
191
  metadata: dict[str, Any] = Field(default_factory=dict)
127
192
 
@@ -2,6 +2,7 @@ import httpx
2
2
  import pytest
3
3
 
4
4
  from piphi_runtime_kit_python import EventClient, RuntimeAuthContext, RuntimeProcessState, TelemetryClient
5
+ from piphi_runtime_kit_python.runtime.errors import CoreUnavailableError
5
6
  from piphi_runtime_kit_python.runtime.dispatch import (
6
7
  build_local_event_record,
7
8
  dispatch_event_delivery,
@@ -48,6 +49,39 @@ async def test_dispatch_telemetry_delivery_returns_true_on_success(
48
49
  assert called == [("device-1", "container-1")]
49
50
 
50
51
 
52
+ @pytest.mark.asyncio
53
+ async def test_dispatch_telemetry_delivery_retries_retryable_core_errors(
54
+ monkeypatch: pytest.MonkeyPatch,
55
+ ) -> None:
56
+ process_state = RuntimeProcessState()
57
+ client = TelemetryClient(process_state=process_state, core_base_url="http://core.test")
58
+ auth_context = RuntimeAuthContext(container_id="container-retry", internal_token="token-retry")
59
+ attempts: list[int] = []
60
+
61
+ async def fake_send_metrics(self, **kwargs) -> None: # noqa: ARG001
62
+ attempts.append(1)
63
+ if len(attempts) == 1:
64
+ raise CoreUnavailableError(
65
+ operation="telemetry_delivery",
66
+ url="http://core.test/api/v2/integrations/telemetry",
67
+ message="PiPhi Core is unreachable",
68
+ retryable=True,
69
+ timeout_seconds=3.0,
70
+ )
71
+
72
+ monkeypatch.setattr(TelemetryClient, "send_metrics", fake_send_metrics)
73
+
74
+ delivered = await dispatch_telemetry_delivery(
75
+ telemetry_client=client,
76
+ auth_context=auth_context,
77
+ device_id="device-retry",
78
+ metrics={"is_on": True},
79
+ )
80
+
81
+ assert delivered is True
82
+ assert len(attempts) == 2
83
+
84
+
51
85
  @pytest.mark.asyncio
52
86
  async def test_dispatch_telemetry_delivery_calls_error_handler_and_returns_false(
53
87
  monkeypatch: pytest.MonkeyPatch,
@@ -107,6 +141,46 @@ async def test_dispatch_event_delivery_returns_true_on_success(
107
141
  assert called == ["device.updated"]
108
142
 
109
143
 
144
+ @pytest.mark.asyncio
145
+ async def test_dispatch_event_delivery_retries_retryable_core_errors(
146
+ monkeypatch: pytest.MonkeyPatch,
147
+ ) -> None:
148
+ process_state = RuntimeProcessState()
149
+ client = EventClient(process_state=process_state, core_base_url="http://core.test")
150
+ auth_context = RuntimeAuthContext(container_id="container-event", internal_token="token-event")
151
+ attempts: list[int] = []
152
+
153
+ async def fake_send_event(self, **kwargs) -> bool: # noqa: ARG001
154
+ attempts.append(1)
155
+ if len(attempts) == 1:
156
+ raise CoreUnavailableError(
157
+ operation="event_delivery",
158
+ url="http://core.test/api/v2/events/ingest",
159
+ message="PiPhi Core is unreachable",
160
+ retryable=True,
161
+ timeout_seconds=3.0,
162
+ )
163
+ return True
164
+
165
+ monkeypatch.setattr(EventClient, "send_event", fake_send_event)
166
+
167
+ delivered = await dispatch_event_delivery(
168
+ event_client=client,
169
+ auth_context=auth_context,
170
+ event_type="device.updated",
171
+ device={
172
+ "device_id": "device-event",
173
+ "container_id": "container-event",
174
+ "integration_id": "integration-event",
175
+ "config_id": "config-event",
176
+ },
177
+ source="runtime-event",
178
+ )
179
+
180
+ assert delivered is True
181
+ assert len(attempts) == 2
182
+
183
+
110
184
  @pytest.mark.asyncio
111
185
  async def test_dispatch_event_delivery_calls_skip_handler_when_client_returns_false(
112
186
  monkeypatch: pytest.MonkeyPatch,
@@ -0,0 +1,29 @@
1
+ from piphi_runtime_kit_python import RuntimeConfig, build_runtime_identity, resolve_config_id, resolve_device_id
2
+
3
+
4
+ def test_runtime_identity_helpers_prefer_explicit_config_and_device_ids() -> None:
5
+ payload = RuntimeConfig.model_validate(
6
+ {
7
+ "id": "core-config-uuid",
8
+ "config_id": "core-config-uuid",
9
+ "device_id": "vendor-device-42",
10
+ "container_id": "container-1",
11
+ "integration_id": "demo-runtime",
12
+ }
13
+ )
14
+
15
+ assert resolve_config_id(payload) == "core-config-uuid"
16
+ assert resolve_device_id(payload) == "vendor-device-42"
17
+ assert build_runtime_identity(payload) == {
18
+ "config_id": "core-config-uuid",
19
+ "device_id": "vendor-device-42",
20
+ "container_id": "container-1",
21
+ "integration_id": "demo-runtime",
22
+ }
23
+
24
+
25
+ def test_runtime_identity_helpers_fall_back_to_id() -> None:
26
+ payload = {"id": "core-config-uuid"}
27
+
28
+ assert resolve_config_id(payload) == "core-config-uuid"
29
+ assert resolve_device_id(payload) == "core-config-uuid"
@@ -21,6 +21,16 @@ def test_runtime_registry_update_state_mirrors_latest_state_to_entry() -> None:
21
21
  assert registry.entries["device-1"]["latest_state"] == {"is_on": True}
22
22
 
23
23
 
24
+ def test_runtime_registry_update_state_uses_entry_device_id_when_present() -> None:
25
+ registry = RuntimeRegistry()
26
+ registry.set("cfg-1", {"device_id": "sensor-1"})
27
+
28
+ latest_state = registry.update_state("cfg-1", {"temperature_c": 21.5})
29
+
30
+ assert latest_state["device_id"] == "sensor-1"
31
+ assert registry.state_snapshots["cfg-1"]["device_id"] == "sensor-1"
32
+
33
+
24
34
  def test_runtime_registry_append_event_bounds_recent_event_window() -> None:
25
35
  registry = RuntimeRegistry(max_recent_events=2)
26
36
 
@@ -40,6 +40,10 @@ def test_runtime_starter_entities_response_wraps_runtime_entities() -> None:
40
40
  "config_id": "cfg-1",
41
41
  "device_id": "office-plug",
42
42
  "device_class": "plug",
43
+ "available_commands": [
44
+ {"id": "toggle", "label": "Toggle", "kind": "primary"},
45
+ {"id": "refresh", "label": "Refresh", "kind": "secondary"},
46
+ ],
43
47
  }
44
48
  ],
45
49
  capabilities={"switch": {"kind": "action"}},
@@ -49,5 +53,6 @@ def test_runtime_starter_entities_response_wraps_runtime_entities() -> None:
49
53
  assert isinstance(response, RuntimeEntitiesResponse)
50
54
  assert response.entities[0].name == "Office Plug"
51
55
  assert response.entities[0].device_class == "plug"
56
+ assert response.entities[0].available_commands[0].id == "toggle"
52
57
  assert response.capabilities == {"switch": {"kind": "action"}}
53
58
  assert response.commands == {"turn_on": {"description": "Turn on"}}
@@ -17,6 +17,7 @@ from piphi_runtime_kit_python import (
17
17
  RuntimeConfigSyncResponse,
18
18
  RuntimeDiagnosticsResponse,
19
19
  RuntimeEntitiesResponse,
20
+ RuntimeEntityCommandResponse,
20
21
  RuntimeEntityDashboardResponse,
21
22
  RuntimeEntityResponse,
22
23
  RuntimeHealthResponse,
@@ -85,6 +86,20 @@ def test_runtime_entity_models_support_aliases_and_defaults() -> None:
85
86
  "deviceId": "office-plug",
86
87
  "deviceClass": "plug",
87
88
  "entityType": "switch",
89
+ "availableCommands": [
90
+ {
91
+ "id": "toggle",
92
+ "label": "Toggle",
93
+ "kind": "primary",
94
+ },
95
+ {
96
+ "id": "set_brightness",
97
+ "label": "Set Brightness",
98
+ "argsSchema": {
99
+ "brightness": {"type": "number", "min": 0, "max": 100, "unit": "%"}
100
+ },
101
+ },
102
+ ],
88
103
  "dashboard": {
89
104
  "allowedWidgets": ["tile", "stat"],
90
105
  "defaultWidget": "tile",
@@ -97,6 +112,10 @@ def test_runtime_entity_models_support_aliases_and_defaults() -> None:
97
112
  assert entity.device_id == "office-plug"
98
113
  assert entity.device_class == "plug"
99
114
  assert entity.entity_type == "switch"
115
+ assert len(entity.available_commands) == 2
116
+ assert isinstance(entity.available_commands[0], RuntimeEntityCommandResponse)
117
+ assert entity.available_commands[0].id == "toggle"
118
+ assert entity.available_commands[1].args_schema["brightness"].minimum == 0
100
119
  assert entity.dashboard is not None
101
120
  assert entity.dashboard.allowed_widgets == ["tile", "stat"]
102
121
  assert entity.dashboard.default_widget == "tile"