dosync 0.4.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.
- dosync/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
dosync/models.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Core data models (Layers 3–5)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
import time
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Enumerations ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
class Urgency(str, Enum):
|
|
17
|
+
"""Execution priority of an Intent. Determines policy bypass behaviour."""
|
|
18
|
+
INFO = "info"
|
|
19
|
+
WARNING = "warning"
|
|
20
|
+
ALERT = "alert"
|
|
21
|
+
EMERGENCY = "emergency"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Severity(str, Enum):
|
|
25
|
+
"""Observable severity of a DeviceEvent or EventSpec condition.
|
|
26
|
+
Intentionally separate from Urgency: a temperature anomaly may have
|
|
27
|
+
severity=warning (the condition is notable) while the resulting intent
|
|
28
|
+
carries urgency=emergency (act immediately). Conflating the two
|
|
29
|
+
prevents expressing "low-severity emergency" or "high-severity routine"."""
|
|
30
|
+
INFO = "info"
|
|
31
|
+
WARNING = "warning"
|
|
32
|
+
ALERT = "alert"
|
|
33
|
+
EMERGENCY = "emergency"
|
|
34
|
+
|
|
35
|
+
class DeviceCategory(str, Enum):
|
|
36
|
+
SENSOR = "sensor"
|
|
37
|
+
ACTUATOR = "actuator"
|
|
38
|
+
HYBRID = "hybrid"
|
|
39
|
+
COMMUNICATION = "communication"
|
|
40
|
+
EMERGENCY = "emergency"
|
|
41
|
+
CONTEXT = "context" # contributes to high-level occupancy inference
|
|
42
|
+
|
|
43
|
+
class CertTier(str, Enum):
|
|
44
|
+
BASIC = "basic" # Layers 1-3
|
|
45
|
+
STANDARD = "standard" # Layers 1-4
|
|
46
|
+
EMERGENCY = "emergency" # All layers + override
|
|
47
|
+
|
|
48
|
+
class FailurePolicy(str, Enum):
|
|
49
|
+
"""
|
|
50
|
+
Define el comportamiento del executor cuando una acción falla.
|
|
51
|
+
|
|
52
|
+
CONTINUE — comportamiento actual: continúa con las acciones restantes
|
|
53
|
+
ABORT — detiene las acciones pendientes al primer fallo
|
|
54
|
+
RETRY — reintenta la acción fallida N veces antes de continuar
|
|
55
|
+
"""
|
|
56
|
+
CONTINUE = "continue"
|
|
57
|
+
ABORT = "abort"
|
|
58
|
+
RETRY = "retry"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IntentClass(str):
|
|
62
|
+
"""
|
|
63
|
+
Open string type for DoSync intent classes.
|
|
64
|
+
|
|
65
|
+
The protocol defines the FORMAT of an intent class name, not its vocabulary.
|
|
66
|
+
Any string matching ^[a-z][a-z0-9_]*$ is a valid intent class.
|
|
67
|
+
|
|
68
|
+
Five universal intents are seeded into every hub at init time.
|
|
69
|
+
Domain-specific intents (healthcare, retail, industrial, residential)
|
|
70
|
+
are registered via POST /v1/intent-classes — no code changes required.
|
|
71
|
+
|
|
72
|
+
.value returns self (str) for compatibility with Enum-style .value access.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
_PATTERN = __import__('re').compile(r'^[a-z][a-z0-9_]*$')
|
|
76
|
+
|
|
77
|
+
def __new__(cls, value: str) -> 'IntentClass':
|
|
78
|
+
if not cls._PATTERN.match(str(value)):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Invalid intent class name '{value}'. "
|
|
81
|
+
"Must match ^[a-z][a-z0-9_]*$ "
|
|
82
|
+
"(lowercase letters, digits, underscores only)"
|
|
83
|
+
)
|
|
84
|
+
return super().__new__(cls, value)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def value(self) -> str:
|
|
88
|
+
"""Compatibility with Enum-style .value access."""
|
|
89
|
+
return str(self)
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
return f"IntentClass({str(self)!r})"
|
|
93
|
+
|
|
94
|
+
def __eq__(self, other) -> bool:
|
|
95
|
+
return str(self) == str(other)
|
|
96
|
+
|
|
97
|
+
def __hash__(self) -> int:
|
|
98
|
+
return hash(str(self))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Universal intent classes — seeded at hub init ─────────────────────────────
|
|
102
|
+
# These five are the only intents defined at the PROTOCOL level.
|
|
103
|
+
# They represent concepts valid in any physical environment regardless of domain.
|
|
104
|
+
# All other intents (residential, healthcare, industrial, etc.) are registered
|
|
105
|
+
# via POST /v1/intent-classes without any code changes.
|
|
106
|
+
|
|
107
|
+
IntentClass.ENSURE_SAFETY = IntentClass("ensure_safety") # Safety emergency
|
|
108
|
+
IntentClass.ALERT_ANOMALY = IntentClass("alert_anomaly") # Unexpected condition
|
|
109
|
+
IntentClass.CONTROL_ACCESS = IntentClass("control_access") # Physical access control
|
|
110
|
+
IntentClass.REPORT_STATUS = IntentClass("report_status") # Status report
|
|
111
|
+
IntentClass.NOTIFY = IntentClass("notify") # Push information
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ContextSignalType(str, Enum):
|
|
115
|
+
"""Type of inference contributed by a context provider."""
|
|
116
|
+
PRESENCE = "presence" # whether someone is home
|
|
117
|
+
LOCATION = "location" # GPS location of a household member
|
|
118
|
+
SLEEP = "sleep" # sleep state
|
|
119
|
+
HEALTH = "health" # vital signs, physical activity
|
|
120
|
+
ROUTINE = "routine" # daily routine pattern
|
|
121
|
+
VEHICLE = "vehicle" # vehicle / garage state
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Capability manifest (Layer 3) ─────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class SensorSpec:
|
|
128
|
+
id: str
|
|
129
|
+
type: str # "temperature" | "boolean" | "motion" | etc.
|
|
130
|
+
description: str = ""
|
|
131
|
+
unit: Optional[str] = None
|
|
132
|
+
range: Optional[list[float]] = None
|
|
133
|
+
poll_interval_ms: int = 30_000
|
|
134
|
+
# ── SENSOR-KIND (panel 2026-07-14, shipped 2026-07-17) ───────────────────
|
|
135
|
+
# Not all sensors are alike, and collapsing them made report_status
|
|
136
|
+
# genuinely ambiguous: a DHT measuring the room and a lamp reporting its own
|
|
137
|
+
# brightness both "sense", but "read the environment" and "read every
|
|
138
|
+
# device's self-state" are different questions. The kind distinguishes them
|
|
139
|
+
# WITHOUT hiding anything — a lamp's brightness is real telemetry and stays
|
|
140
|
+
# declared (hiding it would be the TV mistake: mutilating truth to encode a
|
|
141
|
+
# preference).
|
|
142
|
+
# "environment" — measures the world: temperature, motion, humidity, sun.
|
|
143
|
+
# "device_state" — reports the device's own condition: brightness, on/off,
|
|
144
|
+
# position, a setpoint.
|
|
145
|
+
# Default "environment" keeps every existing manifest byte-for-byte valid.
|
|
146
|
+
# The grain is PER SENSOR, deliberately: a thermostat's current_temp
|
|
147
|
+
# measures the room (environment) while its target_temp is a setpoint
|
|
148
|
+
# (device_state) — a per-device rule would misclassify one of them.
|
|
149
|
+
kind: str = "environment"
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class ActuatorSpec:
|
|
153
|
+
id: str
|
|
154
|
+
type: str # "lock" | "light" | "unlock" | etc.
|
|
155
|
+
description: str = ""
|
|
156
|
+
params_schema: dict = field(default_factory=dict)
|
|
157
|
+
# ── Execution model (orthogonal to params_schema) ─────────────────────────
|
|
158
|
+
# "instant" (default): fire-and-result, exactly as every actuator works today.
|
|
159
|
+
# "long_running": the action takes time and has a lifecycle (see operations.py).
|
|
160
|
+
# The default keeps every existing manifest byte-for-byte compatible.
|
|
161
|
+
execution_model: str = "instant"
|
|
162
|
+
# Optional richness flags — only meaningful when execution_model == "long_running".
|
|
163
|
+
# A simple long-running device (e.g. an oven) declares none of these; a drone
|
|
164
|
+
# declares all three. The device declares how rich it is; the hub adapts.
|
|
165
|
+
supports_progress: bool = False # hub can query intermediate progress
|
|
166
|
+
supports_cancel: bool = False # the operation can be cancelled
|
|
167
|
+
emits_telemetry: bool = False # device streams telemetry → enables sub-states + reconciliation
|
|
168
|
+
# ── INDEPENDENT-OBSERVATION (panel design 2026-07-21) ─────────────────────
|
|
169
|
+
# The manufacturer's NATURAL pairing: which sensor confirms this actuator's
|
|
170
|
+
# effect (e.g. a lock's own bolt_position). Optional — most actuators declare
|
|
171
|
+
# none. A deployment can add or override a CROSS-DEVICE binding on the intent
|
|
172
|
+
# (the lock from vendor A verified by vendor B's door sensor), which wins:
|
|
173
|
+
# the manufacturer cannot know sensors it does not ship with.
|
|
174
|
+
verify_with: Optional["VerifyBinding"] = None
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class EventSpec:
|
|
178
|
+
"""Severity of a device event condition (separate from Intent urgency)."""
|
|
179
|
+
id: str
|
|
180
|
+
severity: Severity
|
|
181
|
+
description: str = ""
|
|
182
|
+
|
|
183
|
+
def __post_init__(self):
|
|
184
|
+
# Auto-coerce string → Severity enum (FastAPI parses dataclass fields as raw strings)
|
|
185
|
+
if isinstance(self.severity, str):
|
|
186
|
+
self.severity = Severity(self.severity)
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class ContextSignal:
|
|
190
|
+
"""
|
|
191
|
+
Declara que este dispositivo contribuye a inferencias de contexto.
|
|
192
|
+
Ejemplo: un smartwatch contribuye a PRESENCE y HEALTH.
|
|
193
|
+
"""
|
|
194
|
+
type: ContextSignalType
|
|
195
|
+
description: str = ""
|
|
196
|
+
confidence_weight: float = 1.0 # relative weight in occupancy inference (0.0-1.0)
|
|
197
|
+
# phone GPS weighs more than a PIR for presence
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class CapabilityManifest:
|
|
201
|
+
device_id: str
|
|
202
|
+
device_name: str
|
|
203
|
+
manufacturer: str
|
|
204
|
+
model: str
|
|
205
|
+
firmware: str
|
|
206
|
+
category: DeviceCategory
|
|
207
|
+
tags: list[str]
|
|
208
|
+
sensors: list[SensorSpec] = field(default_factory=list)
|
|
209
|
+
actuators: list[ActuatorSpec] = field(default_factory=list)
|
|
210
|
+
events: list[EventSpec] = field(default_factory=list)
|
|
211
|
+
context_signals: list[ContextSignal] = field(default_factory=list)
|
|
212
|
+
emergency_capable: bool = False
|
|
213
|
+
cert_tier: CertTier = CertTier.BASIC
|
|
214
|
+
dosync_version: str = "0.1"
|
|
215
|
+
adapter: Optional[str] = None
|
|
216
|
+
adapter_config: dict = field(default_factory=dict)
|
|
217
|
+
|
|
218
|
+
def to_dict(self) -> dict:
|
|
219
|
+
d = {
|
|
220
|
+
"dosync_version": self.dosync_version,
|
|
221
|
+
"device_id": self.device_id,
|
|
222
|
+
"device_name": self.device_name,
|
|
223
|
+
"manufacturer": self.manufacturer,
|
|
224
|
+
"model": self.model,
|
|
225
|
+
"firmware": self.firmware,
|
|
226
|
+
"category": self.category.value,
|
|
227
|
+
"tags": self.tags,
|
|
228
|
+
"capabilities": {
|
|
229
|
+
"sensors": [s.__dict__ for s in self.sensors],
|
|
230
|
+
"actuators": [a.__dict__ for a in self.actuators],
|
|
231
|
+
"events": [{**e.__dict__, "severity": e.severity.value if hasattr(e.severity, "value") else e.severity}
|
|
232
|
+
for e in self.events],
|
|
233
|
+
"context_signals": [
|
|
234
|
+
{**c.__dict__, "type": c.type.value}
|
|
235
|
+
for c in self.context_signals
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
"emergency_capable": self.emergency_capable,
|
|
239
|
+
"cert_tier": self.cert_tier.value,
|
|
240
|
+
}
|
|
241
|
+
if self.adapter:
|
|
242
|
+
d["adapter"] = self.adapter
|
|
243
|
+
d["adapter_config"] = self.adapter_config
|
|
244
|
+
return d
|
|
245
|
+
|
|
246
|
+
def to_public_dict(self) -> dict:
|
|
247
|
+
"""
|
|
248
|
+
Serialize manifest for public API responses.
|
|
249
|
+
|
|
250
|
+
Identical to to_dict() but excludes adapter_config, which contains
|
|
251
|
+
sensitive internal routing information (IP addresses, ports, tokens).
|
|
252
|
+
Clients interact with the hub via the semantic intent layer and never
|
|
253
|
+
need to know the physical address of a device.
|
|
254
|
+
|
|
255
|
+
Use to_dict() for internal operations (DB persistence, adapter routing).
|
|
256
|
+
Use to_public_dict() for all GET /v1/devices/* API responses.
|
|
257
|
+
"""
|
|
258
|
+
d = self.to_dict()
|
|
259
|
+
d.pop("adapter_config", None)
|
|
260
|
+
return d
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── Context model (occupancy inference and routines) ────────────────────────────
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class PresenceSignal:
|
|
267
|
+
"""
|
|
268
|
+
A single presence signal emitted by a context provider.
|
|
269
|
+
The hub aggregates them to infer home occupancy state.
|
|
270
|
+
"""
|
|
271
|
+
device_id: str
|
|
272
|
+
signal_type: ContextSignalType
|
|
273
|
+
present: bool # True = presence detected
|
|
274
|
+
confidence: float # 0.0-1.0
|
|
275
|
+
member_id: Optional[str] = None # which household member this signal belongs to
|
|
276
|
+
timestamp: float = field(default_factory=time.time)
|
|
277
|
+
|
|
278
|
+
@dataclass
|
|
279
|
+
class OccupancyState:
|
|
280
|
+
"""
|
|
281
|
+
Estado inferido de ocupacion del hogar, calculado por el hub
|
|
282
|
+
a partir de multiples PresenceSignals.
|
|
283
|
+
"""
|
|
284
|
+
occupied: bool
|
|
285
|
+
confidence: float # 0.0-1.0
|
|
286
|
+
members_home: list[str] # IDs of household members currently detected home
|
|
287
|
+
signals_used: int # how many signals contributed to this state
|
|
288
|
+
last_updated: float = field(default_factory=time.time)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ── Intent (Layer 5) ──────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
@dataclass
|
|
294
|
+
class Intent:
|
|
295
|
+
intent: IntentClass
|
|
296
|
+
context: dict[str, Any]
|
|
297
|
+
urgency: Urgency = Urgency.INFO
|
|
298
|
+
subject: Optional[str] = None
|
|
299
|
+
source: str = "api" # who fired this intent: "api" | "mcp" | "hub" | "scheduler" | "gpio" | "recovery"
|
|
300
|
+
constraints: dict[str, Any] = field(default_factory=lambda: {
|
|
301
|
+
"timeout_ms": 10_000,
|
|
302
|
+
"require_confirmation": False,
|
|
303
|
+
})
|
|
304
|
+
intent_id: str = field(
|
|
305
|
+
default_factory=lambda: f"int-{int(time.time())}-{uuid.uuid4().hex[:6]}"
|
|
306
|
+
)
|
|
307
|
+
timestamp: float = field(default_factory=time.time)
|
|
308
|
+
|
|
309
|
+
def to_dict(self) -> dict:
|
|
310
|
+
return {
|
|
311
|
+
"intent_id": self.intent_id,
|
|
312
|
+
"intent": self.intent.value,
|
|
313
|
+
"subject": self.subject,
|
|
314
|
+
"urgency": self.urgency.value,
|
|
315
|
+
"source": self.source,
|
|
316
|
+
"context": self.context,
|
|
317
|
+
"constraints": self.constraints,
|
|
318
|
+
"timestamp": self.timestamp,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ── Device event (Layer 5, device -> AI) ──────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
@dataclass
|
|
325
|
+
class DeviceEvent:
|
|
326
|
+
device_id: str
|
|
327
|
+
event_id: str
|
|
328
|
+
severity: Severity
|
|
329
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
330
|
+
timestamp: float = field(default_factory=time.time)
|
|
331
|
+
|
|
332
|
+
def __post_init__(self):
|
|
333
|
+
if isinstance(self.severity, str):
|
|
334
|
+
self.severity = Severity(self.severity)
|
|
335
|
+
|
|
336
|
+
def to_dict(self) -> dict:
|
|
337
|
+
return {
|
|
338
|
+
"device_id": self.device_id,
|
|
339
|
+
"event_id": self.event_id,
|
|
340
|
+
"severity": self.severity.value if hasattr(self.severity, "value") else self.severity,
|
|
341
|
+
"data": self.data,
|
|
342
|
+
"timestamp": self.timestamp,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ── Action plan (Layer 4 output) ──────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
class VerificationStatus(str, Enum):
|
|
349
|
+
"""INDEPENDENT-OBSERVATION (panel design 2026-07-21). Separate from success:
|
|
350
|
+
success answers "did the device accept the command?"; verification answers
|
|
351
|
+
"did an independent sensor confirm the effect happened?"."""
|
|
352
|
+
UNVERIFIED = "unverified" # no check ran (opt-in absent, or check pending)
|
|
353
|
+
VERIFIED = "verified" # sensor agreed with expected_reading in time
|
|
354
|
+
CONTRADICTED = "contradicted" # device said OK but the sensor disagreed
|
|
355
|
+
UNVERIFIABLE = "unverifiable" # the verifying sensor itself did not answer in time
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass
|
|
359
|
+
class VerifyBinding:
|
|
360
|
+
"""Opt-in, declarative (NOT a rule language — panel decision, Sosa): one
|
|
361
|
+
sensor, one expected reading, one deadline. Declared on the manifest (the
|
|
362
|
+
manufacturer's natural pairing) and/or on the intent (deployment cross-link,
|
|
363
|
+
which overrides)."""
|
|
364
|
+
sensor_id: str
|
|
365
|
+
expected_reading: Any
|
|
366
|
+
deadline_s: float = 5.0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass
|
|
370
|
+
class VerificationResult:
|
|
371
|
+
status: "VerificationStatus"
|
|
372
|
+
sensor_id: str
|
|
373
|
+
expected: Any
|
|
374
|
+
observed: Any = None
|
|
375
|
+
# Grade of independence (panel decision, Benítez): a sensor on a DIFFERENT
|
|
376
|
+
# device_id than the actuator is genuine independent observation; the same
|
|
377
|
+
# firmware reporting twice is weaker evidence. Recorded so an auditor knows
|
|
378
|
+
# what "verified" actually means.
|
|
379
|
+
independence: str = "independent_device" # or "same_device"
|
|
380
|
+
checked_at: float = field(default_factory=time.time)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass
|
|
384
|
+
class DeviceAction:
|
|
385
|
+
device_id: str
|
|
386
|
+
action: str
|
|
387
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
388
|
+
relevance_score: float = 0.0
|
|
389
|
+
# INDEPENDENT-OBSERVATION: opt-in. None → behaves exactly as before.
|
|
390
|
+
verify_with: Optional["VerifyBinding"] = None
|
|
391
|
+
|
|
392
|
+
@dataclass
|
|
393
|
+
class ActionPlan:
|
|
394
|
+
intent_id: str
|
|
395
|
+
actions: list[DeviceAction]
|
|
396
|
+
urgency: Urgency
|
|
397
|
+
created_at: float = field(default_factory=time.time)
|
|
398
|
+
failure_policy: "FailurePolicy" = field(default=None)
|
|
399
|
+
max_retries: int = 1
|
|
400
|
+
# failure_policy=None → usa CONTINUE (backward compatible)
|
|
401
|
+
|
|
402
|
+
@dataclass
|
|
403
|
+
class ActionResult:
|
|
404
|
+
device_id: str
|
|
405
|
+
action: str
|
|
406
|
+
success: bool
|
|
407
|
+
response: Any = None
|
|
408
|
+
error: Optional[str] = None
|
|
409
|
+
executed_at: float = field(default_factory=time.time)
|
|
410
|
+
aborted: bool = False # True if cancelled by ABORT policy
|
|
411
|
+
retries: int = 0 # retries attempted before final result
|
|
412
|
+
# INDEPENDENT-OBSERVATION: separate from success (panel decision, Morales).
|
|
413
|
+
# None when the action declared no verify_with. success=True with
|
|
414
|
+
# verification.status=CONTRADICTED is a real, valuable state: the device
|
|
415
|
+
# accepted the command but the world did not change.
|
|
416
|
+
verification: Optional["VerificationResult"] = None
|
|
417
|
+
|
|
418
|
+
@dataclass
|
|
419
|
+
class IntentResult:
|
|
420
|
+
intent_id: str
|
|
421
|
+
success: bool
|
|
422
|
+
results: list[ActionResult]
|
|
423
|
+
failed_devices: list[str] = field(default_factory=list)
|
|
424
|
+
aborted_devices: list[str] = field(default_factory=list)
|
|
425
|
+
completed_at: float = field(default_factory=time.time)
|
|
426
|
+
failure_policy_applied: str = "continue"
|
|
427
|
+
status: str = "success"
|
|
428
|
+
rejected_actions: list = field(default_factory=list)
|
|
429
|
+
# Long-running operations started by this intent (execution_model). Each entry:
|
|
430
|
+
# {operation_id, device_id, state}. EMPTY for intents that only triggered instant
|
|
431
|
+
# actions → a client that predates operations sees an identical IntentResult.
|
|
432
|
+
# The presence of entries signals "these actions started and are still running;
|
|
433
|
+
# track them by operation_id". (Querying/cancelling them is a separate API step.)
|
|
434
|
+
operations: list = field(default_factory=list)
|
|
435
|
+
# "success" — all actions completed successfully
|
|
436
|
+
# "partial" — some actions failed or were rejected, rest continued
|
|
437
|
+
# "partial_abort" — some actions executed, rest aborted by ABORT policy
|
|
438
|
+
# "failed" — all actions failed or intent was blocked
|
|
439
|
+
# "retry_exhausted"— retries exhausted, result is failure
|
|
440
|
+
# "rejected_invalid_params" — every action rejected by param validation (v0.3)
|
|
441
|
+
# "accepted" — the intent started one or more long-running operations that
|
|
442
|
+
# are still in progress (execution_model). Not success (not done) nor failed
|
|
443
|
+
# (not failed) — accepted and running. Only appears when `operations` is non-empty.
|
|
444
|
+
# rejected_actions: actions dropped because their params violated the
|
|
445
|
+
# actuator's JSON Schema — distinct from failed_devices (device didn't
|
|
446
|
+
# respond). Each entry: {device_id, action, reason}.
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ── Phased action plan (for ordered sequences like multi-phase emergencies) ─────
|
|
450
|
+
|
|
451
|
+
@dataclass
|
|
452
|
+
class PhaseAction:
|
|
453
|
+
"""A single action within a plan phase."""
|
|
454
|
+
device_id: str
|
|
455
|
+
action: str
|
|
456
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
457
|
+
|
|
458
|
+
@dataclass
|
|
459
|
+
class Phase:
|
|
460
|
+
"""
|
|
461
|
+
A group of actions executed in parallel.
|
|
462
|
+
Phases execute sequentially with a configurable delay between them.
|
|
463
|
+
"""
|
|
464
|
+
name: str
|
|
465
|
+
actions: list[PhaseAction]
|
|
466
|
+
delay_after_ms: int = 0 # wait X ms before executing the next phase
|
|
467
|
+
|
|
468
|
+
@dataclass
|
|
469
|
+
class PhasedActionPlan:
|
|
470
|
+
intent_id: str
|
|
471
|
+
phases: list[Phase]
|
|
472
|
+
urgency: Urgency
|
|
473
|
+
created_at: float = field(default_factory=time.time)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ── Family profile (per-family configurable routines) ───────────────────────────
|
|
477
|
+
|
|
478
|
+
@dataclass
|
|
479
|
+
class RoutineAction:
|
|
480
|
+
"""A single action within a family routine."""
|
|
481
|
+
tag: str # device tag that should execute this action
|
|
482
|
+
action_type: str # actuator type
|
|
483
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
484
|
+
description: str = ""
|
|
485
|
+
|
|
486
|
+
@dataclass
|
|
487
|
+
class FamilyProfile:
|
|
488
|
+
"""
|
|
489
|
+
Per-family routine profile.
|
|
490
|
+
Defines which actions to execute for each routine and at what time.
|
|
491
|
+
Each family configures their own — no values are imposed.
|
|
492
|
+
"""
|
|
493
|
+
family_name: str
|
|
494
|
+
|
|
495
|
+
# Morning routine — triggered by the first motion detection of the day
|
|
496
|
+
routine_morning: list[RoutineAction] = field(default_factory=list)
|
|
497
|
+
|
|
498
|
+
# Bedtime routine — time-based; fired by an external scheduler client
|
|
499
|
+
routine_bedtime: list[RoutineAction] = field(default_factory=list)
|
|
500
|
+
bedtime_hour: int = 21
|
|
501
|
+
bedtime_minute: int = 30
|
|
502
|
+
|
|
503
|
+
# Away mode — triggered by garage sensor or presence signal
|
|
504
|
+
routine_away: list[RoutineAction] = field(default_factory=list)
|
|
505
|
+
|
|
506
|
+
timezone: str = "America/Argentina/Cordoba"
|
|
507
|
+
created_at: float = field(default_factory=time.time)
|
|
508
|
+
|
|
509
|
+
def to_dict(self) -> dict:
|
|
510
|
+
def action_list(actions):
|
|
511
|
+
return [
|
|
512
|
+
{"tag": a.tag, "action_type": a.action_type,
|
|
513
|
+
"params": a.params, "description": a.description}
|
|
514
|
+
for a in actions
|
|
515
|
+
]
|
|
516
|
+
return {
|
|
517
|
+
"family_name": self.family_name,
|
|
518
|
+
"bedtime": f"{self.bedtime_hour:02d}:{self.bedtime_minute:02d}",
|
|
519
|
+
"timezone": self.timezone,
|
|
520
|
+
"routine_morning": action_list(self.routine_morning),
|
|
521
|
+
"routine_bedtime": action_list(self.routine_bedtime),
|
|
522
|
+
"routine_away": action_list(self.routine_away),
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
@classmethod
|
|
526
|
+
def from_dict(cls, data: dict) -> "FamilyProfile":
|
|
527
|
+
"""Rebuild a FamilyProfile from to_dict() output — the missing round-trip.
|
|
528
|
+
|
|
529
|
+
Added 2026-07-14: the profile was persisted by set_family_profile() but
|
|
530
|
+
NEVER restored on startup (db.load_family_profile() existed and nothing
|
|
531
|
+
called it), so every hub restart silently dropped it — even though
|
|
532
|
+
_restore_from_db's docstring promised the profile survives restarts.
|
|
533
|
+
|
|
534
|
+
Note the asymmetry this must absorb: to_dict serializes bedtime as an
|
|
535
|
+
"HH:MM" string, so it is parsed back into hour/minute here.
|
|
536
|
+
"""
|
|
537
|
+
def action_list(items):
|
|
538
|
+
return [
|
|
539
|
+
RoutineAction(
|
|
540
|
+
tag=a["tag"],
|
|
541
|
+
action_type=a["action_type"],
|
|
542
|
+
params=a.get("params", {}) or {},
|
|
543
|
+
description=a.get("description", ""),
|
|
544
|
+
)
|
|
545
|
+
for a in (items or [])
|
|
546
|
+
]
|
|
547
|
+
|
|
548
|
+
bedtime = data.get("bedtime", "21:30")
|
|
549
|
+
try:
|
|
550
|
+
_bh, _bm = (int(x) for x in str(bedtime).split(":", 1))
|
|
551
|
+
except (ValueError, AttributeError):
|
|
552
|
+
_bh, _bm = 21, 30
|
|
553
|
+
|
|
554
|
+
return cls(
|
|
555
|
+
family_name=data.get("family_name", ""),
|
|
556
|
+
routine_morning=action_list(data.get("routine_morning")),
|
|
557
|
+
routine_bedtime=action_list(data.get("routine_bedtime")),
|
|
558
|
+
routine_away=action_list(data.get("routine_away")),
|
|
559
|
+
bedtime_hour=_bh,
|
|
560
|
+
bedtime_minute=_bm,
|
|
561
|
+
timezone=data.get("timezone", "America/Argentina/Cordoba"),
|
|
562
|
+
)
|