piphi-runtime-kit-python 0.4.6__py3-none-any.whl → 0.6.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.
Files changed (28) hide show
  1. piphi_runtime_kit_python/__init__.py +35 -5
  2. piphi_runtime_kit_python/adapters/fastapi.py +2 -2
  3. piphi_runtime_kit_python/py.typed +1 -0
  4. piphi_runtime_kit_python/runtime/__init__.py +25 -3
  5. piphi_runtime_kit_python/runtime/auth.py +9 -5
  6. piphi_runtime_kit_python/runtime/config_sync.py +13 -8
  7. piphi_runtime_kit_python/runtime/configuration.py +13 -5
  8. piphi_runtime_kit_python/runtime/context.py +3 -2
  9. piphi_runtime_kit_python/runtime/discovery.py +2 -2
  10. piphi_runtime_kit_python/runtime/dispatch.py +67 -17
  11. piphi_runtime_kit_python/runtime/errors.py +4 -4
  12. piphi_runtime_kit_python/runtime/events.py +42 -7
  13. piphi_runtime_kit_python/runtime/health.py +5 -2
  14. piphi_runtime_kit_python/runtime/identity.py +80 -5
  15. piphi_runtime_kit_python/runtime/lifespan.py +10 -5
  16. piphi_runtime_kit_python/runtime/mqtt.py +8 -4
  17. piphi_runtime_kit_python/runtime/registry.py +8 -4
  18. piphi_runtime_kit_python/runtime/starter.py +40 -12
  19. piphi_runtime_kit_python/runtime/state.py +10 -2
  20. piphi_runtime_kit_python/runtime/tasks.py +73 -14
  21. piphi_runtime_kit_python/runtime/telemetry.py +75 -6
  22. piphi_runtime_kit_python/schemas.py +25 -3
  23. {piphi_runtime_kit_python-0.4.6.dist-info → piphi_runtime_kit_python-0.6.0.dist-info}/METADATA +102 -9
  24. piphi_runtime_kit_python-0.6.0.dist-info/RECORD +29 -0
  25. {piphi_runtime_kit_python-0.4.6.dist-info → piphi_runtime_kit_python-0.6.0.dist-info}/WHEEL +1 -1
  26. piphi_runtime_kit_python-0.4.6.dist-info/RECORD +0 -28
  27. {piphi_runtime_kit_python-0.4.6.dist-info → piphi_runtime_kit_python-0.6.0.dist-info}/entry_points.txt +0 -0
  28. {piphi_runtime_kit_python-0.4.6.dist-info → piphi_runtime_kit_python-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -63,8 +63,17 @@ from .runtime.events import (
63
63
  format_event_log,
64
64
  normalize_event_payload,
65
65
  )
66
- from .runtime.health import build_runtime_diagnostics_response, build_runtime_health_response
67
- from .runtime.identity import build_runtime_identity, resolve_config_id, resolve_device_id
66
+ from .runtime.health import (
67
+ build_runtime_diagnostics_response,
68
+ build_runtime_health_response,
69
+ )
70
+ from .runtime.identity import (
71
+ RuntimeDeviceRef,
72
+ RuntimeIdentityError,
73
+ build_runtime_identity,
74
+ resolve_config_id,
75
+ resolve_device_id,
76
+ )
68
77
  from .runtime.lifespan import (
69
78
  DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
70
79
  DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
@@ -86,9 +95,24 @@ from .runtime.mqtt import (
86
95
  )
87
96
  from .runtime.registry import RuntimeRegistry
88
97
  from .runtime.state import RuntimeProcessState
89
- from .runtime.starter import RuntimeStarter, build_entities_response, create_runtime_starter
90
- from .runtime.tasks import create_tracked_task, shutdown_background_tasks, track_background_task
91
- from .runtime.telemetry import TelemetryClient, build_core_auth_headers
98
+ from .runtime.starter import (
99
+ RuntimeStarter,
100
+ build_entities_response,
101
+ create_runtime_starter,
102
+ )
103
+ from .runtime.tasks import (
104
+ BackgroundTaskLimitError,
105
+ create_tracked_task,
106
+ shutdown_background_tasks,
107
+ track_background_task,
108
+ )
109
+ from .runtime.telemetry import (
110
+ TelemetryClient,
111
+ TelemetryReading,
112
+ TelemetryValue,
113
+ build_core_auth_headers,
114
+ build_telemetry_maps,
115
+ )
92
116
  from .schemas import (
93
117
  CoreEventPayload,
94
118
  EventSeverity,
@@ -115,6 +139,7 @@ from .schemas import (
115
139
  )
116
140
 
117
141
  __all__ = [
142
+ "BackgroundTaskLimitError",
118
143
  "ConfigSyncCoordinator",
119
144
  "CoreAuthError",
120
145
  "CoreDeliveryError",
@@ -150,12 +175,16 @@ __all__ = [
150
175
  "RuntimeConfigSnapshot",
151
176
  "RuntimeConfigSyncResponse",
152
177
  "RuntimeContext",
178
+ "RuntimeDeviceRef",
179
+ "RuntimeIdentityError",
153
180
  "RuntimeRegistry",
154
181
  "RuntimeProcessState",
155
182
  "RuntimeStarter",
156
183
  "RuntimeDiagnosticsResponse",
157
184
  "RuntimeHealthResponse",
158
185
  "TelemetryClient",
186
+ "TelemetryReading",
187
+ "TelemetryValue",
159
188
  "EventClient",
160
189
  "TelemetryPayload",
161
190
  "CoreEventPayload",
@@ -172,6 +201,7 @@ __all__ = [
172
201
  "build_local_event_record",
173
202
  "build_runtime_identity",
174
203
  "build_core_auth_headers",
204
+ "build_telemetry_maps",
175
205
  "build_config_apply_response",
176
206
  "build_config_remove_response",
177
207
  "build_entities_response",
@@ -1,7 +1,7 @@
1
- from __future__ import annotations
2
-
3
1
  """Optional FastAPI-facing helpers for common PiPhi runtime patterns."""
4
2
 
3
+ from __future__ import annotations
4
+
5
5
  from typing import TYPE_CHECKING, Any, Mapping
6
6
 
7
7
  from ..runtime.auth import RuntimeAuthHeaders
@@ -0,0 +1 @@
1
+
@@ -8,7 +8,11 @@ from .auth import (
8
8
  format_runtime_auth_sync_log,
9
9
  mask_token,
10
10
  )
11
- from .config_sync import ConfigSyncCoordinator, build_sync_response, reconcile_config_ids
11
+ from .config_sync import (
12
+ ConfigSyncCoordinator,
13
+ build_sync_response,
14
+ reconcile_config_ids,
15
+ )
12
16
  from .configuration import (
13
17
  build_config_apply_response,
14
18
  build_config_remove_response,
@@ -49,6 +53,7 @@ from .events import (
49
53
  normalize_event_payload,
50
54
  )
51
55
  from .health import build_runtime_diagnostics_response, build_runtime_health_response
56
+ from .identity import RuntimeDeviceRef, RuntimeIdentityError
52
57
  from .lifespan import (
53
58
  DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
54
59
  DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
@@ -60,10 +65,22 @@ from .lifespan import (
60
65
  from .registry import RuntimeRegistry
61
66
  from .state import RuntimeProcessState
62
67
  from .starter import RuntimeStarter, create_runtime_starter
63
- from .tasks import create_tracked_task, shutdown_background_tasks, track_background_task
64
- from .telemetry import TelemetryClient, build_core_auth_headers
68
+ from .tasks import (
69
+ BackgroundTaskLimitError,
70
+ create_tracked_task,
71
+ shutdown_background_tasks,
72
+ track_background_task,
73
+ )
74
+ from .telemetry import (
75
+ TelemetryClient,
76
+ TelemetryReading,
77
+ TelemetryValue,
78
+ build_core_auth_headers,
79
+ build_telemetry_maps,
80
+ )
65
81
 
66
82
  __all__ = [
83
+ "BackgroundTaskLimitError",
67
84
  "ConfigSyncCoordinator",
68
85
  "CoreAuthError",
69
86
  "CoreDeliveryError",
@@ -77,10 +94,14 @@ __all__ = [
77
94
  "RuntimeAuthContext",
78
95
  "RuntimeAuthHeaders",
79
96
  "RuntimeContext",
97
+ "RuntimeDeviceRef",
98
+ "RuntimeIdentityError",
80
99
  "RuntimeRegistry",
81
100
  "RuntimeProcessState",
82
101
  "RuntimeStarter",
83
102
  "TelemetryClient",
103
+ "TelemetryReading",
104
+ "TelemetryValue",
84
105
  "EventClient",
85
106
  "build_local_event_record",
86
107
  "build_config_apply_response",
@@ -90,6 +111,7 @@ __all__ = [
90
111
  "build_event_ingest_response",
91
112
  "build_event_list_response",
92
113
  "build_core_auth_headers",
114
+ "build_telemetry_maps",
93
115
  "build_runtime_auth_headers",
94
116
  "build_sync_response",
95
117
  "bind_core_http_client",
@@ -1,15 +1,15 @@
1
- from __future__ import annotations
2
-
3
1
  """Helpers for resolving PiPhi runtime auth across requests and background work."""
4
2
 
3
+ from __future__ import annotations
4
+
5
5
  import os
6
6
  from dataclasses import dataclass
7
7
  from typing import Any, Mapping
8
8
 
9
9
 
10
- DEFAULT_INTERNAL_TOKEN_ENV_NAME = "PIPHI_INTEGRATION_INTERNAL_TOKEN"
10
+ DEFAULT_INTERNAL_TOKEN_ENV_NAME = "PIPHI_INTEGRATION_INTERNAL_TOKEN" # nosec B105
11
11
  RUNTIME_CONTAINER_ID_HEADER_NAME = "X-Container-Id"
12
- RUNTIME_INTERNAL_TOKEN_HEADER_NAME = "X-PiPhi-Integration-Token"
12
+ RUNTIME_INTERNAL_TOKEN_HEADER_NAME = "X-PiPhi-Integration-Token" # nosec B105
13
13
 
14
14
 
15
15
  def mask_token(token: str | None) -> str:
@@ -32,6 +32,7 @@ def _get_header_value(headers: Mapping[str, Any], name: str) -> str:
32
32
  @dataclass(slots=True, frozen=True)
33
33
  class RuntimeAuthHeaders:
34
34
  """Normalized PiPhi runtime auth values extracted from request headers."""
35
+
35
36
  container_id: str = ""
36
37
  internal_token: str = ""
37
38
 
@@ -81,6 +82,7 @@ class RuntimeAuthContext:
81
82
  Integrations can keep the most recent container scope and internal token here,
82
83
  then reuse it for outbound telemetry or config sync calls.
83
84
  """
85
+
84
86
  container_id: str = ""
85
87
  internal_token: str = ""
86
88
 
@@ -130,5 +132,7 @@ class RuntimeAuthContext:
130
132
  ) -> tuple[str, str]:
131
133
  """Resolve the effective container scope and internal token for a request."""
132
134
  resolved_container_id = str(container_id or "").strip() or self.container_id
133
- resolved_internal_token = self.internal_token or (os.getenv(token_env_name) or "").strip()
135
+ resolved_internal_token = (
136
+ self.internal_token or (os.getenv(token_env_name) or "").strip()
137
+ )
134
138
  return resolved_container_id, resolved_internal_token
@@ -1,7 +1,7 @@
1
- from __future__ import annotations
2
-
3
1
  """Helpers for basic config-sync orchestration and generation tracking."""
4
2
 
3
+ from __future__ import annotations
4
+
5
5
  from dataclasses import dataclass
6
6
  import json
7
7
  import os
@@ -36,7 +36,9 @@ def resolve_core_base_url(default: str | None = None) -> str | None:
36
36
  value = (os.getenv(CORE_BASE_URL_ENV_NAME) or "").strip().rstrip("/")
37
37
  if value:
38
38
  return value
39
- return default.rstrip("/") if isinstance(default, str) and default.strip() else default
39
+ return (
40
+ default.rstrip("/") if isinstance(default, str) and default.strip() else default
41
+ )
40
42
 
41
43
 
42
44
  def resolve_runtime_config_snapshot_path(
@@ -164,7 +166,7 @@ def build_runtime_config_snapshot_from_core_rows(
164
166
  rows: list[dict[str, Any]],
165
167
  *,
166
168
  container_id: str,
167
- config_model: type[TConfig] = RuntimeConfig,
169
+ config_model: type[TConfig] = RuntimeConfig, # type: ignore[assignment]
168
170
  snapshot_model: type[Any] = RuntimeConfigSnapshot,
169
171
  reason: str = "startup_rehydrate",
170
172
  ) -> Any:
@@ -200,7 +202,7 @@ async def fetch_core_runtime_config_snapshot(
200
202
  runtime_context: RuntimeContext,
201
203
  client: httpx.AsyncClient,
202
204
  core_base_url: str | None = None,
203
- config_model: type[TConfig] = RuntimeConfig,
205
+ config_model: type[TConfig] = RuntimeConfig, # type: ignore[assignment]
204
206
  snapshot_model: type[Any] = RuntimeConfigSnapshot,
205
207
  timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
206
208
  reason: str = "startup_rehydrate",
@@ -247,7 +249,7 @@ async def rehydrate_runtime_configs(
247
249
  runtime_context: RuntimeContext,
248
250
  client: httpx.AsyncClient,
249
251
  apply_snapshot: SnapshotApplyCallable,
250
- config_model: type[TConfig] = RuntimeConfig,
252
+ config_model: type[TConfig] = RuntimeConfig, # type: ignore[assignment]
251
253
  snapshot_model: type[Any] = RuntimeConfigSnapshot,
252
254
  core_base_url: str | None = None,
253
255
  timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
@@ -338,7 +340,7 @@ def build_sync_response(
338
340
  applied: list[str] | None = None,
339
341
  removed: list[str] | None = None,
340
342
  active_config_ids: list[str] | None = None,
341
- metadata: dict | None = None,
343
+ metadata: dict[str, Any] | None = None,
342
344
  ) -> RuntimeConfigSyncResponse:
343
345
  """Build a standard PiPhi config-sync response payload."""
344
346
  return RuntimeConfigSyncResponse(
@@ -360,6 +362,7 @@ class ConfigSyncCoordinator:
360
362
  This class intentionally stays small. It helps integrations decide whether
361
363
  an incoming snapshot is stale and keeps generation tracking in one place.
362
364
  """
365
+
363
366
  process_state: RuntimeProcessState
364
367
 
365
368
  def get_current_generation(self) -> int | None:
@@ -443,7 +446,9 @@ class ConfigSyncCoordinator:
443
446
 
444
447
  current_generation = self.mark_generation(snapshot.generation)
445
448
  next_active_config_ids = (
446
- get_active_config_ids() if get_active_config_ids is not None else incoming_ids
449
+ get_active_config_ids()
450
+ if get_active_config_ids is not None
451
+ else incoming_ids
447
452
  )
448
453
 
449
454
  return build_sync_response(
@@ -1,10 +1,14 @@
1
- from __future__ import annotations
2
-
3
1
  """Helpers for typed runtime config handling, safe logging, and responses."""
4
2
 
3
+ from __future__ import annotations
4
+
5
5
  from typing import Any, Mapping, TypeVar
6
6
 
7
- from ..schemas import RuntimeConfig, RuntimeConfigApplyResponse, RuntimeConfigRemoveResponse
7
+ from ..schemas import (
8
+ RuntimeConfig,
9
+ RuntimeConfigApplyResponse,
10
+ RuntimeConfigRemoveResponse,
11
+ )
8
12
 
9
13
  TConfig = TypeVar("TConfig", bound=RuntimeConfig)
10
14
 
@@ -53,7 +57,9 @@ def format_config_apply_log(
53
57
  secret_keys: tuple[str, ...] = DEFAULT_CONFIG_SECRET_KEYS,
54
58
  ) -> str:
55
59
  """Build a consistent config lifecycle log message with redacted values."""
56
- raw_payload = payload.model_dump() if isinstance(payload, RuntimeConfig) else dict(payload)
60
+ raw_payload = (
61
+ payload.model_dump() if isinstance(payload, RuntimeConfig) else dict(payload)
62
+ )
57
63
  config_id = str(raw_payload.get("id") or "missing")
58
64
  container_id = str(raw_payload.get("container_id") or "missing")
59
65
  redacted_payload = redact_config_secrets(raw_payload, secret_keys=secret_keys)
@@ -97,7 +103,9 @@ def build_config_remove_response(
97
103
  )
98
104
 
99
105
 
100
- def validate_typed_config(payload: TConfig | Mapping[str, Any], config_model: type[TConfig]) -> TConfig:
106
+ def validate_typed_config(
107
+ payload: TConfig | Mapping[str, Any], config_model: type[TConfig]
108
+ ) -> TConfig:
101
109
  """Validate one config payload against an integration-specific RuntimeConfig subclass."""
102
110
  if isinstance(payload, config_model):
103
111
  return payload
@@ -1,7 +1,7 @@
1
- from __future__ import annotations
2
-
3
1
  """Higher-level runtime context that bundles auth and process state together."""
4
2
 
3
+ from __future__ import annotations
4
+
5
5
  from dataclasses import dataclass, field
6
6
 
7
7
  import httpx
@@ -17,6 +17,7 @@ class RuntimeContext:
17
17
  This is the easiest entry point when an integration wants one object that
18
18
  carries both request/runtime auth state and process-scoped helper state.
19
19
  """
20
+
20
21
  auth: RuntimeAuthContext = field(default_factory=RuntimeAuthContext)
21
22
  process_state: RuntimeProcessState = field(default_factory=RuntimeProcessState)
22
23
 
@@ -1,7 +1,7 @@
1
- from __future__ import annotations
2
-
3
1
  """Helpers for thin, framework-agnostic discovery request/response handling."""
4
2
 
3
+ from __future__ import annotations
4
+
5
5
  from typing import Any, Iterable, Mapping
6
6
 
7
7
  from ..schemas import IntegrationDiscoveryResponse
@@ -1,16 +1,20 @@
1
- from __future__ import annotations
2
-
3
1
  """Helpers for background dispatch of telemetry and event delivery."""
4
2
 
3
+ from __future__ import annotations
4
+
5
5
  import asyncio
6
- from typing import Any, Callable, Mapping
6
+ import datetime as dt
7
+ import random
8
+ from collections.abc import Awaitable
9
+ from typing import Any, Callable, Mapping, TypeVar
10
+ from uuid import uuid4
7
11
 
12
+ from .auth import RuntimeAuthContext
8
13
  from .errors import CoreDeliveryError
9
14
  from .events import EventClient
15
+ from .state import RuntimeProcessState
10
16
  from .tasks import create_tracked_task
11
17
  from .telemetry import TelemetryClient
12
- from .auth import RuntimeAuthContext
13
- from .state import RuntimeProcessState
14
18
 
15
19
  EventRecorder = Callable[[dict[str, Any]], Any]
16
20
  SkipHandler = Callable[[str, dict[str, Any]], Any]
@@ -18,6 +22,8 @@ ErrorHandler = Callable[[Exception, dict[str, Any]], Any]
18
22
 
19
23
  DEFAULT_RETRYABLE_DELIVERY_ATTEMPTS = 5
20
24
  DEFAULT_RETRYABLE_DELIVERY_INITIAL_DELAY_SECONDS = 0.5
25
+ DEFAULT_RETRYABLE_DELIVERY_JITTER_RATIO = 0.2
26
+ TDelivery = TypeVar("TDelivery")
21
27
 
22
28
 
23
29
  def build_local_event_record(
@@ -27,9 +33,12 @@ def build_local_event_record(
27
33
  payload: Mapping[str, Any] | None = None,
28
34
  source: str,
29
35
  severity: str,
36
+ event_id: str | None = None,
37
+ ts: str | dt.datetime | None = None,
38
+ topic: str | None = None,
30
39
  ) -> dict[str, Any]:
31
40
  """Build the canonical local runtime event record shape."""
32
- return {
41
+ record = {
33
42
  "event_type": event_type,
34
43
  "source": source,
35
44
  "payload": dict(payload or {}),
@@ -39,28 +48,35 @@ def build_local_event_record(
39
48
  "config_id": device.get("config_id"),
40
49
  "severity": severity,
41
50
  }
51
+ if event_id is not None:
52
+ record["event_id"] = event_id
53
+ if ts is not None:
54
+ record["ts"] = ts.isoformat() if isinstance(ts, dt.datetime) else ts
55
+ if topic is not None:
56
+ record["topic"] = topic
57
+ return record
42
58
 
43
59
 
44
60
  async def _run_with_retryable_core_delivery_backoff(
45
- delivery: Callable[[], Any],
61
+ delivery: Callable[[], Awaitable[TDelivery]],
46
62
  *,
47
63
  max_attempts: int = DEFAULT_RETRYABLE_DELIVERY_ATTEMPTS,
48
64
  initial_delay_seconds: float = DEFAULT_RETRYABLE_DELIVERY_INITIAL_DELAY_SECONDS,
49
- ) -> Any:
65
+ jitter_ratio: float = DEFAULT_RETRYABLE_DELIVERY_JITTER_RATIO,
66
+ random_value: Callable[[], float] = random.random,
67
+ ) -> TDelivery:
50
68
  """Retry transient Core delivery failures with a short exponential backoff."""
51
69
 
52
70
  attempt = 1
53
71
  delay_seconds = initial_delay_seconds
54
72
  while True:
55
73
  try:
56
- result = delivery()
57
- if asyncio.iscoroutine(result):
58
- return await result
59
- return result
74
+ return await delivery()
60
75
  except CoreDeliveryError as exc:
61
76
  if not exc.retryable or attempt >= max_attempts:
62
77
  raise
63
- await asyncio.sleep(delay_seconds)
78
+ jitter_multiplier = 1 - jitter_ratio + (2 * jitter_ratio * random_value())
79
+ await asyncio.sleep(delay_seconds * jitter_multiplier)
64
80
  attempt += 1
65
81
  delay_seconds *= 2
66
82
 
@@ -71,6 +87,7 @@ async def dispatch_telemetry_delivery(
71
87
  auth_context: RuntimeAuthContext,
72
88
  device_id: str,
73
89
  metrics: dict[str, Any],
90
+ config_id: str | None = None,
74
91
  container_id: str | None = None,
75
92
  units: dict[str, Any] | None = None,
76
93
  timestamp: str | None = None,
@@ -87,21 +104,31 @@ async def dispatch_telemetry_delivery(
87
104
  )
88
105
  return False
89
106
 
107
+ resolved_timestamp = timestamp or dt.datetime.now(dt.timezone.utc).isoformat()
108
+
90
109
  try:
91
110
  await _run_with_retryable_core_delivery_backoff(
92
111
  lambda: telemetry_client.send_metrics(
93
112
  device_id=device_id,
94
113
  metrics=metrics,
95
114
  auth_context=auth_context,
115
+ config_id=config_id,
96
116
  container_id=resolved_container_id,
97
117
  units=units,
98
- timestamp=timestamp,
118
+ timestamp=resolved_timestamp,
99
119
  )
100
120
  )
101
121
  return True
102
122
  except Exception as exc:
103
123
  if on_error is not None:
104
- on_error(exc, {"device_id": device_id, "container_id": resolved_container_id})
124
+ on_error(
125
+ exc,
126
+ {
127
+ "device_id": device_id,
128
+ "config_id": config_id,
129
+ "container_id": resolved_container_id,
130
+ },
131
+ )
105
132
  return False
106
133
  raise
107
134
 
@@ -113,12 +140,13 @@ def schedule_telemetry_delivery(
113
140
  auth_context: RuntimeAuthContext,
114
141
  device_id: str,
115
142
  metrics: dict[str, Any],
143
+ config_id: str | None = None,
116
144
  container_id: str | None = None,
117
145
  units: dict[str, Any] | None = None,
118
146
  timestamp: str | None = None,
119
147
  on_skipped: SkipHandler | None = None,
120
148
  on_error: ErrorHandler | None = None,
121
- ):
149
+ ) -> asyncio.Task[bool]:
122
150
  """Schedule background telemetry delivery on the tracked runtime task set."""
123
151
  return create_tracked_task(
124
152
  dispatch_telemetry_delivery(
@@ -126,6 +154,7 @@ def schedule_telemetry_delivery(
126
154
  auth_context=auth_context,
127
155
  device_id=device_id,
128
156
  metrics=metrics,
157
+ config_id=config_id,
129
158
  container_id=container_id,
130
159
  units=units,
131
160
  timestamp=timestamp,
@@ -145,10 +174,16 @@ async def dispatch_event_delivery(
145
174
  payload: Mapping[str, Any] | None = None,
146
175
  source: str,
147
176
  severity: str = "info",
177
+ event_id: str | None = None,
178
+ ts: str | dt.datetime | None = None,
179
+ topic: str | None = None,
148
180
  on_skipped: SkipHandler | None = None,
149
181
  on_error: ErrorHandler | None = None,
150
182
  ) -> bool:
151
183
  """Send one event to Core and return whether delivery succeeded."""
184
+ resolved_event_id = event_id or uuid4().hex
185
+ resolved_ts = ts or dt.datetime.now(dt.timezone.utc)
186
+
152
187
  try:
153
188
  event_sent = await _run_with_retryable_core_delivery_backoff(
154
189
  lambda: event_client.send_event(
@@ -161,6 +196,9 @@ async def dispatch_event_delivery(
161
196
  payload=payload,
162
197
  source=source,
163
198
  severity=severity,
199
+ event_id=resolved_event_id,
200
+ ts=resolved_ts,
201
+ topic=topic,
164
202
  )
165
203
  )
166
204
  if not event_sent and on_skipped is not None:
@@ -199,11 +237,17 @@ def schedule_event_delivery(
199
237
  payload: Mapping[str, Any] | None = None,
200
238
  source: str,
201
239
  severity: str = "info",
240
+ event_id: str | None = None,
241
+ ts: str | dt.datetime | None = None,
242
+ topic: str | None = None,
202
243
  record_event: EventRecorder | None = None,
203
244
  on_skipped: SkipHandler | None = None,
204
245
  on_error: ErrorHandler | None = None,
205
- ):
246
+ ) -> asyncio.Task[bool]:
206
247
  """Record an optional local event and schedule delivery to Core."""
248
+ resolved_event_id = event_id or uuid4().hex
249
+ resolved_ts = ts or dt.datetime.now(dt.timezone.utc)
250
+
207
251
  if record_event is not None:
208
252
  record_event(
209
253
  build_local_event_record(
@@ -212,6 +256,9 @@ def schedule_event_delivery(
212
256
  payload=payload,
213
257
  source=source,
214
258
  severity=severity,
259
+ event_id=resolved_event_id,
260
+ ts=resolved_ts,
261
+ topic=topic,
215
262
  )
216
263
  )
217
264
  return create_tracked_task(
@@ -223,6 +270,9 @@ def schedule_event_delivery(
223
270
  payload=payload,
224
271
  source=source,
225
272
  severity=severity,
273
+ event_id=resolved_event_id,
274
+ ts=resolved_ts,
275
+ topic=topic,
226
276
  on_skipped=on_skipped,
227
277
  on_error=on_error,
228
278
  ),
@@ -1,7 +1,7 @@
1
- from __future__ import annotations
2
-
3
1
  """Typed delivery errors for PiPhi runtime-to-Core communication."""
4
2
 
3
+ from __future__ import annotations
4
+
5
5
  from dataclasses import dataclass
6
6
 
7
7
  import httpx
@@ -66,7 +66,7 @@ def classify_core_delivery_error(
66
66
  timeout_seconds: float | None,
67
67
  ) -> CoreDeliveryError:
68
68
  """Convert raw HTTP client exceptions into stable PiPhi delivery errors."""
69
- if isinstance(exc, httpx.ReadTimeout):
69
+ if isinstance(exc, httpx.TimeoutException):
70
70
  return CoreTimeoutError(
71
71
  operation=operation,
72
72
  url=url,
@@ -75,7 +75,7 @@ def classify_core_delivery_error(
75
75
  timeout_seconds=timeout_seconds,
76
76
  )
77
77
 
78
- if isinstance(exc, httpx.ConnectError):
78
+ if isinstance(exc, httpx.TransportError):
79
79
  return CoreUnavailableError(
80
80
  operation=operation,
81
81
  url=url,