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/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
+ )