piphi-runtime-kit-python 0.6.0__py3-none-any.whl → 0.7.1__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.
@@ -1,3 +1,19 @@
1
+ from .automations import (
2
+ AutomationActionDefinition,
3
+ AutomationActionRequest,
4
+ AutomationActionResult,
5
+ AutomationClient,
6
+ AutomationContractReport,
7
+ AutomationEventDefinition,
8
+ AutomationIdempotencyStore,
9
+ AutomationIdempotencyClaimStore,
10
+ AutomationRegistry,
11
+ InMemoryAutomationIdempotencyStore,
12
+ SQLiteAutomationIdempotencyStore,
13
+ assert_behaviors_contract,
14
+ audit_behaviors_contract,
15
+ build_mock_automation_event,
16
+ )
1
17
  from .runtime.auth import (
2
18
  RUNTIME_CONTAINER_ID_HEADER_NAME,
3
19
  RUNTIME_INTERNAL_TOKEN_HEADER_NAME,
@@ -139,6 +155,17 @@ from .schemas import (
139
155
  )
140
156
 
141
157
  __all__ = [
158
+ "AutomationActionDefinition",
159
+ "AutomationActionRequest",
160
+ "AutomationActionResult",
161
+ "AutomationClient",
162
+ "AutomationContractReport",
163
+ "AutomationEventDefinition",
164
+ "AutomationIdempotencyStore",
165
+ "AutomationIdempotencyClaimStore",
166
+ "AutomationRegistry",
167
+ "InMemoryAutomationIdempotencyStore",
168
+ "SQLiteAutomationIdempotencyStore",
142
169
  "BackgroundTaskLimitError",
143
170
  "ConfigSyncCoordinator",
144
171
  "CoreAuthError",
@@ -193,6 +220,7 @@ __all__ = [
193
220
  "IntegrationEventIngestResponse",
194
221
  "IntegrationEventListResponse",
195
222
  "build_runtime_config_snapshot_from_core_rows",
223
+ "build_mock_automation_event",
196
224
  "build_discovery_response",
197
225
  "classify_core_delivery_error",
198
226
  "build_core_event_payload",
@@ -241,6 +269,8 @@ __all__ = [
241
269
  "resolve_device_id",
242
270
  "validate_typed_config",
243
271
  "validate_typed_configs",
272
+ "assert_behaviors_contract",
273
+ "audit_behaviors_contract",
244
274
  "DEFAULT_SOURCE_TOPIC_PREFIX",
245
275
  "build_source_errors_topic",
246
276
  "build_source_model_packets_topic",
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING, Any, Mapping
6
6
 
7
+ from ..automations import AutomationActionResult, AutomationRegistry
7
8
  from ..runtime.auth import RuntimeAuthHeaders
8
9
  from ..runtime.context import RuntimeContext
9
10
 
@@ -11,6 +12,31 @@ if TYPE_CHECKING:
11
12
  from fastapi import Request
12
13
 
13
14
 
15
+ async def dispatch_automation_action_from_fastapi(
16
+ registry: AutomationRegistry,
17
+ request: "Request",
18
+ payload: object | Mapping[str, Any],
19
+ ) -> AutomationActionResult:
20
+ """Dispatch a Core command while preserving its stable idempotency key.
21
+
22
+ The key is accepted from the canonical header first and then from the JSON
23
+ payload for compatibility with runtimes that proxy requests internally.
24
+ """
25
+ payload_data = (
26
+ dict(payload)
27
+ if isinstance(payload, Mapping)
28
+ else payload.model_dump(mode="python")
29
+ if hasattr(payload, "model_dump")
30
+ else dict(vars(payload))
31
+ )
32
+ header_key = str(request.headers.get("X-PiPhi-Idempotency-Key") or "").strip()
33
+ payload_key = str(payload_data.get("idempotency_key") or "").strip()
34
+ return await registry.dispatch(
35
+ payload_data,
36
+ idempotency_key=header_key or payload_key or None,
37
+ )
38
+
39
+
14
40
  def get_payload_container_id(
15
41
  payload: object | Mapping[str, Any] | None,
16
42
  *,
@@ -0,0 +1,554 @@
1
+ """Typed automation helpers for PiPhi integration runtimes.
2
+
3
+ ``behaviors.json`` remains the declarative catalog consumed by Core and the
4
+ builders. This module implements the runtime half of that contract: emitting
5
+ declared events, dispatching declared actions, preserving idempotency keys, and
6
+ checking that runtime handlers match the published behavior schema.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import Awaitable, Callable, Mapping
13
+ from contextlib import closing
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ import sqlite3
17
+ from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ from .runtime.auth import RuntimeAuthContext
22
+ from .runtime.events import EventClient, build_core_event_payload
23
+ from .runtime.identity import RuntimeDeviceRef
24
+ from .schemas import CoreEventPayload, EventSeverity
25
+
26
+ class AutomationActionRequest(BaseModel):
27
+ """Normalized action request received from PiPhi Core."""
28
+
29
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
30
+
31
+ command: str = Field(min_length=1)
32
+ args: dict[str, Any] = Field(default_factory=dict)
33
+ idempotency_key: str | None = None
34
+ command_id: str | None = None
35
+ correlation_id: str | None = None
36
+ integration_id: str | None = None
37
+ config_id: str | None = None
38
+ container_id: str | None = None
39
+ device_id: str | None = None
40
+ entity_id: str | None = None
41
+ capability_id: str | None = None
42
+ run_id: str | None = None
43
+ node_id: str | None = None
44
+
45
+
46
+ class AutomationActionResult(BaseModel):
47
+ """Structured result returned by an automation action handler."""
48
+
49
+ model_config = ConfigDict(extra="forbid")
50
+
51
+ status: Literal["success", "failed"]
52
+ result: dict[str, Any] = Field(default_factory=dict)
53
+ error: str | None = None
54
+ retryable: bool = False
55
+ metadata: dict[str, Any] = Field(default_factory=dict)
56
+ replayed: bool = False
57
+
58
+ @property
59
+ def ok(self) -> bool:
60
+ return self.status == "success"
61
+
62
+ @classmethod
63
+ def success(
64
+ cls,
65
+ result: Mapping[str, Any] | None = None,
66
+ **values: Any,
67
+ ) -> "AutomationActionResult":
68
+ payload = dict(result or {})
69
+ payload.update(values)
70
+ return cls(status="success", result=payload)
71
+
72
+ @classmethod
73
+ def failure(
74
+ cls,
75
+ error: str,
76
+ *,
77
+ retryable: bool = False,
78
+ metadata: Mapping[str, Any] | None = None,
79
+ ) -> "AutomationActionResult":
80
+ return cls(
81
+ status="failed",
82
+ error=str(error),
83
+ retryable=retryable,
84
+ metadata=dict(metadata or {}),
85
+ )
86
+
87
+
88
+ AutomationHandlerValue: TypeAlias = (
89
+ AutomationActionResult | Mapping[str, Any] | None
90
+ )
91
+ AutomationActionHandler: TypeAlias = Callable[
92
+ [AutomationActionRequest],
93
+ AutomationHandlerValue | Awaitable[AutomationHandlerValue],
94
+ ]
95
+
96
+
97
+ class AutomationIdempotencyStore(Protocol):
98
+ """Persistence contract for action results keyed by Core idempotency key."""
99
+
100
+ async def get(self, key: str) -> AutomationActionResult | None: ...
101
+
102
+ async def put(self, key: str, result: AutomationActionResult) -> None: ...
103
+
104
+
105
+ @runtime_checkable
106
+ class AutomationIdempotencyClaimStore(AutomationIdempotencyStore, Protocol):
107
+ """Store that atomically claims keys across processes before side effects."""
108
+
109
+ async def claim(self, key: str) -> bool: ...
110
+
111
+ async def release(self, key: str) -> None: ...
112
+
113
+
114
+ class InMemoryAutomationIdempotencyStore:
115
+ """Process-local idempotency store for development and tests.
116
+
117
+ Production runtimes should provide a durable implementation when an action
118
+ changes external state. Core supplies a stable key, but the runtime owns the
119
+ device-side effect and therefore must persist the final outcome.
120
+ """
121
+
122
+ def __init__(self) -> None:
123
+ self._results: dict[str, AutomationActionResult] = {}
124
+
125
+ async def get(self, key: str) -> AutomationActionResult | None:
126
+ result = self._results.get(key)
127
+ return result.model_copy(deep=True) if result else None
128
+
129
+ async def put(self, key: str, result: AutomationActionResult) -> None:
130
+ self._results[key] = result.model_copy(deep=True)
131
+
132
+
133
+ class SQLiteAutomationIdempotencyStore:
134
+ """Durable, process-safe action claim ledger backed by SQLite.
135
+
136
+ A key is inserted as ``dispatching`` before the handler runs. A second
137
+ process cannot claim the same key. Terminal outcomes are replayed, while a
138
+ surviving ``dispatching`` row is treated as an ambiguous earlier attempt
139
+ and is never silently executed again.
140
+ """
141
+
142
+ def __init__(self, path: str | Path) -> None:
143
+ self.path = str(Path(path).expanduser().resolve())
144
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
145
+ self._initialize()
146
+
147
+ def _connect(self) -> sqlite3.Connection:
148
+ connection = sqlite3.connect(self.path, timeout=5.0)
149
+ connection.execute("PRAGMA busy_timeout = 5000")
150
+ return connection
151
+
152
+ def _initialize(self) -> None:
153
+ with closing(self._connect()) as connection, connection:
154
+ connection.execute(
155
+ """
156
+ CREATE TABLE IF NOT EXISTS automation_action_delivery (
157
+ action_key TEXT PRIMARY KEY,
158
+ status TEXT NOT NULL,
159
+ result_json TEXT,
160
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
161
+ )
162
+ """
163
+ )
164
+
165
+ async def claim(self, key: str) -> bool:
166
+ return await asyncio.to_thread(self._claim_sync, key)
167
+
168
+ def _claim_sync(self, key: str) -> bool:
169
+ with closing(self._connect()) as connection, connection:
170
+ cursor = connection.execute(
171
+ """
172
+ INSERT OR IGNORE INTO automation_action_delivery
173
+ (action_key, status)
174
+ VALUES (?, 'dispatching')
175
+ """,
176
+ (key,),
177
+ )
178
+ return cursor.rowcount == 1
179
+
180
+ async def get(self, key: str) -> AutomationActionResult | None:
181
+ return await asyncio.to_thread(self._get_sync, key)
182
+
183
+ def _get_sync(self, key: str) -> AutomationActionResult | None:
184
+ with closing(self._connect()) as connection:
185
+ row = connection.execute(
186
+ """
187
+ SELECT status, result_json
188
+ FROM automation_action_delivery
189
+ WHERE action_key = ?
190
+ """,
191
+ (key,),
192
+ ).fetchone()
193
+ if row is None:
194
+ return None
195
+ status, result_json = row
196
+ if status == "dispatching":
197
+ return AutomationActionResult.failure(
198
+ "An earlier action attempt may still have reached the device; manual review is required",
199
+ retryable=False,
200
+ metadata={"delivery_status": "ambiguous"},
201
+ )
202
+ if not result_json:
203
+ return None
204
+ return AutomationActionResult.model_validate_json(result_json)
205
+
206
+ async def put(self, key: str, result: AutomationActionResult) -> None:
207
+ await asyncio.to_thread(self._put_sync, key, result)
208
+
209
+ def _put_sync(self, key: str, result: AutomationActionResult) -> None:
210
+ with closing(self._connect()) as connection, connection:
211
+ connection.execute(
212
+ """
213
+ INSERT INTO automation_action_delivery
214
+ (action_key, status, result_json, updated_at)
215
+ VALUES (?, 'terminal', ?, CURRENT_TIMESTAMP)
216
+ ON CONFLICT(action_key) DO UPDATE SET
217
+ status = 'terminal',
218
+ result_json = excluded.result_json,
219
+ updated_at = CURRENT_TIMESTAMP
220
+ """,
221
+ (key, result.model_dump_json()),
222
+ )
223
+
224
+ async def release(self, key: str) -> None:
225
+ await asyncio.to_thread(self._release_sync, key)
226
+
227
+ def _release_sync(self, key: str) -> None:
228
+ with closing(self._connect()) as connection, connection:
229
+ connection.execute(
230
+ """
231
+ DELETE FROM automation_action_delivery
232
+ WHERE action_key = ? AND status = 'dispatching'
233
+ """,
234
+ (key,),
235
+ )
236
+
237
+
238
+ @dataclass(frozen=True, slots=True)
239
+ class AutomationActionDefinition:
240
+ command: str
241
+ label: str
242
+ parameter_schema: Mapping[str, Any]
243
+ result_schema: Mapping[str, Any]
244
+
245
+
246
+ @dataclass(frozen=True, slots=True)
247
+ class AutomationEventDefinition:
248
+ event_type: str
249
+ label: str
250
+ data_schema: Mapping[str, Any]
251
+
252
+
253
+ @dataclass(frozen=True, slots=True)
254
+ class AutomationContractReport:
255
+ missing_action_handlers: tuple[str, ...]
256
+ missing_event_publishers: tuple[str, ...]
257
+ undeclared_action_handlers: tuple[str, ...]
258
+ undeclared_event_publishers: tuple[str, ...]
259
+
260
+ @property
261
+ def ok(self) -> bool:
262
+ return not any(
263
+ (
264
+ self.missing_action_handlers,
265
+ self.missing_event_publishers,
266
+ self.undeclared_action_handlers,
267
+ self.undeclared_event_publishers,
268
+ )
269
+ )
270
+
271
+ def messages(self) -> tuple[str, ...]:
272
+ groups = (
273
+ ("Missing action handlers", self.missing_action_handlers),
274
+ ("Missing event publishers", self.missing_event_publishers),
275
+ ("Handlers absent from behaviors.json", self.undeclared_action_handlers),
276
+ ("Events absent from behaviors.json", self.undeclared_event_publishers),
277
+ )
278
+ return tuple(f"{label}: {', '.join(values)}" for label, values in groups if values)
279
+
280
+
281
+ class AutomationRegistry:
282
+ """Registry for typed runtime action handlers and event definitions."""
283
+
284
+ def __init__(
285
+ self,
286
+ *,
287
+ idempotency_store: AutomationIdempotencyStore | None = None,
288
+ ) -> None:
289
+ self.idempotency_store = idempotency_store
290
+ self._actions: dict[str, tuple[AutomationActionDefinition, AutomationActionHandler]] = {}
291
+ self._events: dict[str, AutomationEventDefinition] = {}
292
+ self._key_locks: dict[str, asyncio.Lock] = {}
293
+
294
+ @property
295
+ def action_definitions(self) -> tuple[AutomationActionDefinition, ...]:
296
+ return tuple(item[0] for item in self._actions.values())
297
+
298
+ @property
299
+ def event_definitions(self) -> tuple[AutomationEventDefinition, ...]:
300
+ return tuple(self._events.values())
301
+
302
+ def action(
303
+ self,
304
+ command: str,
305
+ *,
306
+ label: str | None = None,
307
+ parameter_schema: Mapping[str, Any] | None = None,
308
+ result_schema: Mapping[str, Any] | None = None,
309
+ ) -> Callable[[AutomationActionHandler], AutomationActionHandler]:
310
+ """Register an action handler while preserving its builder schemas."""
311
+ normalized = str(command or "").strip()
312
+ if not normalized:
313
+ raise ValueError("Automation action command cannot be empty")
314
+
315
+ def decorator(handler: AutomationActionHandler) -> AutomationActionHandler:
316
+ if normalized in self._actions:
317
+ raise ValueError(f"Automation action '{normalized}' is already registered")
318
+ definition = AutomationActionDefinition(
319
+ command=normalized,
320
+ label=str(label or normalized).strip(),
321
+ parameter_schema=dict(parameter_schema or {}),
322
+ result_schema=dict(result_schema or {}),
323
+ )
324
+ self._actions[normalized] = (definition, handler)
325
+ return handler
326
+
327
+ return decorator
328
+
329
+ def event(
330
+ self,
331
+ event_type: str,
332
+ *,
333
+ label: str | None = None,
334
+ data_schema: Mapping[str, Any] | None = None,
335
+ ) -> AutomationEventDefinition:
336
+ """Declare an event the integration may publish."""
337
+ normalized = str(event_type or "").strip()
338
+ if not normalized:
339
+ raise ValueError("Automation event type cannot be empty")
340
+ if normalized in self._events:
341
+ raise ValueError(f"Automation event '{normalized}' is already registered")
342
+ definition = AutomationEventDefinition(
343
+ event_type=normalized,
344
+ label=str(label or normalized).strip(),
345
+ data_schema=dict(data_schema or {}),
346
+ )
347
+ self._events[normalized] = definition
348
+ return definition
349
+
350
+ async def dispatch(
351
+ self,
352
+ request: AutomationActionRequest | Mapping[str, Any],
353
+ *,
354
+ idempotency_key: str | None = None,
355
+ ) -> AutomationActionResult:
356
+ """Validate and dispatch an action, replaying durable terminal outcomes."""
357
+ action_request = (
358
+ request
359
+ if isinstance(request, AutomationActionRequest)
360
+ else AutomationActionRequest.model_validate(request)
361
+ )
362
+ if idempotency_key:
363
+ action_request = action_request.model_copy(
364
+ update={"idempotency_key": str(idempotency_key).strip() or None}
365
+ )
366
+ registered = self._actions.get(action_request.command)
367
+ if registered is None:
368
+ return AutomationActionResult.failure(
369
+ f"Unknown automation action '{action_request.command}'",
370
+ retryable=False,
371
+ )
372
+
373
+ scoped_key = (
374
+ f"{action_request.command}:{action_request.idempotency_key}"
375
+ if action_request.idempotency_key
376
+ else ""
377
+ )
378
+ if scoped_key and self.idempotency_store is not None:
379
+ lock = self._key_locks.setdefault(scoped_key, asyncio.Lock())
380
+ async with lock:
381
+ cached = await self.idempotency_store.get(scoped_key)
382
+ if cached is not None:
383
+ return cached.model_copy(update={"replayed": True})
384
+ claim_store = (
385
+ self.idempotency_store
386
+ if isinstance(
387
+ self.idempotency_store,
388
+ AutomationIdempotencyClaimStore,
389
+ )
390
+ else None
391
+ )
392
+ if claim_store is not None and not await claim_store.claim(scoped_key):
393
+ claimed_result = await claim_store.get(scoped_key)
394
+ return (
395
+ claimed_result.model_copy(update={"replayed": True})
396
+ if claimed_result is not None
397
+ else AutomationActionResult.failure(
398
+ "The action idempotency key is already being processed",
399
+ retryable=False,
400
+ metadata={"delivery_status": "ambiguous"},
401
+ )
402
+ )
403
+ result = await self._invoke(registered[1], action_request)
404
+ if result.ok or not result.retryable:
405
+ await self.idempotency_store.put(scoped_key, result)
406
+ elif claim_store is not None:
407
+ await claim_store.release(scoped_key)
408
+ return result
409
+ return await self._invoke(registered[1], action_request)
410
+
411
+ async def _invoke(
412
+ self,
413
+ handler: AutomationActionHandler,
414
+ request: AutomationActionRequest,
415
+ ) -> AutomationActionResult:
416
+ try:
417
+ candidate = handler(request)
418
+ value = await candidate if isinstance(candidate, Awaitable) else candidate
419
+ if isinstance(value, AutomationActionResult):
420
+ return value
421
+ if value is None:
422
+ return AutomationActionResult.success()
423
+ if isinstance(value, Mapping):
424
+ return AutomationActionResult.success(value)
425
+ return AutomationActionResult.failure(
426
+ "Automation handler returned an unsupported result type"
427
+ )
428
+ except Exception as exc:
429
+ return AutomationActionResult.failure(str(exc), retryable=False)
430
+
431
+ def contract_snapshot(self) -> dict[str, Any]:
432
+ """Return stable JSON data for conformance snapshots and tooling."""
433
+ return {
434
+ "actions": [
435
+ {
436
+ "command": item.command,
437
+ "label": item.label,
438
+ "parameter_schema": dict(item.parameter_schema),
439
+ "result_schema": dict(item.result_schema),
440
+ }
441
+ for item in self.action_definitions
442
+ ],
443
+ "events": [
444
+ {
445
+ "event_type": item.event_type,
446
+ "label": item.label,
447
+ "data_schema": dict(item.data_schema),
448
+ }
449
+ for item in self.event_definitions
450
+ ],
451
+ }
452
+
453
+
454
+ @dataclass(slots=True)
455
+ class AutomationClient:
456
+ """High-level automation event publisher backed by the existing EventClient."""
457
+
458
+ event_client: EventClient
459
+ auth_context: RuntimeAuthContext
460
+ registry: AutomationRegistry | None = None
461
+
462
+ async def emit(
463
+ self,
464
+ event_type: str,
465
+ *,
466
+ device: RuntimeDeviceRef | Mapping[str, Any],
467
+ data: Mapping[str, Any] | None = None,
468
+ source: str | None = None,
469
+ severity: EventSeverity | str = EventSeverity.info,
470
+ event_id: str | None = None,
471
+ ) -> bool:
472
+ if self.registry is not None and event_type not in {
473
+ item.event_type for item in self.registry.event_definitions
474
+ }:
475
+ raise ValueError(f"Automation event '{event_type}' is not registered")
476
+ return await self.event_client.send_device_event(
477
+ device=device,
478
+ event_type=event_type,
479
+ auth_context=self.auth_context,
480
+ payload=data,
481
+ source=source,
482
+ severity=severity,
483
+ event_id=event_id,
484
+ )
485
+
486
+
487
+ def build_mock_automation_event(
488
+ event_type: str,
489
+ *,
490
+ data: Mapping[str, Any] | None = None,
491
+ integration_id: str = "mock-integration",
492
+ config_id: str = "mock-config",
493
+ container_id: str = "mock-container",
494
+ device_id: str = "mock-device",
495
+ event_id: str | None = None,
496
+ ) -> CoreEventPayload:
497
+ """Create a canonical, non-dispatched event fixture for automated tests."""
498
+ return build_core_event_payload(
499
+ event_type=event_type,
500
+ integration_id=integration_id,
501
+ config_id=config_id,
502
+ container_id=container_id,
503
+ device_id=device_id,
504
+ payload=data,
505
+ source="automation-sdk-mock",
506
+ event_id=event_id,
507
+ )
508
+
509
+
510
+ def audit_behaviors_contract(
511
+ behaviors: Mapping[str, Any],
512
+ registry: AutomationRegistry,
513
+ ) -> AutomationContractReport:
514
+ """Compare runtime registrations with commands/events in ``behaviors.json``."""
515
+ declared_actions: set[str] = set()
516
+ declared_events: set[str] = set()
517
+ devices = behaviors.get("devices", [])
518
+ if not isinstance(devices, list):
519
+ devices = []
520
+ for device in devices:
521
+ if not isinstance(device, Mapping):
522
+ continue
523
+ actions = device.get("actions", [])
524
+ triggers = device.get("triggers", [])
525
+ for action in actions if isinstance(actions, list) else []:
526
+ if not isinstance(action, Mapping):
527
+ continue
528
+ runtime = action.get("runtime")
529
+ if isinstance(runtime, Mapping) and runtime.get("command"):
530
+ declared_actions.add(str(runtime["command"]).strip())
531
+ for trigger in triggers if isinstance(triggers, list) else []:
532
+ if not isinstance(trigger, Mapping):
533
+ continue
534
+ runtime = trigger.get("runtime")
535
+ if isinstance(runtime, Mapping) and runtime.get("event"):
536
+ declared_events.add(str(runtime["event"]).strip())
537
+
538
+ registered_actions = {item.command for item in registry.action_definitions}
539
+ registered_events = {item.event_type for item in registry.event_definitions}
540
+ return AutomationContractReport(
541
+ missing_action_handlers=tuple(sorted(declared_actions - registered_actions)),
542
+ missing_event_publishers=tuple(sorted(declared_events - registered_events)),
543
+ undeclared_action_handlers=tuple(sorted(registered_actions - declared_actions)),
544
+ undeclared_event_publishers=tuple(sorted(registered_events - declared_events)),
545
+ )
546
+
547
+
548
+ def assert_behaviors_contract(
549
+ behaviors: Mapping[str, Any],
550
+ registry: AutomationRegistry,
551
+ ) -> None:
552
+ report = audit_behaviors_contract(behaviors, registry)
553
+ if not report.ok:
554
+ raise ValueError("; ".join(report.messages()))
@@ -1,12 +1,14 @@
1
1
  """Convenience re-exports for the optional FastAPI adapter layer."""
2
2
 
3
3
  from .adapters.fastapi import (
4
+ dispatch_automation_action_from_fastapi,
4
5
  get_payload_container_id,
5
6
  sync_runtime_auth_from_fastapi_payload,
6
7
  sync_runtime_auth_from_fastapi_request,
7
8
  )
8
9
 
9
10
  __all__ = [
11
+ "dispatch_automation_action_from_fastapi",
10
12
  "get_payload_container_id",
11
13
  "sync_runtime_auth_from_fastapi_payload",
12
14
  "sync_runtime_auth_from_fastapi_request",
@@ -114,11 +114,21 @@ class IntegrationDiscoveryResponse(BaseModel):
114
114
  class IntegrationCommandRequest(BaseModel):
115
115
  """Standard command payload forwarded from PiPhi Core to a runtime."""
116
116
 
117
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
118
+
117
119
  command: str
118
120
  capability_id: str | None = None
119
121
  device_id: str | None = None
120
122
  entity_id: str | None = None
121
123
  args: dict[str, Any] = Field(default_factory=dict)
124
+ idempotency_key: str | None = None
125
+ command_id: str | None = None
126
+ correlation_id: str | None = None
127
+ integration_id: str | None = None
128
+ config_id: str | None = None
129
+ container_id: str | None = None
130
+ run_id: str | None = None
131
+ node_id: str | None = None
122
132
 
123
133
 
124
134
  class RuntimeEntityDashboardResponse(BaseModel):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: piphi-runtime-kit-python
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: PiPhi Network runtime integration helpers
5
5
  Keywords: piphi,runtime,integration,iot
6
6
  Author-Email: KelvinSan <support@piphi.network>
@@ -34,7 +34,7 @@ plumbing without hiding the HTTP contract behind a large framework. The goal
34
34
  is to help developers move faster while still understanding what their runtime
35
35
  is doing.
36
36
 
37
- Version `0.6.0` is the current documented baseline.
37
+ Version `0.7.1` is the current documented baseline.
38
38
 
39
39
  > New to PiPhi? Start with [First 15 Minutes](#first-15-minutes), then read [The Golden Path](#the-golden-path), then compare your code to the example app.
40
40
 
@@ -95,6 +95,8 @@ The SDK is meant to own the shared PiPhi runtime plumbing:
95
95
  - discovery normalization and response helpers
96
96
  - lifecycle bootstrap helpers
97
97
  - runtime health and diagnostics helpers
98
+ - typed automation actions and event publishers
99
+ - automation idempotency propagation and contract auditing
98
100
  - in-memory runtime registry for active entries, state, and recent events
99
101
  - clearer PiPhi-specific delivery errors
100
102
 
@@ -167,6 +169,51 @@ The SDK remains intentionally thin: it validates and sends the data, while
167
169
  PiPhi Core owns state-change derivation, persistence policy, and automation
168
170
  semantics.
169
171
 
172
+ ## Automation SDK
173
+
174
+ `behaviors.json` and the Automation SDK solve two different halves of the same
175
+ integration contract:
176
+
177
+ - `behaviors.json` is the declarative catalog. It tells Core and the builders
178
+ which triggers and actions exist, how they are labeled, and which fields the
179
+ user can configure.
180
+ - the Automation SDK is executable runtime code. It publishes declared events,
181
+ receives action commands, validates their context, invokes the correct
182
+ handler, and preserves Core's idempotency key.
183
+
184
+ Declare the user-facing card in `behaviors.json`, then implement its runtime
185
+ command with `AutomationRegistry`:
186
+
187
+ ```python
188
+ from piphi_runtime_kit_python import (
189
+ AutomationActionRequest,
190
+ AutomationRegistry,
191
+ SQLiteAutomationIdempotencyStore,
192
+ )
193
+
194
+ automations = AutomationRegistry(
195
+ idempotency_store=SQLiteAutomationIdempotencyStore(
196
+ "/data/piphi/automation-actions.sqlite3"
197
+ ),
198
+ )
199
+
200
+ @automations.action("set_fan_speed", label="Set fan speed")
201
+ async def set_fan_speed(request: AutomationActionRequest) -> dict[str, int]:
202
+ speed = int(request.args["speed"])
203
+ await vendor_client.set_speed(request.device_id, speed)
204
+ return {"speed": speed}
205
+ ```
206
+
207
+ Core supplies a stable idempotency key for each action. Integrations that
208
+ change a device or external service must persist terminal results in a durable
209
+ `AutomationIdempotencyStore`. The SQLite implementation atomically claims keys
210
+ across local runtime processes; the in-memory store is deliberately for tests
211
+ and local development only.
212
+
213
+ For the complete FastAPI wiring, event publishing, contract audit, and mock
214
+ event testing patterns, read [Building automation-capable integrations](docs/automations.md)
215
+ and run the [automation runtime example](examples/automation_runtime/app.py).
216
+
170
217
  ## Install
171
218
 
172
219
  The package currently supports Python `>=3.11`.
@@ -1,11 +1,12 @@
1
- piphi_runtime_kit_python-0.6.0.dist-info/METADATA,sha256=pIgtdp6nS2WlVavrZ8OAAqSkEtrpMWhR-EsUqv3ArUY,34631
2
- piphi_runtime_kit_python-0.6.0.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
3
- piphi_runtime_kit_python-0.6.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
- piphi_runtime_kit_python-0.6.0.dist-info/licenses/LICENSE,sha256=pS6szQI4X7qAin1ODdoDp-XwZvsFy6ZZ28-JAwGHgzo,1070
5
- piphi_runtime_kit_python/__init__.py,sha256=GpYxQ9cCRjLa8tZ6fxAfn407M0-R-Xbt5oHk4WqnBQ8,7247
1
+ piphi_runtime_kit_python-0.7.1.dist-info/METADATA,sha256=tGubJnlnCSLE74rqkMbf7TL9tVLVrUEvvEDYA1GEXH8,36501
2
+ piphi_runtime_kit_python-0.7.1.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
3
+ piphi_runtime_kit_python-0.7.1.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
+ piphi_runtime_kit_python-0.7.1.dist-info/licenses/LICENSE,sha256=pS6szQI4X7qAin1ODdoDp-XwZvsFy6ZZ28-JAwGHgzo,1070
5
+ piphi_runtime_kit_python/__init__.py,sha256=c0UP0yoq03cdAvd-o-KzlGjA2CmxruuDGQFZ9fQKScY,8178
6
6
  piphi_runtime_kit_python/adapters/__init__.py,sha256=3jBhPZSBN3Qo7MKHlxAol2jPU3cK23BXHR2oTlircsw,361
7
- piphi_runtime_kit_python/adapters/fastapi.py,sha256=hpTWnWKYWsNuCzXm-NGdqO8IwARrcQkp6cTuwOyE6O4,2098
8
- piphi_runtime_kit_python/fastapi.py,sha256=OZ-ds2-g32kB4YkF90D5bM4vVVjpOWPtrNC-WdhDNyo,361
7
+ piphi_runtime_kit_python/adapters/fastapi.py,sha256=6sAEqJLKM_p4CYt3WB5VLsylXlyhe4v3GxBQawO4rps,3078
8
+ piphi_runtime_kit_python/automations.py,sha256=wA5BMG-4Zkv1rjE0MKVcW6tPx4DIhz3vHKoVvmBGMO4,20489
9
+ piphi_runtime_kit_python/fastapi.py,sha256=xvHU19-CVDPKjmXW6nW72mpdbJEOLZzT8Twal7lCCkc,453
9
10
  piphi_runtime_kit_python/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
10
11
  piphi_runtime_kit_python/runtime/__init__.py,sha256=cVJj6eniXw9oM_H795vVhVyI1c_dYrcZ-eYQgbT4LE4,4081
11
12
  piphi_runtime_kit_python/runtime/auth.py,sha256=qKKkAK4RB-WFPyjQAoXp3t8sQuLDEPb-DR5TTe_OSnA,4702
@@ -25,5 +26,5 @@ piphi_runtime_kit_python/runtime/starter.py,sha256=D_mCuwiQl6xLLKQHjf963tKB2EUdm
25
26
  piphi_runtime_kit_python/runtime/state.py,sha256=OIwVxAzX2ErsZ2XdrWlIP5r3GHHub5y9PDtoqNDlwkQ,952
26
27
  piphi_runtime_kit_python/runtime/tasks.py,sha256=E9WoE75NQiJp3buJYexysl3NuP8HJHJ3KfaGVKLdiw0,3238
27
28
  piphi_runtime_kit_python/runtime/telemetry.py,sha256=vV8f1SY4zD-YjVNZWGl3UdCAEYYxFzaPiSNJ7iFBDNI,6729
28
- piphi_runtime_kit_python/schemas.py,sha256=p1GsExPvhIbXWb8GET0n6WhXa91ujEhASDwwII07n-I,10110
29
- piphi_runtime_kit_python-0.6.0.dist-info/RECORD,,
29
+ piphi_runtime_kit_python/schemas.py,sha256=Dq6PVJxA2rOIt9rEKopHlmRfZujpnVfZIWYzW1uc3iw,10458
30
+ piphi_runtime_kit_python-0.7.1.dist-info/RECORD,,