piphi-runtime-kit-python 0.4.1__tar.gz → 0.4.3__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.
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/PKG-INFO +4 -4
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/README.md +3 -3
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/pyproject.toml +1 -1
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/__init__.py +27 -1
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/config_sync.py +74 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/dispatch.py +50 -17
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/health.py +1 -0
- piphi_runtime_kit_python-0.4.3/src/piphi_runtime_kit_python/runtime/identity.py +80 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/registry.py +17 -5
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/schemas.py +88 -1
- piphi_runtime_kit_python-0.4.3/tests/test_config_sync.py +138 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_dispatch_matrix.py +74 -0
- piphi_runtime_kit_python-0.4.3/tests/test_runtime_identity.py +29 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_registry.py +10 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_starter.py +5 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_schemas_matrix.py +19 -0
- piphi_runtime_kit_python-0.4.1/tests/test_config_sync.py +0 -74
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/LICENSE +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/adapters/__init__.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/adapters/fastapi.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/fastapi.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/__init__.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/auth.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/configuration.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/context.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/discovery.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/errors.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/events.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/lifespan.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/mqtt.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/starter.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/state.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/tasks.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/src/piphi_runtime_kit_python/runtime/telemetry.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/__init__.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_fastapi_adapter.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_auth.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_auth_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_config_sync_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_configuration.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_configuration_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_delivery_errors.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_discovery.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_discovery_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_dispatch.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_errors_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_events.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_events_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_health.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_lifespan.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_mqtt.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_mqtt_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_registry_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_support_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_tasks.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_telemetry.py +0 -0
- {piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/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.
|
|
3
|
+
Version: 0.4.3
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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=
|
|
347
|
+
return build_config_apply_response(config_id=identity["config_id"])
|
|
348
348
|
|
|
349
349
|
|
|
350
350
|
@app.post("/telemetry/example")
|
|
@@ -8,7 +8,18 @@ from .runtime.auth import (
|
|
|
8
8
|
format_runtime_auth_sync_log,
|
|
9
9
|
mask_token,
|
|
10
10
|
)
|
|
11
|
-
from .runtime.config_sync import
|
|
11
|
+
from .runtime.config_sync import (
|
|
12
|
+
CORE_BASE_URL_ENV_NAME,
|
|
13
|
+
DEFAULT_RUNTIME_CONFIG_SNAPSHOT_FILENAME,
|
|
14
|
+
DEFAULT_RUNTIME_VOLUME_DIR,
|
|
15
|
+
RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME,
|
|
16
|
+
ConfigSyncCoordinator,
|
|
17
|
+
build_sync_response,
|
|
18
|
+
load_runtime_config_snapshot,
|
|
19
|
+
reconcile_config_ids,
|
|
20
|
+
resolve_core_base_url,
|
|
21
|
+
resolve_runtime_config_snapshot_path,
|
|
22
|
+
)
|
|
12
23
|
from .runtime.configuration import (
|
|
13
24
|
build_config_apply_response,
|
|
14
25
|
build_config_remove_response,
|
|
@@ -49,6 +60,7 @@ from .runtime.events import (
|
|
|
49
60
|
normalize_event_payload,
|
|
50
61
|
)
|
|
51
62
|
from .runtime.health import build_runtime_diagnostics_response, build_runtime_health_response
|
|
63
|
+
from .runtime.identity import build_runtime_identity, resolve_config_id, resolve_device_id
|
|
52
64
|
from .runtime.lifespan import (
|
|
53
65
|
DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
|
|
54
66
|
DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
|
|
@@ -80,6 +92,8 @@ from .schemas import (
|
|
|
80
92
|
IntegrationCommandRequest,
|
|
81
93
|
IntegrationDiscoveryRequest,
|
|
82
94
|
IntegrationDiscoveryResponse,
|
|
95
|
+
RuntimeEntityCommandFieldResponse,
|
|
96
|
+
RuntimeEntityCommandResponse,
|
|
83
97
|
RuntimeEntityDashboardResponse,
|
|
84
98
|
RuntimeEntitiesResponse,
|
|
85
99
|
RuntimeEntityResponse,
|
|
@@ -106,6 +120,10 @@ __all__ = [
|
|
|
106
120
|
"CoreUnavailableError",
|
|
107
121
|
"CoreUnexpectedResponseError",
|
|
108
122
|
"DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS",
|
|
123
|
+
"CORE_BASE_URL_ENV_NAME",
|
|
124
|
+
"DEFAULT_RUNTIME_CONFIG_SNAPSHOT_FILENAME",
|
|
125
|
+
"DEFAULT_RUNTIME_VOLUME_DIR",
|
|
126
|
+
"RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME",
|
|
109
127
|
"DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME",
|
|
110
128
|
"DEFAULT_RUNTIME_INTERNAL_TOKEN_ENV_NAME",
|
|
111
129
|
"RUNTIME_CONTAINER_ID_HEADER_NAME",
|
|
@@ -113,6 +131,8 @@ __all__ = [
|
|
|
113
131
|
"IntegrationCommandRequest",
|
|
114
132
|
"IntegrationDiscoveryRequest",
|
|
115
133
|
"IntegrationDiscoveryResponse",
|
|
134
|
+
"RuntimeEntityCommandFieldResponse",
|
|
135
|
+
"RuntimeEntityCommandResponse",
|
|
116
136
|
"RuntimeEntityDashboardResponse",
|
|
117
137
|
"RuntimeEntitiesResponse",
|
|
118
138
|
"RuntimeEntityResponse",
|
|
@@ -144,6 +164,7 @@ __all__ = [
|
|
|
144
164
|
"build_event_ingest_response",
|
|
145
165
|
"build_event_list_response",
|
|
146
166
|
"build_local_event_record",
|
|
167
|
+
"build_runtime_identity",
|
|
147
168
|
"build_core_auth_headers",
|
|
148
169
|
"build_config_apply_response",
|
|
149
170
|
"build_config_remove_response",
|
|
@@ -163,6 +184,7 @@ __all__ = [
|
|
|
163
184
|
"format_discovery_attempt_log",
|
|
164
185
|
"format_event_log",
|
|
165
186
|
"format_runtime_auth_sync_log",
|
|
187
|
+
"load_runtime_config_snapshot",
|
|
166
188
|
"mask_token",
|
|
167
189
|
"MqttBrokerConfig",
|
|
168
190
|
"MqttJsonClient",
|
|
@@ -170,11 +192,15 @@ __all__ = [
|
|
|
170
192
|
"normalize_event_payload",
|
|
171
193
|
"redact_config_secrets",
|
|
172
194
|
"reconcile_config_ids",
|
|
195
|
+
"resolve_core_base_url",
|
|
196
|
+
"resolve_runtime_config_snapshot_path",
|
|
173
197
|
"runtime_lifespan",
|
|
174
198
|
"schedule_event_delivery",
|
|
175
199
|
"schedule_telemetry_delivery",
|
|
176
200
|
"shutdown_background_tasks",
|
|
177
201
|
"track_background_task",
|
|
202
|
+
"resolve_config_id",
|
|
203
|
+
"resolve_device_id",
|
|
178
204
|
"validate_typed_config",
|
|
179
205
|
"validate_typed_configs",
|
|
180
206
|
"DEFAULT_SOURCE_TOPIC_PREFIX",
|
|
@@ -3,6 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
"""Helpers for basic config-sync orchestration and generation tracking."""
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
6
9
|
from typing import Awaitable, Callable, TypeVar
|
|
7
10
|
|
|
8
11
|
from ..schemas import RuntimeConfig, RuntimeConfigSnapshot, RuntimeConfigSyncResponse
|
|
@@ -10,6 +13,77 @@ from .state import RuntimeProcessState
|
|
|
10
13
|
|
|
11
14
|
TConfig = TypeVar("TConfig", bound=RuntimeConfig)
|
|
12
15
|
|
|
16
|
+
CORE_BASE_URL_ENV_NAME = "PIPHI_CORE_BASE_URL"
|
|
17
|
+
RUNTIME_CONTAINER_ID_ENV_NAME = "PIPHI_CONTAINER_ID"
|
|
18
|
+
RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME = "PIPHI_CONFIG_SNAPSHOT_PATH"
|
|
19
|
+
DEFAULT_RUNTIME_VOLUME_DIR = "/.piphinetwork"
|
|
20
|
+
DEFAULT_RUNTIME_CONFIG_SNAPSHOT_FILENAME = "configs.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_core_base_url(default: str | None = None) -> str | None:
|
|
24
|
+
"""Resolve Core's base URL from the managed-runtime environment."""
|
|
25
|
+
value = (os.getenv(CORE_BASE_URL_ENV_NAME) or "").strip().rstrip("/")
|
|
26
|
+
if value:
|
|
27
|
+
return value
|
|
28
|
+
return default.rstrip("/") if isinstance(default, str) and default.strip() else default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_runtime_config_snapshot_path(
|
|
32
|
+
*,
|
|
33
|
+
container_id: str | None = None,
|
|
34
|
+
volume_dir: str | Path = DEFAULT_RUNTIME_VOLUME_DIR,
|
|
35
|
+
) -> Path:
|
|
36
|
+
"""Resolve the local config snapshot path mounted by PiPhi Core."""
|
|
37
|
+
explicit_path = (os.getenv(RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME) or "").strip()
|
|
38
|
+
if explicit_path:
|
|
39
|
+
return Path(explicit_path)
|
|
40
|
+
|
|
41
|
+
resolved_container_id = (
|
|
42
|
+
str(container_id or "").strip()
|
|
43
|
+
or (os.getenv(RUNTIME_CONTAINER_ID_ENV_NAME) or "").strip()
|
|
44
|
+
)
|
|
45
|
+
if resolved_container_id:
|
|
46
|
+
return Path(volume_dir).joinpath(f"{resolved_container_id}.json")
|
|
47
|
+
return Path(volume_dir).joinpath(DEFAULT_RUNTIME_CONFIG_SNAPSHOT_FILENAME)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_runtime_config_snapshot(
|
|
51
|
+
*,
|
|
52
|
+
path: str | Path | None = None,
|
|
53
|
+
container_id: str | None = None,
|
|
54
|
+
volume_dir: str | Path = DEFAULT_RUNTIME_VOLUME_DIR,
|
|
55
|
+
) -> RuntimeConfigSnapshot | None:
|
|
56
|
+
"""Load Core's last known config snapshot from the mounted runtime volume.
|
|
57
|
+
|
|
58
|
+
Missing, unreadable, or invalid snapshots return ``None`` so integrations can
|
|
59
|
+
treat snapshot rehydration as a safe startup fallback rather than a crash
|
|
60
|
+
trigger.
|
|
61
|
+
"""
|
|
62
|
+
snapshot_path = (
|
|
63
|
+
Path(path)
|
|
64
|
+
if path is not None
|
|
65
|
+
else resolve_runtime_config_snapshot_path(
|
|
66
|
+
container_id=container_id,
|
|
67
|
+
volume_dir=volume_dir,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
raw_payload = snapshot_path.read_text(encoding="utf-8")
|
|
72
|
+
except (FileNotFoundError, OSError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
payload = json.loads(raw_payload)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
return None
|
|
79
|
+
if not isinstance(payload, dict):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
return RuntimeConfigSnapshot.model_validate(payload)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
13
87
|
|
|
14
88
|
def reconcile_config_ids(
|
|
15
89
|
*,
|
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
@@ -15,6 +15,7 @@ def _build_runtime_snapshot(runtime_context: RuntimeContext) -> dict[str, Any]:
|
|
|
15
15
|
"core_auth_present": bool(runtime_context.auth.internal_token),
|
|
16
16
|
"core_http_client_bound": process_state.core_http_client is not None,
|
|
17
17
|
"current_generation": process_state.current_generation,
|
|
18
|
+
"config_generation": process_state.current_generation,
|
|
18
19
|
"pending_task_count": len(
|
|
19
20
|
[task for task in process_state.background_tasks if not task.done()]
|
|
20
21
|
),
|
|
@@ -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(
|
|
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":
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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,18 +11,58 @@ 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
|
-
|
|
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):
|
|
19
37
|
"""Snapshot payload sent from PiPhi Core to an integration runtime."""
|
|
38
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
39
|
+
|
|
40
|
+
schema_version: int | None = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
validation_alias=AliasChoices("schema_version", "schemaVersion"),
|
|
43
|
+
)
|
|
20
44
|
container_id: str
|
|
21
45
|
integration_id: str | None = None
|
|
22
46
|
driver_pid: int | None = None
|
|
23
47
|
reason: str | None = None
|
|
24
48
|
generation: int | None = None
|
|
49
|
+
updated_at: str | None = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
validation_alias=AliasChoices("updated_at", "updatedAt"),
|
|
52
|
+
)
|
|
25
53
|
configs: list[RuntimeConfig] = Field(default_factory=list)
|
|
54
|
+
deleted_config_ids: list[str] = Field(
|
|
55
|
+
default_factory=list,
|
|
56
|
+
validation_alias=AliasChoices("deleted_config_ids", "deletedConfigIds"),
|
|
57
|
+
)
|
|
58
|
+
config_hash: str | None = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
validation_alias=AliasChoices("config_hash", "configHash"),
|
|
61
|
+
)
|
|
62
|
+
internal_token: str | None = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
validation_alias=AliasChoices("internal_token", "internalToken"),
|
|
65
|
+
)
|
|
26
66
|
|
|
27
67
|
|
|
28
68
|
class RuntimeConfigSyncResponse(BaseModel):
|
|
@@ -94,6 +134,44 @@ class RuntimeEntityDashboardResponse(BaseModel):
|
|
|
94
134
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
95
135
|
|
|
96
136
|
|
|
137
|
+
class RuntimeEntityCommandFieldResponse(BaseModel):
|
|
138
|
+
"""Optional per-command argument metadata returned by a runtime entity."""
|
|
139
|
+
|
|
140
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
141
|
+
|
|
142
|
+
type: str
|
|
143
|
+
label: str | None = None
|
|
144
|
+
description: str | None = None
|
|
145
|
+
required: bool = False
|
|
146
|
+
default: Any | None = None
|
|
147
|
+
minimum: float | None = Field(
|
|
148
|
+
default=None,
|
|
149
|
+
validation_alias=AliasChoices("minimum", "min"),
|
|
150
|
+
)
|
|
151
|
+
maximum: float | None = Field(
|
|
152
|
+
default=None,
|
|
153
|
+
validation_alias=AliasChoices("maximum", "max"),
|
|
154
|
+
)
|
|
155
|
+
step: float | None = None
|
|
156
|
+
unit: str | None = None
|
|
157
|
+
options: list[Any] = Field(default_factory=list)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RuntimeEntityCommandResponse(BaseModel):
|
|
161
|
+
"""One device-scoped command exposed by a runtime entity."""
|
|
162
|
+
|
|
163
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
164
|
+
|
|
165
|
+
id: str
|
|
166
|
+
label: str | None = None
|
|
167
|
+
description: str | None = None
|
|
168
|
+
kind: str | None = None
|
|
169
|
+
args_schema: dict[str, RuntimeEntityCommandFieldResponse] = Field(
|
|
170
|
+
default_factory=dict,
|
|
171
|
+
validation_alias=AliasChoices("args_schema", "argsSchema"),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
97
175
|
class RuntimeEntityResponse(BaseModel):
|
|
98
176
|
"""One live runtime entity tied to a configured device/account."""
|
|
99
177
|
|
|
@@ -122,6 +200,15 @@ class RuntimeEntityResponse(BaseModel):
|
|
|
122
200
|
default=None,
|
|
123
201
|
validation_alias=AliasChoices("entity_type", "entityType"),
|
|
124
202
|
)
|
|
203
|
+
available_commands: list[RuntimeEntityCommandResponse | str] = Field(
|
|
204
|
+
default_factory=list,
|
|
205
|
+
validation_alias=AliasChoices(
|
|
206
|
+
"available_commands",
|
|
207
|
+
"availableCommands",
|
|
208
|
+
"supported_commands",
|
|
209
|
+
"supportedCommands",
|
|
210
|
+
),
|
|
211
|
+
)
|
|
125
212
|
dashboard: RuntimeEntityDashboardResponse | None = None
|
|
126
213
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
127
214
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from piphi_runtime_kit_python import (
|
|
4
|
+
ConfigSyncCoordinator,
|
|
5
|
+
RuntimeConfig,
|
|
6
|
+
RuntimeConfigSnapshot,
|
|
7
|
+
RuntimeProcessState,
|
|
8
|
+
load_runtime_config_snapshot,
|
|
9
|
+
reconcile_config_ids,
|
|
10
|
+
resolve_core_base_url,
|
|
11
|
+
resolve_runtime_config_snapshot_path,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_reconcile_config_ids_returns_removed_and_unchanged_ids() -> None:
|
|
16
|
+
removed_ids, unchanged_ids = reconcile_config_ids(
|
|
17
|
+
active_ids=["a", "b", "c"],
|
|
18
|
+
incoming_ids=["b", "c", "d"],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
assert removed_ids == ["a"]
|
|
22
|
+
assert unchanged_ids == ["b", "c"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_config_sync_coordinator_detects_stale_snapshot() -> None:
|
|
26
|
+
state = RuntimeProcessState(current_generation=10)
|
|
27
|
+
coordinator = ConfigSyncCoordinator(process_state=state)
|
|
28
|
+
snapshot = RuntimeConfigSnapshot(
|
|
29
|
+
container_id="container-1",
|
|
30
|
+
generation=9,
|
|
31
|
+
configs=[RuntimeConfig(id="device-1")],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
assert coordinator.is_stale(snapshot) is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_config_sync_coordinator_updates_generation() -> None:
|
|
38
|
+
state = RuntimeProcessState()
|
|
39
|
+
coordinator = ConfigSyncCoordinator(process_state=state)
|
|
40
|
+
|
|
41
|
+
assert coordinator.mark_generation(42) == 42
|
|
42
|
+
assert state.current_generation == 42
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_resolve_core_base_url_uses_managed_runtime_env(monkeypatch) -> None:
|
|
46
|
+
monkeypatch.setenv("PIPHI_CORE_BASE_URL", "http://127.0.0.1:31419/")
|
|
47
|
+
|
|
48
|
+
assert resolve_core_base_url() == "http://127.0.0.1:31419"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_resolve_runtime_config_snapshot_path_uses_container_id(monkeypatch, tmp_path) -> None:
|
|
52
|
+
monkeypatch.delenv("PIPHI_CONFIG_SNAPSHOT_PATH", raising=False)
|
|
53
|
+
monkeypatch.setenv("PIPHI_CONTAINER_ID", "container-1")
|
|
54
|
+
|
|
55
|
+
assert resolve_runtime_config_snapshot_path(volume_dir=tmp_path) == tmp_path / "container-1.json"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_resolve_runtime_config_snapshot_path_prefers_explicit_path(monkeypatch, tmp_path) -> None:
|
|
59
|
+
explicit_path = tmp_path / "custom.json"
|
|
60
|
+
monkeypatch.setenv("PIPHI_CONFIG_SNAPSHOT_PATH", str(explicit_path))
|
|
61
|
+
|
|
62
|
+
assert resolve_runtime_config_snapshot_path(volume_dir=tmp_path) == explicit_path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_load_runtime_config_snapshot_reads_core_volume_contract(tmp_path) -> None:
|
|
66
|
+
snapshot_path = tmp_path / "container-1.json"
|
|
67
|
+
snapshot_path.write_text(
|
|
68
|
+
"""
|
|
69
|
+
{
|
|
70
|
+
"schema_version": 1,
|
|
71
|
+
"container_id": "container-1",
|
|
72
|
+
"integration_id": "integration-1",
|
|
73
|
+
"reason": "startup",
|
|
74
|
+
"generation": 42,
|
|
75
|
+
"updated_at": "2026-04-27T12:00:00+00:00",
|
|
76
|
+
"configs": [{"id": "device-1", "serial": "abc123"}],
|
|
77
|
+
"deleted_config_ids": ["device-2"],
|
|
78
|
+
"config_hash": "sha256:abc",
|
|
79
|
+
"internal_token": "runtime-token"
|
|
80
|
+
}
|
|
81
|
+
""",
|
|
82
|
+
encoding="utf-8",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
snapshot = load_runtime_config_snapshot(path=snapshot_path)
|
|
86
|
+
|
|
87
|
+
assert snapshot is not None
|
|
88
|
+
assert snapshot.schema_version == 1
|
|
89
|
+
assert snapshot.container_id == "container-1"
|
|
90
|
+
assert snapshot.generation == 42
|
|
91
|
+
assert snapshot.configs[0].id == "device-1"
|
|
92
|
+
assert snapshot.deleted_config_ids == ["device-2"]
|
|
93
|
+
assert snapshot.config_hash == "sha256:abc"
|
|
94
|
+
assert snapshot.internal_token == "runtime-token"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_load_runtime_config_snapshot_returns_none_for_missing_or_invalid_snapshot(tmp_path) -> None:
|
|
98
|
+
assert load_runtime_config_snapshot(path=tmp_path / "missing.json") is None
|
|
99
|
+
|
|
100
|
+
invalid_path = tmp_path / "invalid.json"
|
|
101
|
+
invalid_path.write_text("not-json", encoding="utf-8")
|
|
102
|
+
|
|
103
|
+
assert load_runtime_config_snapshot(path=invalid_path) is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.asyncio
|
|
107
|
+
async def test_config_sync_coordinator_apply_snapshot_orchestrates_callbacks() -> None:
|
|
108
|
+
state = RuntimeProcessState(current_generation=1)
|
|
109
|
+
coordinator = ConfigSyncCoordinator(process_state=state)
|
|
110
|
+
snapshot = RuntimeConfigSnapshot(
|
|
111
|
+
container_id="container-1",
|
|
112
|
+
generation=2,
|
|
113
|
+
configs=[RuntimeConfig(id="device-2"), RuntimeConfig(id="device-3")],
|
|
114
|
+
)
|
|
115
|
+
active_ids = ["device-1", "device-2"]
|
|
116
|
+
applied: list[str] = []
|
|
117
|
+
removed: list[str] = []
|
|
118
|
+
|
|
119
|
+
async def apply_config(config: RuntimeConfig) -> None:
|
|
120
|
+
applied.append(config.id)
|
|
121
|
+
|
|
122
|
+
async def remove_config(config_id: str) -> bool:
|
|
123
|
+
removed.append(config_id)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
response = await coordinator.apply_snapshot(
|
|
127
|
+
snapshot=snapshot,
|
|
128
|
+
active_config_ids=active_ids,
|
|
129
|
+
apply_config=apply_config,
|
|
130
|
+
remove_config=remove_config,
|
|
131
|
+
get_active_config_ids=lambda: ["device-2", "device-3"],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
assert applied == ["device-2", "device-3"]
|
|
135
|
+
assert removed == ["device-1"]
|
|
136
|
+
assert response.status == "synced"
|
|
137
|
+
assert response.generation == 2
|
|
138
|
+
assert response.active_config_ids == ["device-2", "device-3"]
|
|
@@ -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"
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_registry.py
RENAMED
|
@@ -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
|
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_starter.py
RENAMED
|
@@ -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"}}
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_schemas_matrix.py
RENAMED
|
@@ -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"
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from piphi_runtime_kit_python import (
|
|
4
|
-
ConfigSyncCoordinator,
|
|
5
|
-
RuntimeConfig,
|
|
6
|
-
RuntimeConfigSnapshot,
|
|
7
|
-
RuntimeProcessState,
|
|
8
|
-
reconcile_config_ids,
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_reconcile_config_ids_returns_removed_and_unchanged_ids() -> None:
|
|
13
|
-
removed_ids, unchanged_ids = reconcile_config_ids(
|
|
14
|
-
active_ids=["a", "b", "c"],
|
|
15
|
-
incoming_ids=["b", "c", "d"],
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
assert removed_ids == ["a"]
|
|
19
|
-
assert unchanged_ids == ["b", "c"]
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_config_sync_coordinator_detects_stale_snapshot() -> None:
|
|
23
|
-
state = RuntimeProcessState(current_generation=10)
|
|
24
|
-
coordinator = ConfigSyncCoordinator(process_state=state)
|
|
25
|
-
snapshot = RuntimeConfigSnapshot(
|
|
26
|
-
container_id="container-1",
|
|
27
|
-
generation=9,
|
|
28
|
-
configs=[RuntimeConfig(id="device-1")],
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
assert coordinator.is_stale(snapshot) is True
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_config_sync_coordinator_updates_generation() -> None:
|
|
35
|
-
state = RuntimeProcessState()
|
|
36
|
-
coordinator = ConfigSyncCoordinator(process_state=state)
|
|
37
|
-
|
|
38
|
-
assert coordinator.mark_generation(42) == 42
|
|
39
|
-
assert state.current_generation == 42
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@pytest.mark.asyncio
|
|
43
|
-
async def test_config_sync_coordinator_apply_snapshot_orchestrates_callbacks() -> None:
|
|
44
|
-
state = RuntimeProcessState(current_generation=1)
|
|
45
|
-
coordinator = ConfigSyncCoordinator(process_state=state)
|
|
46
|
-
snapshot = RuntimeConfigSnapshot(
|
|
47
|
-
container_id="container-1",
|
|
48
|
-
generation=2,
|
|
49
|
-
configs=[RuntimeConfig(id="device-2"), RuntimeConfig(id="device-3")],
|
|
50
|
-
)
|
|
51
|
-
active_ids = ["device-1", "device-2"]
|
|
52
|
-
applied: list[str] = []
|
|
53
|
-
removed: list[str] = []
|
|
54
|
-
|
|
55
|
-
async def apply_config(config: RuntimeConfig) -> None:
|
|
56
|
-
applied.append(config.id)
|
|
57
|
-
|
|
58
|
-
async def remove_config(config_id: str) -> bool:
|
|
59
|
-
removed.append(config_id)
|
|
60
|
-
return True
|
|
61
|
-
|
|
62
|
-
response = await coordinator.apply_snapshot(
|
|
63
|
-
snapshot=snapshot,
|
|
64
|
-
active_config_ids=active_ids,
|
|
65
|
-
apply_config=apply_config,
|
|
66
|
-
remove_config=remove_config,
|
|
67
|
-
get_active_config_ids=lambda: ["device-2", "device-3"],
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
assert applied == ["device-2", "device-3"]
|
|
71
|
-
assert removed == ["device-1"]
|
|
72
|
-
assert response.status == "synced"
|
|
73
|
-
assert response.generation == 2
|
|
74
|
-
assert response.active_config_ids == ["device-2", "device-3"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_fastapi_adapter.py
RENAMED
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_auth.py
RENAMED
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_auth_matrix.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_discovery.py
RENAMED
|
File without changes
|
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_dispatch.py
RENAMED
|
File without changes
|
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_events.py
RENAMED
|
File without changes
|
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_health.py
RENAMED
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_lifespan.py
RENAMED
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_mqtt.py
RENAMED
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_mqtt_matrix.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_tasks.py
RENAMED
|
File without changes
|
{piphi_runtime_kit_python-0.4.1 → piphi_runtime_kit_python-0.4.3}/tests/test_runtime_telemetry.py
RENAMED
|
File without changes
|
|
File without changes
|