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/hub.py
ADDED
|
@@ -0,0 +1,2923 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync Hub — Capability Registry + Semantic Resolver
|
|
3
|
+
Layers 3 & 4 of the DoSync protocol stack
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import asyncio
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
try:
|
|
15
|
+
from dosync import metrics as _M
|
|
16
|
+
except Exception: # metrics is optional; never let it break the hub
|
|
17
|
+
_M = None
|
|
18
|
+
from typing import Callable, Optional
|
|
19
|
+
|
|
20
|
+
from .db import DoSyncDB
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from .models import (
|
|
23
|
+
ActionPlan, ActionResult, ActuatorSpec, CapabilityManifest,
|
|
24
|
+
ContextSignalType, DeviceAction, DeviceEvent, FamilyProfile,
|
|
25
|
+
Intent, IntentClass, IntentResult, OccupancyState, Phase,
|
|
26
|
+
PhasedActionPlan, PhaseAction, PresenceSignal, RoutineAction, Urgency,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("dosync.hub")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Capability Registry (Layer 3) ─────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
class CapabilityRegistry:
|
|
35
|
+
"""
|
|
36
|
+
Stores device manifests and answers capability queries.
|
|
37
|
+
In production this would persist to disk / SQLite.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self._devices: dict[str, CapabilityManifest] = {}
|
|
42
|
+
self._listeners: list[Callable] = []
|
|
43
|
+
# Inverted tag index for O(1) device lookup by tag.
|
|
44
|
+
# Maps tag -> set of device_ids that declare that tag.
|
|
45
|
+
self._tag_index: dict[str, set[str]] = {}
|
|
46
|
+
# Emergency-capable device ids for O(1) emergency lookup
|
|
47
|
+
self._emergency_ids: set[str] = set()
|
|
48
|
+
|
|
49
|
+
def register(self, manifest: CapabilityManifest) -> None:
|
|
50
|
+
old = self._devices.get(manifest.device_id)
|
|
51
|
+
if old:
|
|
52
|
+
for tag in old.tags:
|
|
53
|
+
self._tag_index.get(tag, set()).discard(manifest.device_id)
|
|
54
|
+
self._emergency_ids.discard(manifest.device_id)
|
|
55
|
+
self._devices[manifest.device_id] = manifest
|
|
56
|
+
for tag in manifest.tags:
|
|
57
|
+
if tag not in self._tag_index:
|
|
58
|
+
self._tag_index[tag] = set()
|
|
59
|
+
self._tag_index[tag].add(manifest.device_id)
|
|
60
|
+
if manifest.emergency_capable:
|
|
61
|
+
self._emergency_ids.add(manifest.device_id)
|
|
62
|
+
log.info("Registered device: %s (%s)", manifest.device_id, manifest.device_name)
|
|
63
|
+
for cb in self._listeners:
|
|
64
|
+
cb(manifest)
|
|
65
|
+
|
|
66
|
+
def unregister(self, device_id: str) -> None:
|
|
67
|
+
manifest = self._devices.pop(device_id, None)
|
|
68
|
+
if manifest:
|
|
69
|
+
for tag in manifest.tags:
|
|
70
|
+
self._tag_index.get(tag, set()).discard(device_id)
|
|
71
|
+
self._emergency_ids.discard(device_id)
|
|
72
|
+
log.info("Unregistered device: %s", device_id)
|
|
73
|
+
|
|
74
|
+
def get(self, device_id: str) -> Optional[CapabilityManifest]:
|
|
75
|
+
return self._devices.get(device_id)
|
|
76
|
+
|
|
77
|
+
def all(self) -> list[CapabilityManifest]:
|
|
78
|
+
return list(self._devices.values())
|
|
79
|
+
|
|
80
|
+
def find_by_tags(self, tags: list[str]) -> list[CapabilityManifest]:
|
|
81
|
+
"""Return devices that have at least one of the given tags.
|
|
82
|
+
O(|tags| + |candidates|) with the inverted index, not O(n).
|
|
83
|
+
"""
|
|
84
|
+
candidate_ids: set[str] = set()
|
|
85
|
+
for tag in tags:
|
|
86
|
+
candidate_ids |= self._tag_index.get(tag, set())
|
|
87
|
+
return [self._devices[did] for did in candidate_ids if did in self._devices]
|
|
88
|
+
|
|
89
|
+
def find_by_required_tags(self, required_tags: set[str]) -> list[CapabilityManifest]:
|
|
90
|
+
"""Return devices that have ALL of the required tags (intersection index).
|
|
91
|
+
O(|result|) — starts with the smallest tag set and intersects progressively.
|
|
92
|
+
Significantly faster than union-based lookup when tags are specific.
|
|
93
|
+
"""
|
|
94
|
+
if not required_tags:
|
|
95
|
+
return self.all()
|
|
96
|
+
# Sort by set size ascending — smallest set first minimizes intersection cost
|
|
97
|
+
sets = sorted(
|
|
98
|
+
[self._tag_index.get(t, set()) for t in required_tags],
|
|
99
|
+
key=len,
|
|
100
|
+
)
|
|
101
|
+
result_ids = sets[0].copy()
|
|
102
|
+
for s in sets[1:]:
|
|
103
|
+
result_ids &= s
|
|
104
|
+
if not result_ids:
|
|
105
|
+
break
|
|
106
|
+
return [self._devices[did] for did in result_ids if did in self._devices]
|
|
107
|
+
|
|
108
|
+
def find_emergency_capable(self) -> list[CapabilityManifest]:
|
|
109
|
+
"""Return emergency-capable devices. O(|emergency_devices|) with index."""
|
|
110
|
+
return [self._devices[did] for did in self._emergency_ids if did in self._devices]
|
|
111
|
+
|
|
112
|
+
def find_by_actuator(self, actuator_type: str) -> list[CapabilityManifest]:
|
|
113
|
+
return [
|
|
114
|
+
d for d in self._devices.values()
|
|
115
|
+
if any(a.type == actuator_type for a in d.actuators)
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
def on_register(self, cb: Callable) -> None:
|
|
119
|
+
self._listeners.append(cb)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Semantic Resolver (Layer 4) ───────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
# Maps intent classes to the tags and actuator types we look for
|
|
125
|
+
# ── Intent class resolution ──────────────────────────────────────────────────
|
|
126
|
+
# DoSync v0.4+: Intent classes are stored in the database, not hardcoded here.
|
|
127
|
+
# The protocol defines the FORMAT of an intent name, not its vocabulary.
|
|
128
|
+
# Resolution tags and actuators are registered via POST /v1/intent-classes.
|
|
129
|
+
# Universal intents (ensure_safety, alert_anomaly, control_access,
|
|
130
|
+
# report_status, notify) are seeded automatically at hub init.
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Resolver interface ────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class ScoreBreakdown:
|
|
138
|
+
"""v9: the structured result of ONE scoring computation, shared by the
|
|
139
|
+
decision path (resolve uses .total) and the explanation path (explain reads
|
|
140
|
+
the components). Because both consume this, the explanation cannot diverge
|
|
141
|
+
from the decision — the class that used to be two copies is now one."""
|
|
142
|
+
matched_tags: list
|
|
143
|
+
tag_component: float
|
|
144
|
+
location_component: float
|
|
145
|
+
emergency_component: float
|
|
146
|
+
matched_actuators: list
|
|
147
|
+
actuator_component: float
|
|
148
|
+
hard_filtered: bool
|
|
149
|
+
required_specific_tags: list
|
|
150
|
+
device_tags: list
|
|
151
|
+
had_any_tag_overlap: bool
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def total(self) -> float:
|
|
155
|
+
# The hard filter zeroes the score outright: an all-specific resolution
|
|
156
|
+
# with no overlap is OUT, bonuses notwithstanding (F3b).
|
|
157
|
+
if self.hard_filtered:
|
|
158
|
+
return 0.0
|
|
159
|
+
return (self.tag_component + self.location_component
|
|
160
|
+
+ self.emergency_component + self.actuator_component)
|
|
161
|
+
|
|
162
|
+
def exclusion_reason(self) -> str:
|
|
163
|
+
if self.hard_filtered:
|
|
164
|
+
return (f"required specific tags {self.required_specific_tags} not in "
|
|
165
|
+
f"device tags {self.device_tags} (hard filter — bonuses do not apply)")
|
|
166
|
+
if not self.had_any_tag_overlap:
|
|
167
|
+
return "no tag overlap with intent resolution tags"
|
|
168
|
+
return "score = 0"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class BaseResolver:
|
|
172
|
+
"""
|
|
173
|
+
Formal interface for DoSync semantic resolvers.
|
|
174
|
+
|
|
175
|
+
The protocol defines WHAT a resolver must do, not HOW.
|
|
176
|
+
Third-party implementations can be dropped in by subclassing this
|
|
177
|
+
and passing the instance to DoSyncHub.
|
|
178
|
+
|
|
179
|
+
A resolver receives an Intent and returns an ActionPlan.
|
|
180
|
+
It has read-only access to the CapabilityRegistry.
|
|
181
|
+
|
|
182
|
+
To implement a custom resolver:
|
|
183
|
+
class MyResolver(BaseResolver):
|
|
184
|
+
async def resolve(self, intent: Intent) -> ActionPlan:
|
|
185
|
+
...
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, registry: 'CapabilityRegistry'):
|
|
189
|
+
self.registry = registry
|
|
190
|
+
|
|
191
|
+
def resolve(self, intent: Intent) -> ActionPlan:
|
|
192
|
+
raise NotImplementedError(
|
|
193
|
+
'Subclasses must implement resolve(intent) -> ActionPlan'
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ExternalResolver(BaseResolver):
|
|
198
|
+
"""
|
|
199
|
+
HTTP-based external resolver. Delegates intent resolution to an external
|
|
200
|
+
service implementing the DoSync External Resolver Protocol.
|
|
201
|
+
|
|
202
|
+
The external service receives an Intent + the full CapabilityRegistry and
|
|
203
|
+
returns an ActionPlan. This enables resolvers implemented in any language
|
|
204
|
+
(Go, Node.js, Rust, etc.) or backed by a local LLM.
|
|
205
|
+
|
|
206
|
+
Configuration:
|
|
207
|
+
DOSYNC_RESOLVER_URL=http://my-resolver:8080
|
|
208
|
+
|
|
209
|
+
Wire format (POST /resolve):
|
|
210
|
+
Request body:
|
|
211
|
+
{
|
|
212
|
+
"intent": <Intent.to_dict()>,
|
|
213
|
+
"registry": [<CapabilityManifest.to_dict()>, ...],
|
|
214
|
+
"hub_id": "<hub identifier>"
|
|
215
|
+
}
|
|
216
|
+
Response body:
|
|
217
|
+
{
|
|
218
|
+
"intent_id": "<same intent_id from request>",
|
|
219
|
+
"urgency": "info" | "warning" | "alert" | "emergency",
|
|
220
|
+
"actions": [
|
|
221
|
+
{
|
|
222
|
+
"device_id": "<str>",
|
|
223
|
+
"action": "<str>",
|
|
224
|
+
"params": {},
|
|
225
|
+
"relevance_score": 0.0
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
Timeout: 500ms (spec §6 requirement for non-LLM resolvers)
|
|
230
|
+
|
|
231
|
+
If the external resolver is unreachable or times out, falls back to
|
|
232
|
+
the CapabilityMatchingResolver automatically. Fallback is logged.
|
|
233
|
+
|
|
234
|
+
Full protocol definition: spec/RESOLVER-SPEC-v0.3.md §5 External Resolver
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
TIMEOUT_S = 0.5 # 500ms — spec requirement for non-LLM resolvers
|
|
238
|
+
|
|
239
|
+
def __init__(self, registry: 'CapabilityRegistry', url: str, hub_id: str = "",
|
|
240
|
+
hub: "DoSyncHub | None" = None):
|
|
241
|
+
super().__init__(registry)
|
|
242
|
+
self._url = url.rstrip("/") + "/resolve"
|
|
243
|
+
self._hub_id = hub_id
|
|
244
|
+
# Wiring contract (regression: tests/test_resolution_wiring.py): every
|
|
245
|
+
# resolver that reads intent resolutions needs a hub handle (_hub) for
|
|
246
|
+
# DB access — INCLUDING the local fallback. An unwired fallback returns
|
|
247
|
+
# empty resolutions for every intent, which is exactly the silent
|
|
248
|
+
# failure that gutted production explain()/fallback until 2026-07-11.
|
|
249
|
+
self._hub = hub
|
|
250
|
+
self._fallback = CapabilityMatchingResolver(registry)
|
|
251
|
+
self._fallback._hub = hub
|
|
252
|
+
log.info("ExternalResolver configured: %s (fallback: CapabilityMatchingResolver)", self._url)
|
|
253
|
+
|
|
254
|
+
def resolve(self, intent: Intent) -> ActionPlan:
|
|
255
|
+
"""Resolve intent via external HTTP service.
|
|
256
|
+
|
|
257
|
+
Runs the blocking HTTP call in a ThreadPoolExecutor so it never
|
|
258
|
+
blocks the asyncio event loop, regardless of whether resolve() is
|
|
259
|
+
called from sync or async context.
|
|
260
|
+
|
|
261
|
+
Supports HTTPS: set DOSYNC_RESOLVER_URL=https://... and optionally
|
|
262
|
+
DOSYNC_RESOLVER_CA_CERT=/path/to/ca.crt for self-signed certificates.
|
|
263
|
+
"""
|
|
264
|
+
import concurrent.futures
|
|
265
|
+
try:
|
|
266
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
267
|
+
future = pool.submit(self._http_resolve, intent)
|
|
268
|
+
return future.result(timeout=self.TIMEOUT_S + 1.0)
|
|
269
|
+
except concurrent.futures.TimeoutError:
|
|
270
|
+
log.warning("ExternalResolver timeout after %.1fs — falling back", self.TIMEOUT_S)
|
|
271
|
+
return self._fallback.resolve(intent)
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
log.warning("ExternalResolver error (%s) — falling back", exc)
|
|
274
|
+
return self._fallback.resolve(intent)
|
|
275
|
+
|
|
276
|
+
def _http_resolve(self, intent: Intent) -> ActionPlan:
|
|
277
|
+
"""Synchronous HTTP call — runs in thread pool, never in event loop."""
|
|
278
|
+
import json, ssl, urllib.request, urllib.error, os
|
|
279
|
+
|
|
280
|
+
payload = {
|
|
281
|
+
"intent": intent.to_dict(),
|
|
282
|
+
"registry": [m.to_dict() for m in self.registry.all()],
|
|
283
|
+
"hub_id": self._hub_id,
|
|
284
|
+
}
|
|
285
|
+
data = json.dumps(payload).encode()
|
|
286
|
+
|
|
287
|
+
# TLS context — use CA cert if provided, or system trust store
|
|
288
|
+
ctx = None
|
|
289
|
+
if self._url.startswith("https://"):
|
|
290
|
+
ctx = ssl.create_default_context()
|
|
291
|
+
ca_cert = os.environ.get("DOSYNC_RESOLVER_CA_CERT", "")
|
|
292
|
+
if ca_cert and os.path.exists(ca_cert):
|
|
293
|
+
ctx.load_verify_locations(ca_cert)
|
|
294
|
+
|
|
295
|
+
req = urllib.request.Request(
|
|
296
|
+
self._url,
|
|
297
|
+
data=data,
|
|
298
|
+
headers={"Content-Type": "application/json"},
|
|
299
|
+
method="POST",
|
|
300
|
+
)
|
|
301
|
+
try:
|
|
302
|
+
with urllib.request.urlopen(req, timeout=self.TIMEOUT_S, context=ctx) as resp:
|
|
303
|
+
body = json.loads(resp.read())
|
|
304
|
+
except urllib.error.URLError as exc:
|
|
305
|
+
raise RuntimeError(f"URLError: {exc}") from exc
|
|
306
|
+
|
|
307
|
+
# Parse response → ActionPlan
|
|
308
|
+
actions = [
|
|
309
|
+
DeviceAction(
|
|
310
|
+
device_id=a["device_id"],
|
|
311
|
+
action=a["action"],
|
|
312
|
+
params=a.get("params", {}),
|
|
313
|
+
relevance_score=float(a.get("relevance_score", 0.0)),
|
|
314
|
+
)
|
|
315
|
+
for a in body.get("actions", [])
|
|
316
|
+
]
|
|
317
|
+
return ActionPlan(
|
|
318
|
+
intent_id=intent.intent_id,
|
|
319
|
+
actions=actions,
|
|
320
|
+
urgency=intent.urgency,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Parse the response into an ActionPlan
|
|
324
|
+
try:
|
|
325
|
+
actions = [
|
|
326
|
+
DeviceAction(
|
|
327
|
+
device_id=a["device_id"],
|
|
328
|
+
action=a["action"],
|
|
329
|
+
params=a.get("params", {}),
|
|
330
|
+
relevance_score=float(a.get("relevance_score", 0.0)),
|
|
331
|
+
)
|
|
332
|
+
for a in body.get("actions", [])
|
|
333
|
+
]
|
|
334
|
+
return ActionPlan(
|
|
335
|
+
intent_id=intent.intent_id,
|
|
336
|
+
actions=actions,
|
|
337
|
+
urgency=intent.urgency,
|
|
338
|
+
)
|
|
339
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
340
|
+
log.warning("ExternalResolver returned invalid ActionPlan (%s) — falling back", e)
|
|
341
|
+
return self._fallback.resolve(intent)
|
|
342
|
+
|
|
343
|
+
def explain(self, intent: Intent) -> dict:
|
|
344
|
+
"""
|
|
345
|
+
ExternalResolver delegates resolution to the external service.
|
|
346
|
+
For explain(), falls back to CapabilityMatchingResolver so the
|
|
347
|
+
scoring breakdown reflects the local tag-matching algorithm.
|
|
348
|
+
"""
|
|
349
|
+
result = self._fallback.explain(intent)
|
|
350
|
+
result["resolver"] = "ExternalResolver"
|
|
351
|
+
result["external_url"] = self._url
|
|
352
|
+
# Preserve the fallback's semantic note (e.g. the read-only status-query
|
|
353
|
+
# explanation) — clobbering it hid WHY actuators don't fire. The wrapper
|
|
354
|
+
# context goes in its own field instead.
|
|
355
|
+
result["resolver_note"] = (
|
|
356
|
+
"Scoring shown is from local CapabilityMatchingResolver (fallback). "
|
|
357
|
+
"Actual resolution is delegated to the external service."
|
|
358
|
+
)
|
|
359
|
+
return result
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class CapabilityMatchingResolver(BaseResolver):
|
|
363
|
+
"""
|
|
364
|
+
Layer 4: resolves an Intent into an ActionPlan by matching
|
|
365
|
+
against registered device capabilities.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
def __init__(self, registry: CapabilityRegistry):
|
|
369
|
+
self.registry = registry
|
|
370
|
+
|
|
371
|
+
# v9 (2026-07-21): scoring weights, named once. Previously these five
|
|
372
|
+
# constants were duplicated between _relevance_score (the decision) and
|
|
373
|
+
# explain (the story), with a comment promising they "must mirror exactly"
|
|
374
|
+
# — a promise the language did not enforce. Now there is one source.
|
|
375
|
+
_W_TAG = 10.0 # per overlapping tag
|
|
376
|
+
_W_LOCATION = 15.0 # context location matches a device tag
|
|
377
|
+
_W_EMERGENCY = 30.0 # emergency urgency + emergency_capable device
|
|
378
|
+
_W_ACTUATOR = 12.0 # per matching actuator type
|
|
379
|
+
_FORCED_SCORE = 50.0 # emergency force-inclusion floor (mirrors resolve())
|
|
380
|
+
|
|
381
|
+
def _score_breakdown(
|
|
382
|
+
self,
|
|
383
|
+
device: CapabilityManifest,
|
|
384
|
+
intent: Intent,
|
|
385
|
+
resolution: dict,
|
|
386
|
+
) -> "ScoreBreakdown":
|
|
387
|
+
"""The ONE scoring computation. Returns a structured breakdown whose
|
|
388
|
+
.total is the relevance score. Both the decision path (resolve, which
|
|
389
|
+
uses .total) and the explanation path (explain, which reads the parts)
|
|
390
|
+
consume THIS — they can no longer disagree, because there is nothing to
|
|
391
|
+
keep in sync."""
|
|
392
|
+
target_tags = set(resolution.get("tags", []))
|
|
393
|
+
device_tags = set(device.tags)
|
|
394
|
+
generic_tags = {"light", "climate", "communication", "sensor", "appliance", "display"}
|
|
395
|
+
specific_tags = target_tags - generic_tags
|
|
396
|
+
resolution_is_all_specific = bool(specific_tags) and not (target_tags & generic_tags)
|
|
397
|
+
|
|
398
|
+
matched_tags = target_tags & device_tags
|
|
399
|
+
# Hard-filter semantics (2026-07-11 panel decision, F3b): only when the
|
|
400
|
+
# resolution is ALL specific tags are those tags a requirement; mixed
|
|
401
|
+
# resolutions treat specific tags as boost, not gate.
|
|
402
|
+
hard_filtered = resolution_is_all_specific and not (specific_tags & device_tags)
|
|
403
|
+
|
|
404
|
+
location = intent.context.get("location", "")
|
|
405
|
+
location_hit = bool(location) and location in device_tags
|
|
406
|
+
|
|
407
|
+
emergency_hit = (intent.urgency == Urgency.EMERGENCY and device.emergency_capable)
|
|
408
|
+
|
|
409
|
+
target_actuators = set(resolution.get("actuators", []))
|
|
410
|
+
device_actuators = {a.type for a in device.actuators}
|
|
411
|
+
matched_actuators = target_actuators & device_actuators
|
|
412
|
+
|
|
413
|
+
return ScoreBreakdown(
|
|
414
|
+
matched_tags=sorted(matched_tags),
|
|
415
|
+
tag_component=len(matched_tags) * self._W_TAG,
|
|
416
|
+
location_component=self._W_LOCATION if location_hit else 0.0,
|
|
417
|
+
emergency_component=self._W_EMERGENCY if emergency_hit else 0.0,
|
|
418
|
+
matched_actuators=sorted(matched_actuators),
|
|
419
|
+
actuator_component=len(matched_actuators) * self._W_ACTUATOR,
|
|
420
|
+
hard_filtered=hard_filtered,
|
|
421
|
+
required_specific_tags=sorted(specific_tags) if hard_filtered else [],
|
|
422
|
+
device_tags=sorted(device_tags),
|
|
423
|
+
had_any_tag_overlap=bool(matched_tags),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def _relevance_score(
|
|
427
|
+
self,
|
|
428
|
+
device: CapabilityManifest,
|
|
429
|
+
intent: Intent,
|
|
430
|
+
resolution: dict,
|
|
431
|
+
) -> float:
|
|
432
|
+
"""Back-compat thin wrapper: the score is the breakdown's total."""
|
|
433
|
+
return self._score_breakdown(device, intent, resolution).total
|
|
434
|
+
|
|
435
|
+
def explain(self, intent: Intent) -> dict:
|
|
436
|
+
"""
|
|
437
|
+
Explica el razonamiento del resolver para un intent dado.
|
|
438
|
+
Muestra el score de cada dispositivo y por qué fue incluido o excluido.
|
|
439
|
+
Calculado on-demand — refleja el estado actual del registry.
|
|
440
|
+
"""
|
|
441
|
+
resolution = self._get_resolution(intent)
|
|
442
|
+
target_tags = set(resolution.get("tags", []))
|
|
443
|
+
target_actuators = set(resolution.get("actuators", []))
|
|
444
|
+
generic_tags = {"light", "climate", "communication", "sensor", "appliance", "display"}
|
|
445
|
+
specific_tags = target_tags - generic_tags
|
|
446
|
+
location = intent.context.get("location", "")
|
|
447
|
+
|
|
448
|
+
included = []
|
|
449
|
+
excluded = []
|
|
450
|
+
|
|
451
|
+
# Empty resolution = READ-ONLY status query — mirrors resolve() (F4a):
|
|
452
|
+
# the plan reads sensors on every sensing device; actuators never fire.
|
|
453
|
+
if not target_tags and not target_actuators:
|
|
454
|
+
for device in self.registry.all():
|
|
455
|
+
if device.sensors:
|
|
456
|
+
included.append({
|
|
457
|
+
"device_id": device.device_id,
|
|
458
|
+
"device_name": device.device_name,
|
|
459
|
+
"device_tags": sorted(device.tags),
|
|
460
|
+
"score": 1.0,
|
|
461
|
+
"score_breakdown": {"read_only_status_query": True,
|
|
462
|
+
"sensors": [sn.id for sn in device.sensors]},
|
|
463
|
+
"emergency_capable": device.emergency_capable,
|
|
464
|
+
"included": True,
|
|
465
|
+
})
|
|
466
|
+
else:
|
|
467
|
+
excluded.append({
|
|
468
|
+
"device_id": device.device_id,
|
|
469
|
+
"device_name": device.device_name,
|
|
470
|
+
"device_tags": sorted(device.tags),
|
|
471
|
+
"reason": "read-only status query — device has no sensors to read",
|
|
472
|
+
"included": False,
|
|
473
|
+
})
|
|
474
|
+
return {
|
|
475
|
+
"intent": intent.intent.value,
|
|
476
|
+
"urgency": intent.urgency.value,
|
|
477
|
+
"context": intent.context,
|
|
478
|
+
"resolution_tags": [],
|
|
479
|
+
"resolution_actuators": [],
|
|
480
|
+
"devices_evaluated": len(included) + len(excluded),
|
|
481
|
+
"devices_included": len(included),
|
|
482
|
+
"devices_excluded": len(excluded),
|
|
483
|
+
"included": included,
|
|
484
|
+
"excluded": excluded,
|
|
485
|
+
"note": ("Empty resolution: this intent is a read-only status query. "
|
|
486
|
+
"The plan is read_sensors on every sensing device; actuators never fire."),
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
for device in self.registry.all():
|
|
490
|
+
# v9: consume the SAME breakdown resolve() decides with. No recompute,
|
|
491
|
+
# nothing to keep in sync — the explanation IS the decision, narrated.
|
|
492
|
+
bd = self._score_breakdown(device, intent, resolution)
|
|
493
|
+
score = bd.total
|
|
494
|
+
|
|
495
|
+
# Emergency force-inclusion — mirrors resolve() (F2b): emergency_capable
|
|
496
|
+
# devices always participate in an emergency response, even when
|
|
497
|
+
# tags/actuators match nothing.
|
|
498
|
+
forced_emergency = (score == 0.0 and intent.urgency == Urgency.EMERGENCY
|
|
499
|
+
and device.emergency_capable)
|
|
500
|
+
if forced_emergency:
|
|
501
|
+
score = self._FORCED_SCORE
|
|
502
|
+
|
|
503
|
+
if score == 0:
|
|
504
|
+
excluded.append({
|
|
505
|
+
"device_id": device.device_id,
|
|
506
|
+
"device_name": device.device_name,
|
|
507
|
+
"device_tags": sorted(device.tags),
|
|
508
|
+
"reason": bd.exclusion_reason(),
|
|
509
|
+
"included": False,
|
|
510
|
+
})
|
|
511
|
+
else:
|
|
512
|
+
included.append({
|
|
513
|
+
"device_id": device.device_id,
|
|
514
|
+
"device_name": device.device_name,
|
|
515
|
+
"device_tags": sorted(device.tags),
|
|
516
|
+
"score": score,
|
|
517
|
+
"score_breakdown": {
|
|
518
|
+
"tag_overlap": bd.tag_component,
|
|
519
|
+
"matched_tags": bd.matched_tags,
|
|
520
|
+
"location_bonus": bd.location_component,
|
|
521
|
+
"emergency_bonus": bd.emergency_component,
|
|
522
|
+
"actuator_match": bd.actuator_component,
|
|
523
|
+
"matched_actuators": bd.matched_actuators,
|
|
524
|
+
"forced_emergency": forced_emergency,
|
|
525
|
+
},
|
|
526
|
+
"emergency_capable": device.emergency_capable,
|
|
527
|
+
"included": True,
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
# Sort included devices by descending relevance score
|
|
531
|
+
included.sort(key=lambda x: x["score"], reverse=True)
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
"intent": intent.intent.value,
|
|
535
|
+
"urgency": intent.urgency.value,
|
|
536
|
+
"context": intent.context,
|
|
537
|
+
"resolution_tags": sorted(target_tags),
|
|
538
|
+
"resolution_actuators": sorted(target_actuators),
|
|
539
|
+
"devices_evaluated": len(included) + len(excluded),
|
|
540
|
+
"devices_included": len(included),
|
|
541
|
+
"devices_excluded": len(excluded),
|
|
542
|
+
"included": included,
|
|
543
|
+
"excluded": excluded,
|
|
544
|
+
"note": (
|
|
545
|
+
"This explanation reflects the resolver scoring only. "
|
|
546
|
+
"The PolicyEngine may further block, modify, or request confirmation "
|
|
547
|
+
"before execution. See the audit log for actual execution outcomes."
|
|
548
|
+
),
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
def _profile_params(self, device: CapabilityManifest,
|
|
552
|
+
actuator_type: str, intent: Intent) -> dict | None:
|
|
553
|
+
"""
|
|
554
|
+
Si el intent tiene acciones explicitas del FamilyProfile en el context,
|
|
555
|
+
busca los params correspondientes a este dispositivo y actuator.
|
|
556
|
+
Retorna None si no hay match — el caller usara los defaults.
|
|
557
|
+
"""
|
|
558
|
+
profile_actions = intent.context.get("actions", [])
|
|
559
|
+
if not profile_actions:
|
|
560
|
+
return None
|
|
561
|
+
for pa in profile_actions:
|
|
562
|
+
# Match: device tag matches the action tag
|
|
563
|
+
# and actuator type matches
|
|
564
|
+
if (pa.get("action_type") == actuator_type and
|
|
565
|
+
pa.get("tag") in device.tags):
|
|
566
|
+
return pa.get("params", {})
|
|
567
|
+
return None
|
|
568
|
+
|
|
569
|
+
def _build_actions_for_device(
|
|
570
|
+
self,
|
|
571
|
+
device: CapabilityManifest,
|
|
572
|
+
intent: Intent,
|
|
573
|
+
resolution: dict,
|
|
574
|
+
) -> list[DeviceAction]:
|
|
575
|
+
actions = []
|
|
576
|
+
target_actuators = set(resolution.get("actuators", []))
|
|
577
|
+
|
|
578
|
+
for actuator in device.actuators:
|
|
579
|
+
if not target_actuators or actuator.type in target_actuators:
|
|
580
|
+
# Prefer FamilyProfile params when available
|
|
581
|
+
profile_p = self._profile_params(device, actuator.type, intent)
|
|
582
|
+
params = profile_p if profile_p is not None else self._default_params(actuator, intent)
|
|
583
|
+
actions.append(DeviceAction(
|
|
584
|
+
device_id=device.device_id,
|
|
585
|
+
action=actuator.type,
|
|
586
|
+
params=params,
|
|
587
|
+
))
|
|
588
|
+
|
|
589
|
+
# For sensors with no actuators, add a "read" action
|
|
590
|
+
if not actions and device.sensors:
|
|
591
|
+
actions.append(DeviceAction(
|
|
592
|
+
device_id=device.device_id,
|
|
593
|
+
action="read_sensors",
|
|
594
|
+
params={"sensor_ids": [s.id for s in device.sensors]},
|
|
595
|
+
))
|
|
596
|
+
|
|
597
|
+
return actions
|
|
598
|
+
|
|
599
|
+
def _default_params(self, actuator: ActuatorSpec, intent: Intent) -> dict:
|
|
600
|
+
"""Sensible defaults per actuator type."""
|
|
601
|
+
defaults = {
|
|
602
|
+
"unlock": {"duration_seconds": 300},
|
|
603
|
+
"lock": {},
|
|
604
|
+
"call": {"number": intent.context.get("emergency_number", "911"),
|
|
605
|
+
"message": intent.context.get("message", "Emergency at home")},
|
|
606
|
+
"notify": {"message": intent.context.get("message", ""),
|
|
607
|
+
"urgency": intent.urgency.value},
|
|
608
|
+
"alarm": {"pattern": "emergency" if intent.urgency == Urgency.EMERGENCY
|
|
609
|
+
else "alert"},
|
|
610
|
+
"light": {"brightness": 100, "color": "white"},
|
|
611
|
+
"set_brightness": {"brightness": 100},
|
|
612
|
+
"set_temperature": {"celsius": intent.context.get("target_temp", 21)},
|
|
613
|
+
}
|
|
614
|
+
return defaults.get(actuator.type, {})
|
|
615
|
+
|
|
616
|
+
def _get_resolution(self, intent: Intent) -> dict:
|
|
617
|
+
"""Return resolution tags/actuators from the intent_classes DB table.
|
|
618
|
+
All intent classes — universal and domain-specific — live in the DB.
|
|
619
|
+
Falls back to empty resolution if intent class is not registered.
|
|
620
|
+
"""
|
|
621
|
+
try:
|
|
622
|
+
# StateAwareResolver stores the hub as self._hub; some external/custom
|
|
623
|
+
# resolvers may expose it publicly as self.hub. Check both — this line
|
|
624
|
+
# being wrong ("hub" only) silently emptied EVERY intent resolution in
|
|
625
|
+
# production until 2026-07-11: the resolver ran purely on the
|
|
626
|
+
# emergency-capable bonus. Regression: tests/test_resolution_wiring.py
|
|
627
|
+
hub = getattr(self, "_hub", None) or getattr(self, "hub", None)
|
|
628
|
+
db = getattr(hub, "db", None)
|
|
629
|
+
if db:
|
|
630
|
+
name = str(intent.intent)
|
|
631
|
+
row = db.get_intent_class(name)
|
|
632
|
+
if row:
|
|
633
|
+
return {
|
|
634
|
+
"tags": row["resolution_tags"],
|
|
635
|
+
"actuators": row["resolution_actuators"],
|
|
636
|
+
}
|
|
637
|
+
except Exception as e:
|
|
638
|
+
log.warning("_get_resolution: DB lookup failed for '%s': %s", str(intent.intent), e)
|
|
639
|
+
return {"tags": [], "actuators": []}
|
|
640
|
+
|
|
641
|
+
def resolve(self, intent: Intent) -> ActionPlan:
|
|
642
|
+
from datetime import datetime
|
|
643
|
+
# Context validation: schedule-aware intents
|
|
644
|
+
schedule = intent.context.get("schedule")
|
|
645
|
+
if schedule:
|
|
646
|
+
now = datetime.now()
|
|
647
|
+
days_ok = now.weekday() < 5 # lun-vie = 0-4
|
|
648
|
+
hour_range = schedule.get("hour_range")
|
|
649
|
+
if hour_range:
|
|
650
|
+
h_start, h_end = hour_range
|
|
651
|
+
hour_ok = h_start <= now.hour * 60 + now.minute <= h_end
|
|
652
|
+
else:
|
|
653
|
+
hour_ok = True
|
|
654
|
+
if not days_ok or not hour_ok:
|
|
655
|
+
log.info("Intent '%s' blocked by schedule (day=%s hour=%s:%s)",
|
|
656
|
+
intent.intent.value, now.weekday(), now.hour, now.minute)
|
|
657
|
+
return ActionPlan(intent_id=intent.intent_id, actions=[], urgency=intent.urgency)
|
|
658
|
+
resolution = self._get_resolution(intent)
|
|
659
|
+
|
|
660
|
+
# Candidate selection via inverted tag index.
|
|
661
|
+
# Strategy:
|
|
662
|
+
# specific_tags → intersection index: O(|result|), devices must have ALL
|
|
663
|
+
# generic_tags only → union index: O(|tags| + |candidates|)
|
|
664
|
+
# no tags (report_status) → all devices
|
|
665
|
+
target_tags = set(resolution.get("tags", []))
|
|
666
|
+
generic_tags = {"light", "climate", "communication", "sensor", "appliance", "display"}
|
|
667
|
+
specific_tags = target_tags - generic_tags
|
|
668
|
+
|
|
669
|
+
if target_tags:
|
|
670
|
+
# Union index: candidates are devices with ANY of the target tags.
|
|
671
|
+
# O(|target_tags| + |candidates|) with the inverted index.
|
|
672
|
+
candidates = self.registry.find_by_tags(list(target_tags))
|
|
673
|
+
else:
|
|
674
|
+
# Empty resolution (e.g. report_status) = a READ-ONLY status query
|
|
675
|
+
# across the deployment (2026-07-11 panel decision, F4a): the plan is
|
|
676
|
+
# read_sensors on every device that senses — actuators never fire on
|
|
677
|
+
# a status query. Note: before this, the "all devices as candidates"
|
|
678
|
+
# branch was dead code — every candidate scored 0 and was dropped, so
|
|
679
|
+
# report_status had never produced a single action in production.
|
|
680
|
+
# SENSOR-KIND scope (2026-07-17): "read the environment" and "read
|
|
681
|
+
# every device's self-state" are different status questions, and the
|
|
682
|
+
# protocol now distinguishes them (SensorSpec.kind) while the
|
|
683
|
+
# DEPLOYMENT decides which one a bare status query means:
|
|
684
|
+
# * intent.context["scope"] wins per-query: "all" | "environment"
|
|
685
|
+
# * otherwise DOSYNC_STATUS_SCOPE (deployment config, same place
|
|
686
|
+
# the deployment declares its other preferences)
|
|
687
|
+
# * otherwise "all" — today's behavior, so nothing changes for a
|
|
688
|
+
# deployment that has expressed no preference. The protocol has
|
|
689
|
+
# no opinion; it only makes the question expressible.
|
|
690
|
+
# Invalid values warn and fall back rather than fail: a status query
|
|
691
|
+
# is read-only and harmless, and refusing it over a typo'd
|
|
692
|
+
# preference would be disproportionate.
|
|
693
|
+
scope = (intent.context or {}).get("scope")
|
|
694
|
+
if scope is not None and scope not in ("all", "environment"):
|
|
695
|
+
log.warning("status scope %r unknown — using deployment default", scope)
|
|
696
|
+
scope = None
|
|
697
|
+
if scope is None:
|
|
698
|
+
scope = os.environ.get("DOSYNC_STATUS_SCOPE", "all")
|
|
699
|
+
if scope not in ("all", "environment"):
|
|
700
|
+
log.warning("DOSYNC_STATUS_SCOPE=%r invalid — using 'all'", scope)
|
|
701
|
+
scope = "all"
|
|
702
|
+
|
|
703
|
+
read_actions = []
|
|
704
|
+
for d in self.registry.all():
|
|
705
|
+
if not d.sensors:
|
|
706
|
+
continue
|
|
707
|
+
sensor_ids = [
|
|
708
|
+
sn.id for sn in d.sensors
|
|
709
|
+
if scope == "all"
|
|
710
|
+
or getattr(sn, "kind", "environment") == "environment"
|
|
711
|
+
]
|
|
712
|
+
if sensor_ids: # a device with only device_state sensors drops out
|
|
713
|
+
read_actions.append(DeviceAction(
|
|
714
|
+
device_id=d.device_id,
|
|
715
|
+
action="read_sensors",
|
|
716
|
+
params={"sensor_ids": sensor_ids},
|
|
717
|
+
))
|
|
718
|
+
return ActionPlan(
|
|
719
|
+
intent_id=intent.intent_id,
|
|
720
|
+
actions=read_actions,
|
|
721
|
+
urgency=intent.urgency,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
# Emergency intents: always include emergency_capable devices as candidates,
|
|
725
|
+
# even if their tags don't overlap with the intent's resolution tags.
|
|
726
|
+
# This ensures physical safety devices (lights at max brightness, alarms)
|
|
727
|
+
# are never excluded by the tag filter on emergency intents.
|
|
728
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
729
|
+
candidate_ids = {d.device_id for d in candidates}
|
|
730
|
+
for device in self.registry.find_emergency_capable():
|
|
731
|
+
if device.device_id not in candidate_ids:
|
|
732
|
+
candidates.append(device)
|
|
733
|
+
|
|
734
|
+
# Score candidates only
|
|
735
|
+
scored: list[tuple[float, CapabilityManifest]] = []
|
|
736
|
+
for device in candidates:
|
|
737
|
+
score = self._relevance_score(device, intent, resolution)
|
|
738
|
+
if score > 0:
|
|
739
|
+
scored.append((score, device))
|
|
740
|
+
|
|
741
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
742
|
+
|
|
743
|
+
# Emergency: always include ALL emergency-capable devices
|
|
744
|
+
forced_ids: set = set()
|
|
745
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
746
|
+
scored_ids = {d.device_id for _, d in scored}
|
|
747
|
+
for device in self.registry.find_emergency_capable():
|
|
748
|
+
if device.device_id not in scored_ids:
|
|
749
|
+
scored.append((50.0, device))
|
|
750
|
+
forced_ids.add(device.device_id)
|
|
751
|
+
|
|
752
|
+
# Build actions. F2b (2026-07-11 panel decision): at EMERGENCY urgency,
|
|
753
|
+
# an emergency_capable device whose resolution-scoped build yields ZERO
|
|
754
|
+
# actions falls back to its FULL capability set — a safety device that
|
|
755
|
+
# shows up and does nothing is worse than one that acts broadly. This
|
|
756
|
+
# covers both force-included devices and tag/bonus-scored devices whose
|
|
757
|
+
# actuator types simply don't appear in the resolution list. Tag and
|
|
758
|
+
# type devices correctly (TAG-VOCABULARY.md) for precise, resolution-
|
|
759
|
+
# scoped behavior instead. NOTE: the fallback builds every actuator the
|
|
760
|
+
# device declares — a deliberately blunt instrument for emergencies.
|
|
761
|
+
all_actions: list[DeviceAction] = []
|
|
762
|
+
for score, device in scored:
|
|
763
|
+
actions = self._build_actions_for_device(device, intent, resolution)
|
|
764
|
+
if (not actions and intent.urgency == Urgency.EMERGENCY
|
|
765
|
+
and device.emergency_capable):
|
|
766
|
+
actions = self._build_actions_for_device(
|
|
767
|
+
device, intent, {**resolution, "actuators": []})
|
|
768
|
+
for a in actions:
|
|
769
|
+
a.relevance_score = score
|
|
770
|
+
all_actions.extend(actions)
|
|
771
|
+
|
|
772
|
+
log.info(
|
|
773
|
+
"Intent '%s' resolved to %d actions across %d devices",
|
|
774
|
+
intent.intent.value, len(all_actions), len(scored),
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
return ActionPlan(
|
|
778
|
+
intent_id=intent.intent_id,
|
|
779
|
+
actions=all_actions,
|
|
780
|
+
urgency=intent.urgency,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# ── State-aware resolver ──────────────────────────────────────────────────────
|
|
787
|
+
|
|
788
|
+
class StateAwareResolver(CapabilityMatchingResolver):
|
|
789
|
+
"""
|
|
790
|
+
Extends CapabilityMatchingResolver with device state awareness.
|
|
791
|
+
|
|
792
|
+
Before executing an action, queries the current device state
|
|
793
|
+
and skips redundant actions:
|
|
794
|
+
- Does not turn on a light that is already at full brightness
|
|
795
|
+
- Does not unlock a door that is already unlocked
|
|
796
|
+
- Does not set a temperature that is already at target
|
|
797
|
+
|
|
798
|
+
This is the recommended resolver for production deployments.
|
|
799
|
+
"""
|
|
800
|
+
|
|
801
|
+
def __init__(self, registry: 'CapabilityRegistry', hub: 'DoSyncHub'):
|
|
802
|
+
super().__init__(registry)
|
|
803
|
+
self._hub = hub
|
|
804
|
+
self._state_cache: dict = {}
|
|
805
|
+
|
|
806
|
+
def _get_device_state(self, device_id: str) -> dict:
|
|
807
|
+
"""Returns cached device state or empty dict if unknown."""
|
|
808
|
+
return self._state_cache.get(device_id, {})
|
|
809
|
+
|
|
810
|
+
def mark_unreachable(self, device_id: str, ttl_seconds: int = None) -> None:
|
|
811
|
+
"""
|
|
812
|
+
Marks a device as unreachable for TTL seconds.
|
|
813
|
+
TTL is configured via DOSYNC_UNREACHABLE_TTL env var (default: 1800s = 30min).
|
|
814
|
+
Called when an adapter fails with a connection error.
|
|
815
|
+
"""
|
|
816
|
+
import time as _time
|
|
817
|
+
import os as _os
|
|
818
|
+
ttl = ttl_seconds if ttl_seconds is not None else int(_os.environ.get("DOSYNC_UNREACHABLE_TTL", "1800"))
|
|
819
|
+
if device_id not in self._state_cache:
|
|
820
|
+
self._state_cache[device_id] = {}
|
|
821
|
+
self._state_cache[device_id]['unreachable'] = True
|
|
822
|
+
self._state_cache[device_id]['unreachable_until'] = _time.time() + ttl
|
|
823
|
+
self._state_cache[device_id]['unreachable_since'] = _time.time()
|
|
824
|
+
log.info('StateAwareResolver: device %s marked unreachable for %ds (TTL)', device_id, ttl)
|
|
825
|
+
try:
|
|
826
|
+
db = getattr(self._hub, 'db', None)
|
|
827
|
+
if db:
|
|
828
|
+
db.save_device_state(device_id, self._state_cache[device_id])
|
|
829
|
+
except Exception as _e:
|
|
830
|
+
log.warning('StateAwareResolver: failed to persist unreachable state for %s: %s', device_id, _e)
|
|
831
|
+
|
|
832
|
+
def clear_unreachable(self, device_id: str) -> None:
|
|
833
|
+
"""Clears unreachable mark — called when device responds successfully."""
|
|
834
|
+
state = self._state_cache.get(device_id, {})
|
|
835
|
+
state.pop('unreachable', None)
|
|
836
|
+
state.pop('unreachable_until', None)
|
|
837
|
+
state.pop('unreachable_since', None)
|
|
838
|
+
self._state_cache[device_id] = state
|
|
839
|
+
log.info('StateAwareResolver: device %s unreachable mark cleared', device_id)
|
|
840
|
+
|
|
841
|
+
def update_state(self, device_id: str, state: dict) -> None:
|
|
842
|
+
"""Called by adapters after execution to update state cache and persist to DB."""
|
|
843
|
+
if device_id not in self._state_cache:
|
|
844
|
+
self._state_cache[device_id] = {}
|
|
845
|
+
self._state_cache[device_id].update(state)
|
|
846
|
+
# Persist to SQLite to survive hub restarts
|
|
847
|
+
try:
|
|
848
|
+
db = getattr(self._hub, 'db', None)
|
|
849
|
+
if db:
|
|
850
|
+
db.save_device_state(device_id, self._state_cache[device_id])
|
|
851
|
+
except Exception as _e:
|
|
852
|
+
log.warning('StateAwareResolver: failed to persist state for %s: %s', device_id, _e)
|
|
853
|
+
|
|
854
|
+
# NOTE: start_background_refresh/_refresh_cycle used to live here. They moved
|
|
855
|
+
# to the hub (DoSyncHub.start_state_refresh) on 2026-07-14: server.py started
|
|
856
|
+
# them behind `isinstance(hub.resolver, StateAwareResolver)`, which is always
|
|
857
|
+
# False in production (ExternalResolver), so the refresher never ran. State
|
|
858
|
+
# refresh is a hub concern — the hub calls this resolver's update_state() to
|
|
859
|
+
# keep this cache coherent.
|
|
860
|
+
|
|
861
|
+
def _load_state_from_db(self) -> None:
|
|
862
|
+
"""Load state cache from SQLite on startup. Silent if no data exists."""
|
|
863
|
+
try:
|
|
864
|
+
db = getattr(self._hub, 'db', None)
|
|
865
|
+
if db:
|
|
866
|
+
states = db.load_all_device_states()
|
|
867
|
+
if states:
|
|
868
|
+
self._state_cache.update(states)
|
|
869
|
+
log.info('StateAwareResolver: loaded state for %d device(s) from DB',
|
|
870
|
+
len(states))
|
|
871
|
+
except Exception as _e:
|
|
872
|
+
log.warning('StateAwareResolver: failed to load state from DB: %s', _e)
|
|
873
|
+
|
|
874
|
+
def _is_redundant(self, action: DeviceAction) -> bool:
|
|
875
|
+
"""Returns True if the action would have no effect given current state."""
|
|
876
|
+
state = self._get_device_state(action.device_id)
|
|
877
|
+
if not state:
|
|
878
|
+
return False # unknown state — execute to be safe
|
|
879
|
+
|
|
880
|
+
if action.action == 'turn_on' and state.get('on') is True:
|
|
881
|
+
brightness = action.params.get('brightness', 100)
|
|
882
|
+
current_brightness = state.get('brightness', 0)
|
|
883
|
+
if current_brightness >= brightness:
|
|
884
|
+
log.debug('Skipping redundant turn_on for %s (already on at %s%%)',
|
|
885
|
+
action.device_id, current_brightness)
|
|
886
|
+
return True
|
|
887
|
+
|
|
888
|
+
if action.action == 'turn_off' and state.get('on') is False:
|
|
889
|
+
log.debug('Skipping redundant turn_off for %s (already off)', action.device_id)
|
|
890
|
+
return True
|
|
891
|
+
|
|
892
|
+
if action.action == 'unlock' and state.get('locked') is False:
|
|
893
|
+
log.debug('Skipping redundant unlock for %s (already unlocked)', action.device_id)
|
|
894
|
+
return True
|
|
895
|
+
|
|
896
|
+
if action.action == 'lock' and state.get('locked') is True:
|
|
897
|
+
log.debug('Skipping redundant lock for %s (already locked)', action.device_id)
|
|
898
|
+
return True
|
|
899
|
+
|
|
900
|
+
if action.action == 'set_temperature':
|
|
901
|
+
target = action.params.get('celsius')
|
|
902
|
+
current = state.get('temperature')
|
|
903
|
+
if target and current and abs(target - current) < 0.5:
|
|
904
|
+
log.debug('Skipping redundant set_temperature for %s (already at %.1f)',
|
|
905
|
+
action.device_id, current)
|
|
906
|
+
return True
|
|
907
|
+
|
|
908
|
+
return False
|
|
909
|
+
|
|
910
|
+
def resolve(self, intent: Intent) -> ActionPlan:
|
|
911
|
+
"""Resolves intent and filters redundant actions based on device state."""
|
|
912
|
+
plan = super().resolve(intent)
|
|
913
|
+
|
|
914
|
+
if not self._state_cache:
|
|
915
|
+
return plan # no state info yet — pass through
|
|
916
|
+
|
|
917
|
+
filtered = [a for a in plan.actions if not self._is_redundant(a)]
|
|
918
|
+
skipped = len(plan.actions) - len(filtered)
|
|
919
|
+
if skipped:
|
|
920
|
+
log.info('StateAwareResolver: skipped %d redundant action(s) for intent %s',
|
|
921
|
+
skipped, intent.intent.value)
|
|
922
|
+
|
|
923
|
+
return ActionPlan(
|
|
924
|
+
intent_id=plan.intent_id,
|
|
925
|
+
actions=filtered,
|
|
926
|
+
urgency=plan.urgency,
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
# ── Audit log ─────────────────────────────────────────────────────────────────
|
|
930
|
+
|
|
931
|
+
class AuditLog:
|
|
932
|
+
"""
|
|
933
|
+
Tamper-evident chained log for all intent executions.
|
|
934
|
+
SHA-256 chains each entry to the previous one.
|
|
935
|
+
"""
|
|
936
|
+
|
|
937
|
+
def __init__(self):
|
|
938
|
+
self._entries: list[dict] = []
|
|
939
|
+
self._prev_hash = "0" * 64
|
|
940
|
+
self._persist_cb = None # set by DoSyncHub after db.init()
|
|
941
|
+
# AUDIT-ARCHIVE: where THIS chain begins. Genesis for a chain that has
|
|
942
|
+
# never been archived; the last archived entry's hash otherwise (set at
|
|
943
|
+
# restore from audit_meta). Verification starts here, not at genesis.
|
|
944
|
+
self.anchor_prev_hash = "0" * 64
|
|
945
|
+
|
|
946
|
+
def append(self, entry: dict) -> str:
|
|
947
|
+
entry["prev_hash"] = self._prev_hash
|
|
948
|
+
entry["timestamp"] = time.time()
|
|
949
|
+
raw = json.dumps(entry, sort_keys=True)
|
|
950
|
+
entry_hash = hashlib.sha256(raw.encode()).hexdigest()
|
|
951
|
+
entry["hash"] = entry_hash
|
|
952
|
+
self._prev_hash = entry_hash
|
|
953
|
+
self._entries.append(entry)
|
|
954
|
+
if self._persist_cb:
|
|
955
|
+
self._persist_cb(entry)
|
|
956
|
+
return entry_hash
|
|
957
|
+
|
|
958
|
+
def verify(self) -> bool:
|
|
959
|
+
prev = self.anchor_prev_hash
|
|
960
|
+
for entry in self._entries:
|
|
961
|
+
stored_hash = entry.pop("hash")
|
|
962
|
+
raw = json.dumps(entry, sort_keys=True)
|
|
963
|
+
calc = hashlib.sha256(raw.encode()).hexdigest()
|
|
964
|
+
entry["hash"] = stored_hash
|
|
965
|
+
if calc != stored_hash or entry["prev_hash"] != prev:
|
|
966
|
+
return False
|
|
967
|
+
prev = stored_hash
|
|
968
|
+
return True
|
|
969
|
+
|
|
970
|
+
def entries(self) -> list[dict]:
|
|
971
|
+
return list(self._entries)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
# ── DoSync Hub ────────────────────────────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
class _TimedExecutor:
|
|
977
|
+
"""Transparent executor wrapper that records per-action execution latency.
|
|
978
|
+
|
|
979
|
+
Delegates everything to the wrapped executor; only execute() is timed. Any
|
|
980
|
+
attribute the caller expects (release_claim, etc.) passes through. This keeps
|
|
981
|
+
latency instrumentation out of every execution path in the hub.
|
|
982
|
+
"""
|
|
983
|
+
|
|
984
|
+
_dosync_timed = True
|
|
985
|
+
|
|
986
|
+
def __init__(self, inner, hub=None):
|
|
987
|
+
self._inner = inner
|
|
988
|
+
self._hub = hub
|
|
989
|
+
|
|
990
|
+
def __getattr__(self, name):
|
|
991
|
+
return getattr(self._inner, name)
|
|
992
|
+
|
|
993
|
+
async def execute(self, action, urgency):
|
|
994
|
+
_t0 = time.perf_counter()
|
|
995
|
+
result = await self._inner.execute(action, urgency)
|
|
996
|
+
success = bool(getattr(result, "success", False))
|
|
997
|
+
try:
|
|
998
|
+
if _M is not None:
|
|
999
|
+
_M.action_execution_seconds.observe(
|
|
1000
|
+
time.perf_counter() - _t0,
|
|
1001
|
+
{"result": "success" if success else "failed"},
|
|
1002
|
+
)
|
|
1003
|
+
except Exception:
|
|
1004
|
+
pass
|
|
1005
|
+
# Device health: every real action is a health signal, recorded at this
|
|
1006
|
+
# single chokepoint (all execution paths funnel through here) rather than
|
|
1007
|
+
# scattered across parallel/batch/retry paths. Two complementary sinks,
|
|
1008
|
+
# both previously built-but-unwired (no code populated them, so the
|
|
1009
|
+
# /v1/health endpoints returned empty in production):
|
|
1010
|
+
# 1. execution stats (db.device_health) — success-rate history per
|
|
1011
|
+
# device, feeding /v1/health/devices. record_execution's own
|
|
1012
|
+
# docstring says "call after each adapter.execute()"; this is it.
|
|
1013
|
+
# 2. passive reachability (hub.health) — reachable/unreachable + TTL.
|
|
1014
|
+
# A failed action records the failure but does NOT immediately mark a
|
|
1015
|
+
# device unreachable (a single command can fail transiently); only
|
|
1016
|
+
# execution-path timeouts do that. A success always refreshes reachable.
|
|
1017
|
+
try:
|
|
1018
|
+
if self._hub is not None and getattr(self._hub, "db", None) is not None:
|
|
1019
|
+
self._hub.db.record_execution(
|
|
1020
|
+
action.device_id, action.action, success,
|
|
1021
|
+
error=None if success else getattr(result, "error", None))
|
|
1022
|
+
except Exception:
|
|
1023
|
+
pass
|
|
1024
|
+
try:
|
|
1025
|
+
if self._hub is not None and getattr(self._hub, "health", None) is not None:
|
|
1026
|
+
if success:
|
|
1027
|
+
self._hub.health.mark_reachable(action.device_id)
|
|
1028
|
+
except Exception:
|
|
1029
|
+
pass
|
|
1030
|
+
|
|
1031
|
+
# INDEPENDENT-OBSERVATION (panel design 2026-07-21): if the action
|
|
1032
|
+
# declared a verify_with binding and it was ACCEPTED (success), read the
|
|
1033
|
+
# independent sensor and compare against the expected reading. This is
|
|
1034
|
+
# the difference between "the device accepted the command" (success) and
|
|
1035
|
+
# "an independent sensor confirms the effect happened" (verification).
|
|
1036
|
+
# Opt-in: no binding → result unchanged. Never affects success itself.
|
|
1037
|
+
binding = getattr(action, "verify_with", None)
|
|
1038
|
+
if binding is not None and success and self._hub is not None:
|
|
1039
|
+
try:
|
|
1040
|
+
result.verification = await self._verify_action(action, binding)
|
|
1041
|
+
except Exception as _ve:
|
|
1042
|
+
log.warning("verification raised for %s (recorded unverifiable): %s",
|
|
1043
|
+
action.device_id, _ve)
|
|
1044
|
+
from .models import VerificationResult, VerificationStatus
|
|
1045
|
+
result.verification = VerificationResult(
|
|
1046
|
+
status=VerificationStatus.UNVERIFIABLE, sensor_id=binding.sensor_id,
|
|
1047
|
+
expected=binding.expected_reading, observed=None,
|
|
1048
|
+
independence=("same_device" if binding.sensor_id.split(":")[0]
|
|
1049
|
+
== action.device_id else "independent_device"))
|
|
1050
|
+
return result
|
|
1051
|
+
|
|
1052
|
+
async def _verify_action(self, action, binding):
|
|
1053
|
+
"""Read the verifying sensor and grade the outcome. Returns a
|
|
1054
|
+
VerificationResult with one of verified/contradicted/unverifiable."""
|
|
1055
|
+
import asyncio as _asyncio
|
|
1056
|
+
from .models import VerificationResult, VerificationStatus
|
|
1057
|
+
|
|
1058
|
+
from .adapters import AdapterExecutor
|
|
1059
|
+
|
|
1060
|
+
# sensor_id: "device_id:local_sensor" (cross-device) or "device_id".
|
|
1061
|
+
# A sensor on a DIFFERENT device than the actuator is genuine independent
|
|
1062
|
+
# observation (panel, Benítez); same-device is weaker evidence.
|
|
1063
|
+
if ":" in binding.sensor_id:
|
|
1064
|
+
sensor_device, sensor_key = binding.sensor_id.split(":", 1)
|
|
1065
|
+
else:
|
|
1066
|
+
sensor_device, sensor_key = binding.sensor_id, None
|
|
1067
|
+
independence = ("same_device" if sensor_device == action.device_id
|
|
1068
|
+
else "independent_device")
|
|
1069
|
+
|
|
1070
|
+
def _result(status, observed):
|
|
1071
|
+
return VerificationResult(
|
|
1072
|
+
status=status, sensor_id=binding.sensor_id,
|
|
1073
|
+
expected=binding.expected_reading, observed=observed,
|
|
1074
|
+
independence=independence)
|
|
1075
|
+
|
|
1076
|
+
# Read the verifying sensor through its adapter's get_state — the same
|
|
1077
|
+
# path the active probe uses. If we cannot reach one, the honest verdict
|
|
1078
|
+
# is UNVERIFIABLE (we could not look), NOT contradiction.
|
|
1079
|
+
inner = self._inner
|
|
1080
|
+
if not isinstance(inner, AdapterExecutor):
|
|
1081
|
+
return _result(VerificationStatus.UNVERIFIABLE, None)
|
|
1082
|
+
|
|
1083
|
+
device = self._hub.registry.get(sensor_device) if self._hub else None
|
|
1084
|
+
adapter = inner.get_adapter(device.adapter) if device else None
|
|
1085
|
+
if adapter is None or not hasattr(adapter, "get_state"):
|
|
1086
|
+
return _result(VerificationStatus.UNVERIFIABLE, None)
|
|
1087
|
+
|
|
1088
|
+
try:
|
|
1089
|
+
state = await _asyncio.wait_for(
|
|
1090
|
+
adapter.get_state(sensor_device), timeout=binding.deadline_s)
|
|
1091
|
+
except Exception:
|
|
1092
|
+
state = None
|
|
1093
|
+
|
|
1094
|
+
if state is None:
|
|
1095
|
+
return _result(VerificationStatus.UNVERIFIABLE, None)
|
|
1096
|
+
|
|
1097
|
+
observed = state.get(sensor_key) if (sensor_key and isinstance(state, dict)) else state
|
|
1098
|
+
if observed == binding.expected_reading:
|
|
1099
|
+
return _result(VerificationStatus.VERIFIED, observed)
|
|
1100
|
+
if sensor_key is None and isinstance(state, dict) \
|
|
1101
|
+
and binding.expected_reading in state.values():
|
|
1102
|
+
return _result(VerificationStatus.VERIFIED, state)
|
|
1103
|
+
return _result(VerificationStatus.CONTRADICTED, observed)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
class DeviceHealth:
|
|
1107
|
+
"""Passive device health, owned by the HUB (not the resolver).
|
|
1108
|
+
|
|
1109
|
+
Health lived in StateAwareResolver until 2026-07-14, but production runs
|
|
1110
|
+
ExternalResolver (which lacks mark_unreachable), so the hub's
|
|
1111
|
+
`hasattr(resolver, "mark_unreachable")` guards silently no-op'd and NO device
|
|
1112
|
+
was ever marked unreachable in production — a false "everything healthy".
|
|
1113
|
+
Health is a property of the hub: it is populated from the EXECUTION PATH,
|
|
1114
|
+
which runs under any resolver, and persisted via the existing device_state
|
|
1115
|
+
table.
|
|
1116
|
+
|
|
1117
|
+
This is PASSIVE health: it reflects the outcome of the last real interaction
|
|
1118
|
+
with a device (an action executed or timed out), not an active heartbeat. It
|
|
1119
|
+
reports what it knows — last_seen, unreachable_since, until (TTL) — and never
|
|
1120
|
+
asserts "powered off" (it cannot distinguish an off device from a network
|
|
1121
|
+
drop). Active probing is future work (DEVICE-HEALTH-ACTIVE).
|
|
1122
|
+
"""
|
|
1123
|
+
|
|
1124
|
+
DEFAULT_TTL_SECONDS = 300
|
|
1125
|
+
|
|
1126
|
+
def __init__(self, hub: "DoSyncHub"):
|
|
1127
|
+
self._hub = hub
|
|
1128
|
+
self._state: dict[str, dict] = {}
|
|
1129
|
+
self._lock = threading.Lock()
|
|
1130
|
+
|
|
1131
|
+
def _persist(self, device_id: str) -> None:
|
|
1132
|
+
try:
|
|
1133
|
+
db = getattr(self._hub, "db", None)
|
|
1134
|
+
if db:
|
|
1135
|
+
db.save_device_state(device_id, self._state[device_id])
|
|
1136
|
+
except Exception as e:
|
|
1137
|
+
log.warning("DeviceHealth: failed to persist state for %s: %s", device_id, e)
|
|
1138
|
+
|
|
1139
|
+
def mark_reachable(self, device_id: str) -> None:
|
|
1140
|
+
"""A device responded — record last_seen and clear any unreachable mark."""
|
|
1141
|
+
with self._lock:
|
|
1142
|
+
st = self._state.get(device_id, {})
|
|
1143
|
+
st["last_seen"] = time.time()
|
|
1144
|
+
st.pop("unreachable", None)
|
|
1145
|
+
st.pop("unreachable_since", None)
|
|
1146
|
+
st.pop("unreachable_until", None)
|
|
1147
|
+
self._state[device_id] = st
|
|
1148
|
+
self._persist(device_id)
|
|
1149
|
+
|
|
1150
|
+
def record_heartbeat(self, device_id: str, reported: dict | None = None) -> None:
|
|
1151
|
+
"""DEVICE-HEALTH-ACTIVE (b): a device proactively reported its own health.
|
|
1152
|
+
|
|
1153
|
+
This is PUSH health, complementing the hub's PULL probe. It matters for
|
|
1154
|
+
devices the hub cannot poll — behind NAT, sleeping, on networks that
|
|
1155
|
+
forbid inbound connections to the device — which can still assert
|
|
1156
|
+
liveness by reaching out. A heartbeat is POSITIVE SIGNAL, exactly like a
|
|
1157
|
+
successful probe or action: it marks the device reachable (clears any
|
|
1158
|
+
stale unreachable mark) and stamps last_heartbeat. It never marks a
|
|
1159
|
+
device unreachable — a device that stops sending heartbeats is simply a
|
|
1160
|
+
device we have not heard from, which is weaker evidence than an action
|
|
1161
|
+
timing out (the same asymmetry the passive path already preserves).
|
|
1162
|
+
"""
|
|
1163
|
+
with self._lock:
|
|
1164
|
+
st = self._state.get(device_id, {})
|
|
1165
|
+
now = time.time()
|
|
1166
|
+
st["last_seen"] = now
|
|
1167
|
+
st["last_heartbeat"] = now
|
|
1168
|
+
if reported:
|
|
1169
|
+
# A device may volunteer structured self-report (battery, rssi,
|
|
1170
|
+
# firmware…). Stored verbatim under a namespaced key; the hub
|
|
1171
|
+
# takes no position on its contents — it is the device's word.
|
|
1172
|
+
st["heartbeat_report"] = reported
|
|
1173
|
+
st.pop("unreachable", None)
|
|
1174
|
+
st.pop("unreachable_since", None)
|
|
1175
|
+
st.pop("unreachable_until", None)
|
|
1176
|
+
self._state[device_id] = st
|
|
1177
|
+
self._persist(device_id)
|
|
1178
|
+
|
|
1179
|
+
def mark_unreachable(self, device_id: str, ttl_seconds: int | None = None) -> None:
|
|
1180
|
+
"""A device did not respond to a real action — mark unreachable with TTL."""
|
|
1181
|
+
ttl = ttl_seconds if ttl_seconds is not None else self.DEFAULT_TTL_SECONDS
|
|
1182
|
+
with self._lock:
|
|
1183
|
+
st = self._state.get(device_id, {})
|
|
1184
|
+
now = time.time()
|
|
1185
|
+
st["unreachable"] = True
|
|
1186
|
+
st.setdefault("unreachable_since", now) # keep the first failure time
|
|
1187
|
+
st["unreachable_until"] = now + ttl
|
|
1188
|
+
self._state[device_id] = st
|
|
1189
|
+
self._persist(device_id)
|
|
1190
|
+
|
|
1191
|
+
def is_unreachable(self, device_id: str) -> bool:
|
|
1192
|
+
"""True if marked unreachable and the TTL has not expired."""
|
|
1193
|
+
with self._lock:
|
|
1194
|
+
st = self._state.get(device_id)
|
|
1195
|
+
if not st or not st.get("unreachable"):
|
|
1196
|
+
return False
|
|
1197
|
+
if time.time() >= st.get("unreachable_until", 0):
|
|
1198
|
+
return False # TTL expired — treat as unknown/recovered
|
|
1199
|
+
return True
|
|
1200
|
+
|
|
1201
|
+
def snapshot(self, device_id: str) -> dict:
|
|
1202
|
+
"""Honest per-device health. Never asserts 'off' — only what we observed."""
|
|
1203
|
+
with self._lock:
|
|
1204
|
+
st = dict(self._state.get(device_id, {}))
|
|
1205
|
+
unreachable = bool(st.get("unreachable")) and time.time() < st.get("unreachable_until", 0)
|
|
1206
|
+
return {
|
|
1207
|
+
"device_id": device_id,
|
|
1208
|
+
"reachable": (None if "last_seen" not in st and not unreachable
|
|
1209
|
+
else (not unreachable)),
|
|
1210
|
+
"last_seen": st.get("last_seen"),
|
|
1211
|
+
"last_heartbeat": st.get("last_heartbeat"),
|
|
1212
|
+
"heartbeat_report": st.get("heartbeat_report"),
|
|
1213
|
+
"unreachable_since": st.get("unreachable_since") if unreachable else None,
|
|
1214
|
+
"unreachable_until": st.get("unreachable_until") if unreachable else None,
|
|
1215
|
+
"note": ("no interaction recorded yet" if "last_seen" not in st and not unreachable
|
|
1216
|
+
else ("not responding to actions since the time shown (may be powered off "
|
|
1217
|
+
"or network-unreachable; passively observed)" if unreachable
|
|
1218
|
+
else ("last confirmed by a device-initiated heartbeat"
|
|
1219
|
+
if st.get("last_heartbeat") == st.get("last_seen")
|
|
1220
|
+
else "responded to its last action"))),
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
# DEVICE-HEALTH-ACTIVE (c) — powered-off vs network-unreachable.
|
|
1224
|
+
# The honest core: for a UDP device (WiZ), a command timeout looks identical
|
|
1225
|
+
# whether the bulb has no power or its wifi dropped — UDP has no connection
|
|
1226
|
+
# ACK to tell them apart. So (c) does NOT guess the cause from the timeout
|
|
1227
|
+
# alone (that would be a workaround). It CROSS-REFERENCES the independent
|
|
1228
|
+
# signal we already collect — the device-initiated heartbeat (part b) — and
|
|
1229
|
+
# returns a verdict CALIBRATED WITH ITS EVIDENCE, admitting "indeterminate"
|
|
1230
|
+
# when the transport genuinely cannot tell. A device that heartbeat'd seconds
|
|
1231
|
+
# ago but ignores a command was alive just now (network/app issue, not
|
|
1232
|
+
# power); one silent for a long time AND unresponsive is more likely off; one
|
|
1233
|
+
# that never heartbeats at all leaves us honestly unable to say.
|
|
1234
|
+
_HEARTBEAT_FRESH_S = 90.0 # a heartbeat this recent means "was alive just now"
|
|
1235
|
+
|
|
1236
|
+
def reachability_assessment(self, device_id: str, now: float | None = None) -> dict:
|
|
1237
|
+
"""Best available attribution for an unreachable device, with its
|
|
1238
|
+
evidence and a confidence level — never a bare guess."""
|
|
1239
|
+
now = now if now is not None else time.time()
|
|
1240
|
+
snap = self.snapshot(device_id)
|
|
1241
|
+
if snap["reachable"] is not False:
|
|
1242
|
+
return {"cause": "reachable", "confidence": "n/a",
|
|
1243
|
+
"evidence": "device is responding", "device_id": device_id}
|
|
1244
|
+
|
|
1245
|
+
last_hb = snap.get("last_heartbeat")
|
|
1246
|
+
hb_age = (now - last_hb) if last_hb else None
|
|
1247
|
+
|
|
1248
|
+
if hb_age is not None and hb_age < self._HEARTBEAT_FRESH_S:
|
|
1249
|
+
cause, conf, why = ("network_or_app", "high",
|
|
1250
|
+
f"heartbeat {hb_age:.0f}s ago but not responding to actions — the device "
|
|
1251
|
+
f"was alive just now, so this is a network or application fault, not power")
|
|
1252
|
+
elif last_hb is not None:
|
|
1253
|
+
cause, conf, why = ("likely_powered_off", "medium",
|
|
1254
|
+
f"no heartbeat for {hb_age:.0f}s and unresponsive — the device stopped "
|
|
1255
|
+
f"reaching out, consistent with loss of power (but a prolonged network "
|
|
1256
|
+
f"outage cannot be fully excluded)")
|
|
1257
|
+
else:
|
|
1258
|
+
cause, conf, why = ("indeterminate", "low",
|
|
1259
|
+
"device never sent a heartbeat, so power and network cannot be "
|
|
1260
|
+
"distinguished from a command timeout alone (transport limitation)")
|
|
1261
|
+
return {"cause": cause, "confidence": conf, "evidence": why,
|
|
1262
|
+
"device_id": device_id,
|
|
1263
|
+
"last_heartbeat_age_s": round(hb_age, 1) if hb_age is not None else None,
|
|
1264
|
+
"unreachable_since": snap.get("unreachable_since")}
|
|
1265
|
+
|
|
1266
|
+
def load_from_db(self) -> None:
|
|
1267
|
+
try:
|
|
1268
|
+
db = getattr(self._hub, "db", None)
|
|
1269
|
+
if db:
|
|
1270
|
+
with self._lock:
|
|
1271
|
+
self._state = {k: v for k, v in db.load_all_device_states().items()}
|
|
1272
|
+
except Exception as e:
|
|
1273
|
+
log.warning("DeviceHealth: failed to load state from db: %s", e)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
class DoSyncHub:
|
|
1277
|
+
"""
|
|
1278
|
+
Main entry point for the DoSync protocol.
|
|
1279
|
+
Owns the registry, resolver, and audit log.
|
|
1280
|
+
Exposes async methods for device registration and intent execution.
|
|
1281
|
+
"""
|
|
1282
|
+
|
|
1283
|
+
def __init__(self, db_path: str = "dosync.db"):
|
|
1284
|
+
self.registry = CapabilityRegistry()
|
|
1285
|
+
self.resolver = StateAwareResolver(self.registry, self)
|
|
1286
|
+
self.policy_engine = None # set via hub.policy_engine = PolicyEngine()
|
|
1287
|
+
self._active_intents: dict[str, int] = {} # intent_value -> priority
|
|
1288
|
+
self._active_intent_devices: dict[str, set] = {} # intent_value -> device_ids
|
|
1289
|
+
# v13 hygiene (parada técnica 2026-07-21, Paredes): progress_cb failures
|
|
1290
|
+
# are swallowed so an observer can't break execution — but swallowed !=
|
|
1291
|
+
# invisible. Count them so a real callback bug surfaces in /v1/status
|
|
1292
|
+
# instead of hiding in debug logs forever.
|
|
1293
|
+
self.progress_cb_failures: int = 0
|
|
1294
|
+
# Executor for HUB-INITIATED intents — those the hub raises on its own
|
|
1295
|
+
# (e.g. the capability-anomaly security alert), which have no caller to
|
|
1296
|
+
# supply one. Wired by the server at startup. If it is None the hub
|
|
1297
|
+
# cannot dispatch such an intent, and says so rather than failing quietly.
|
|
1298
|
+
self.default_executor = None
|
|
1299
|
+
self.audit_log = AuditLog()
|
|
1300
|
+
self.occupancy = OccupancyEngine()
|
|
1301
|
+
self.family_profile: FamilyProfile | None = None
|
|
1302
|
+
self._event_handlers: list[Callable] = []
|
|
1303
|
+
self.db = DoSyncDB(db_path)
|
|
1304
|
+
self.db.init()
|
|
1305
|
+
self.health = DeviceHealth(self) # hub-owned passive device health
|
|
1306
|
+
# Load persisted state now that db is ready
|
|
1307
|
+
if hasattr(self, "resolver"):
|
|
1308
|
+
self.resolver._load_state_from_db()
|
|
1309
|
+
self.health.load_from_db()
|
|
1310
|
+
self.audit_log._persist_cb = self.db.append_audit
|
|
1311
|
+
self._restore_from_db()
|
|
1312
|
+
|
|
1313
|
+
# ── Family profile ───────────────────────────────────────────────────────
|
|
1314
|
+
|
|
1315
|
+
# ── DB restore ──────────────────────────────────────────────────────────
|
|
1316
|
+
|
|
1317
|
+
async def start_state_refresh(
|
|
1318
|
+
self,
|
|
1319
|
+
executor: "DeviceExecutor",
|
|
1320
|
+
interval: float = None,
|
|
1321
|
+
) -> None:
|
|
1322
|
+
"""Hub-owned background state refresher — ACTIVE device health probing.
|
|
1323
|
+
|
|
1324
|
+
Periodically queries get_state() on every device whose adapter supports
|
|
1325
|
+
it, WITHOUT executing any action. Two effects:
|
|
1326
|
+
* hub.health.mark_reachable() on every responder — this is the active
|
|
1327
|
+
probing that makes recovery detectable within one interval, instead
|
|
1328
|
+
of waiting for the unreachable TTL to lapse or for some action to
|
|
1329
|
+
happen to succeed.
|
|
1330
|
+
* the resolver's own state cache is refreshed if it keeps one
|
|
1331
|
+
(StateAwareResolver.update_state), preserving redundancy detection
|
|
1332
|
+
for deployments that use it.
|
|
1333
|
+
|
|
1334
|
+
Until 2026-07-14 this lived on StateAwareResolver and server.py started
|
|
1335
|
+
it behind `isinstance(hub.resolver, StateAwareResolver)` — always False
|
|
1336
|
+
in production (which runs ExternalResolver), so it NEVER ran, silently,
|
|
1337
|
+
behind a log.debug. State refresh is a hub concern, not a resolver's:
|
|
1338
|
+
the same reasoning that moved device health to the hub.
|
|
1339
|
+
|
|
1340
|
+
POSITIVE SIGNAL ONLY, by deliberate design (preserved from the original):
|
|
1341
|
+
a device that does not answer get_state() is skipped, NOT marked
|
|
1342
|
+
unreachable. A failing get_state is weaker evidence than an action
|
|
1343
|
+
timing out (adapters implement it unevenly), and marking on weak
|
|
1344
|
+
evidence would manufacture false "dead device" reports.
|
|
1345
|
+
|
|
1346
|
+
Args:
|
|
1347
|
+
executor: AdapterExecutor to source adapters from
|
|
1348
|
+
interval: seconds between cycles. Defaults to the
|
|
1349
|
+
DOSYNC_STATE_REFRESH_INTERVAL env var (default 60).
|
|
1350
|
+
"""
|
|
1351
|
+
if interval is None:
|
|
1352
|
+
interval = float(os.environ.get("DOSYNC_STATE_REFRESH_INTERVAL", "60"))
|
|
1353
|
+
|
|
1354
|
+
log.info("Hub: background state refresh started (interval=%.0fs)", interval)
|
|
1355
|
+
|
|
1356
|
+
while True:
|
|
1357
|
+
try:
|
|
1358
|
+
await asyncio.sleep(interval)
|
|
1359
|
+
await self._state_refresh_cycle(executor)
|
|
1360
|
+
except asyncio.CancelledError:
|
|
1361
|
+
log.info("Hub: background state refresh stopped")
|
|
1362
|
+
break
|
|
1363
|
+
except Exception as e:
|
|
1364
|
+
log.warning("Hub: state refresh cycle error: %s", e)
|
|
1365
|
+
|
|
1366
|
+
async def _state_refresh_cycle(self, executor: "DeviceExecutor") -> None:
|
|
1367
|
+
"""One refresh cycle — probe every device whose adapter supports get_state()."""
|
|
1368
|
+
from .adapters import AdapterExecutor
|
|
1369
|
+
if not isinstance(executor, AdapterExecutor):
|
|
1370
|
+
return
|
|
1371
|
+
|
|
1372
|
+
refreshed = 0
|
|
1373
|
+
skipped = 0
|
|
1374
|
+
recovered: list[str] = []
|
|
1375
|
+
|
|
1376
|
+
for device in self.registry.all():
|
|
1377
|
+
adapter = executor.get_adapter(device.adapter)
|
|
1378
|
+
if adapter is None or not hasattr(adapter, "get_state"):
|
|
1379
|
+
skipped += 1
|
|
1380
|
+
continue
|
|
1381
|
+
|
|
1382
|
+
try:
|
|
1383
|
+
state = await asyncio.wait_for(
|
|
1384
|
+
adapter.get_state(device.device_id), timeout=3.0)
|
|
1385
|
+
except Exception:
|
|
1386
|
+
skipped += 1 # positive-signal only: no unreachable mark
|
|
1387
|
+
continue
|
|
1388
|
+
|
|
1389
|
+
if state is None:
|
|
1390
|
+
skipped += 1
|
|
1391
|
+
continue
|
|
1392
|
+
|
|
1393
|
+
# The device answered: it is reachable, right now, without us acting.
|
|
1394
|
+
was_unreachable = self.health.is_unreachable(device.device_id)
|
|
1395
|
+
self.health.mark_reachable(device.device_id)
|
|
1396
|
+
if was_unreachable:
|
|
1397
|
+
recovered.append(device.device_id)
|
|
1398
|
+
|
|
1399
|
+
# Keep a state-caching resolver coherent, if one is plugged in.
|
|
1400
|
+
_update = getattr(self.resolver, "update_state", None)
|
|
1401
|
+
if callable(_update):
|
|
1402
|
+
try:
|
|
1403
|
+
_update(device.device_id, state)
|
|
1404
|
+
except Exception as e:
|
|
1405
|
+
log.debug("Hub: resolver update_state failed for %s: %s",
|
|
1406
|
+
device.device_id, e)
|
|
1407
|
+
_clear = getattr(self.resolver, "clear_unreachable", None)
|
|
1408
|
+
if was_unreachable and callable(_clear):
|
|
1409
|
+
try:
|
|
1410
|
+
_clear(device.device_id)
|
|
1411
|
+
except Exception:
|
|
1412
|
+
pass
|
|
1413
|
+
refreshed += 1
|
|
1414
|
+
|
|
1415
|
+
for device_id in recovered:
|
|
1416
|
+
log.info("Hub: %s back online (detected by state refresh)", device_id)
|
|
1417
|
+
if refreshed:
|
|
1418
|
+
log.debug("Hub: state refresh done — %d probed, %d skipped, %d recovered",
|
|
1419
|
+
refreshed, skipped, len(recovered))
|
|
1420
|
+
|
|
1421
|
+
def _restore_from_db(self) -> None:
|
|
1422
|
+
"""
|
|
1423
|
+
Al iniciar el hub, restaura el estado desde SQLite.
|
|
1424
|
+
Los dispositivos, perfil y audit log sobreviven reinicios.
|
|
1425
|
+
"""
|
|
1426
|
+
from .models import (
|
|
1427
|
+
ActuatorSpec, CapabilityManifest, CertTier, ContextSignal,
|
|
1428
|
+
ContextSignalType, DeviceCategory, EventSpec, SensorSpec, Severity,
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
# Restore devices
|
|
1432
|
+
for manifest_dict in self.db.load_devices():
|
|
1433
|
+
try:
|
|
1434
|
+
# Rebuild CapabilityManifest from persisted dict
|
|
1435
|
+
caps = manifest_dict.get("capabilities", {})
|
|
1436
|
+
|
|
1437
|
+
sensors = [
|
|
1438
|
+
SensorSpec(
|
|
1439
|
+
id=s["id"], type=s["type"],
|
|
1440
|
+
description=s.get("description", ""),
|
|
1441
|
+
unit=s.get("unit"),
|
|
1442
|
+
poll_interval_ms=s.get("poll_interval_ms", 30000),
|
|
1443
|
+
kind=s.get("kind", "environment"), # legacy manifests default
|
|
1444
|
+
)
|
|
1445
|
+
for s in caps.get("sensors", [])
|
|
1446
|
+
]
|
|
1447
|
+
actuators = [
|
|
1448
|
+
ActuatorSpec(
|
|
1449
|
+
id=a["id"], type=a["type"],
|
|
1450
|
+
description=a.get("description", ""),
|
|
1451
|
+
)
|
|
1452
|
+
for a in caps.get("actuators", [])
|
|
1453
|
+
]
|
|
1454
|
+
events = [
|
|
1455
|
+
EventSpec(
|
|
1456
|
+
id=e["id"],
|
|
1457
|
+
severity=Severity(e.get("severity", "info")),
|
|
1458
|
+
description=e.get("description", ""),
|
|
1459
|
+
)
|
|
1460
|
+
for e in caps.get("events", [])
|
|
1461
|
+
]
|
|
1462
|
+
context_signals = [
|
|
1463
|
+
ContextSignal(
|
|
1464
|
+
type=ContextSignalType(c["type"]),
|
|
1465
|
+
description=c.get("description", ""),
|
|
1466
|
+
confidence_weight=c.get("confidence_weight", 1.0),
|
|
1467
|
+
)
|
|
1468
|
+
for c in caps.get("context_signals", [])
|
|
1469
|
+
]
|
|
1470
|
+
|
|
1471
|
+
manifest = CapabilityManifest(
|
|
1472
|
+
device_id=manifest_dict["device_id"],
|
|
1473
|
+
device_name=manifest_dict["device_name"],
|
|
1474
|
+
manufacturer=manifest_dict["manufacturer"],
|
|
1475
|
+
model=manifest_dict["model"],
|
|
1476
|
+
firmware=manifest_dict["firmware"],
|
|
1477
|
+
category=DeviceCategory(manifest_dict["category"]),
|
|
1478
|
+
tags=manifest_dict["tags"],
|
|
1479
|
+
sensors=sensors,
|
|
1480
|
+
actuators=actuators,
|
|
1481
|
+
events=events,
|
|
1482
|
+
context_signals=context_signals,
|
|
1483
|
+
emergency_capable=manifest_dict.get("emergency_capable", False),
|
|
1484
|
+
cert_tier=CertTier(manifest_dict["cert_tier"]) if manifest_dict.get("cert_tier") else None,
|
|
1485
|
+
)
|
|
1486
|
+
# Restore adapter fields — critical for physical device control
|
|
1487
|
+
if manifest_dict.get("adapter"):
|
|
1488
|
+
manifest.adapter = manifest_dict["adapter"]
|
|
1489
|
+
manifest.adapter_config = manifest_dict.get("adapter_config", {})
|
|
1490
|
+
self.registry.register(manifest)
|
|
1491
|
+
except Exception as e:
|
|
1492
|
+
log.warning("Could not restore device %s: %s",
|
|
1493
|
+
manifest_dict.get("device_id", "?"), e)
|
|
1494
|
+
|
|
1495
|
+
# Restore audit log. If older entries were archived to a segment file,
|
|
1496
|
+
# the chain starts at the stored anchor, not at genesis — both the
|
|
1497
|
+
# append continuity (_prev_hash) and verification (anchor_prev_hash)
|
|
1498
|
+
# must honor it.
|
|
1499
|
+
_anchor = self.db.get_audit_anchor()
|
|
1500
|
+
if _anchor:
|
|
1501
|
+
self.audit_log.anchor_prev_hash = _anchor.get("anchor_prev_hash", "0" * 64)
|
|
1502
|
+
self.audit_log._prev_hash = self.audit_log.anchor_prev_hash
|
|
1503
|
+
for entry in self.db.load_audit_log():
|
|
1504
|
+
self.audit_log._entries.append(entry)
|
|
1505
|
+
self.audit_log._prev_hash = entry.get("hash", "0" * 64)
|
|
1506
|
+
|
|
1507
|
+
# Restore family profile. Until 2026-07-14 this was MISSING: the profile
|
|
1508
|
+
# was persisted by set_family_profile() and db.load_family_profile()
|
|
1509
|
+
# existed, but nothing ever called it — so every restart silently dropped
|
|
1510
|
+
# the profile while this method's docstring promised it survives.
|
|
1511
|
+
try:
|
|
1512
|
+
profile_dict = self.db.load_family_profile()
|
|
1513
|
+
if profile_dict:
|
|
1514
|
+
from .models import FamilyProfile
|
|
1515
|
+
self.family_profile = FamilyProfile.from_dict(profile_dict)
|
|
1516
|
+
log.info("Restored family profile: %s", self.family_profile.family_name)
|
|
1517
|
+
except Exception as e:
|
|
1518
|
+
log.warning("Could not restore family profile: %s", e)
|
|
1519
|
+
|
|
1520
|
+
# Restore presence signals
|
|
1521
|
+
from .models import PresenceSignal
|
|
1522
|
+
for signal_dict in self.db.load_presence_signals():
|
|
1523
|
+
try:
|
|
1524
|
+
signal = PresenceSignal(
|
|
1525
|
+
device_id=signal_dict["device_id"],
|
|
1526
|
+
signal_type=ContextSignalType(signal_dict["signal_type"]),
|
|
1527
|
+
present=signal_dict["present"],
|
|
1528
|
+
confidence=signal_dict["confidence"],
|
|
1529
|
+
member_id=signal_dict.get("member_id"),
|
|
1530
|
+
timestamp=signal_dict.get("timestamp", time.time()),
|
|
1531
|
+
)
|
|
1532
|
+
self.occupancy._signals.append(signal)
|
|
1533
|
+
except Exception as e:
|
|
1534
|
+
log.warning("Could not restore presence signal: %s", e)
|
|
1535
|
+
|
|
1536
|
+
log.info(
|
|
1537
|
+
"Hub restored: %d device(s), %d audit entries",
|
|
1538
|
+
len(self.registry.all()),
|
|
1539
|
+
len(self.audit_log.entries()),
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
def set_family_profile(self, profile: FamilyProfile) -> None:
|
|
1543
|
+
"""Load the family profile into the hub and persist it."""
|
|
1544
|
+
self.family_profile = profile
|
|
1545
|
+
self.db.save_family_profile(profile.to_dict())
|
|
1546
|
+
self.audit_log.append({
|
|
1547
|
+
"type": "profile_loaded",
|
|
1548
|
+
"family_name": profile.family_name,
|
|
1549
|
+
"bedtime": f"{profile.bedtime_hour:02d}:{profile.bedtime_minute:02d}",
|
|
1550
|
+
})
|
|
1551
|
+
log.info("Family profile loaded: %s", profile.family_name)
|
|
1552
|
+
|
|
1553
|
+
# ── Occupancy / presence ─────────────────────────────────────────────────
|
|
1554
|
+
|
|
1555
|
+
def update_presence(self, signal: PresenceSignal) -> OccupancyState:
|
|
1556
|
+
"""A context provider updates its presence signal."""
|
|
1557
|
+
self.occupancy.update(signal)
|
|
1558
|
+
self.db.save_presence_signal(signal.device_id, {
|
|
1559
|
+
"device_id": signal.device_id,
|
|
1560
|
+
"signal_type": signal.signal_type.value,
|
|
1561
|
+
"present": signal.present,
|
|
1562
|
+
"confidence": signal.confidence,
|
|
1563
|
+
"member_id": signal.member_id,
|
|
1564
|
+
"timestamp": signal.timestamp,
|
|
1565
|
+
})
|
|
1566
|
+
state = self.occupancy.get_occupancy()
|
|
1567
|
+
self.audit_log.append({
|
|
1568
|
+
"type": "presence_updated",
|
|
1569
|
+
"device_id": signal.device_id,
|
|
1570
|
+
"signal_type": signal.signal_type.value,
|
|
1571
|
+
"present": signal.present,
|
|
1572
|
+
"confidence": signal.confidence,
|
|
1573
|
+
"occupied": state.occupied,
|
|
1574
|
+
"occ_confidence": state.confidence,
|
|
1575
|
+
})
|
|
1576
|
+
return state
|
|
1577
|
+
|
|
1578
|
+
def get_occupancy(self) -> OccupancyState:
|
|
1579
|
+
"""Current inferred occupancy state."""
|
|
1580
|
+
return self.occupancy.get_occupancy()
|
|
1581
|
+
|
|
1582
|
+
# ── Device management ────────────────────────────────────────────────────
|
|
1583
|
+
|
|
1584
|
+
def register_device(self, manifest: CapabilityManifest) -> None:
|
|
1585
|
+
"""
|
|
1586
|
+
Register or update a device in the hub registry.
|
|
1587
|
+
|
|
1588
|
+
On re-registration, classifies the change using firmware + capability diff
|
|
1589
|
+
(per DoSync spec §14) and emits the appropriate audit entry and events:
|
|
1590
|
+
|
|
1591
|
+
- No change: silent reconnect, no extra audit entry
|
|
1592
|
+
- firmware changed only: device_firmware_updated audit entry
|
|
1593
|
+
- caps changed (fw too): device_updated audit entry with diff
|
|
1594
|
+
- caps changed (same fw): device_capability_anomaly + alert_anomaly intent
|
|
1595
|
+
"""
|
|
1596
|
+
existing = self.registry.get(manifest.device_id)
|
|
1597
|
+
|
|
1598
|
+
# First-time registration
|
|
1599
|
+
if existing is None:
|
|
1600
|
+
self.registry.register(manifest)
|
|
1601
|
+
self.db.save_device(manifest.device_id, manifest.to_dict())
|
|
1602
|
+
self.audit_log.append({
|
|
1603
|
+
"type": "device_registered",
|
|
1604
|
+
"device_id": manifest.device_id,
|
|
1605
|
+
"device_name": manifest.device_name,
|
|
1606
|
+
})
|
|
1607
|
+
return
|
|
1608
|
+
|
|
1609
|
+
# ── Re-registration: compute diff ────────────────────────────────────
|
|
1610
|
+
fw_changed = existing.firmware != manifest.firmware
|
|
1611
|
+
ec_changed = existing.emergency_capable != manifest.emergency_capable
|
|
1612
|
+
tags_changed = set(existing.tags) != set(manifest.tags)
|
|
1613
|
+
act_changed = (
|
|
1614
|
+
sorted(a.type for a in existing.actuators) !=
|
|
1615
|
+
sorted(a.type for a in manifest.actuators)
|
|
1616
|
+
)
|
|
1617
|
+
caps_changed = ec_changed or tags_changed or act_changed
|
|
1618
|
+
|
|
1619
|
+
# Silent reconnect — nothing changed
|
|
1620
|
+
if not fw_changed and not caps_changed:
|
|
1621
|
+
self.registry.register(manifest)
|
|
1622
|
+
self.db.save_device(manifest.device_id, manifest.to_dict())
|
|
1623
|
+
return
|
|
1624
|
+
|
|
1625
|
+
# Build diff for audit
|
|
1626
|
+
diff = {}
|
|
1627
|
+
if fw_changed:
|
|
1628
|
+
diff["firmware"] = {"from": existing.firmware, "to": manifest.firmware}
|
|
1629
|
+
if ec_changed:
|
|
1630
|
+
diff["emergency_capable"] = {
|
|
1631
|
+
"from": existing.emergency_capable,
|
|
1632
|
+
"to": manifest.emergency_capable,
|
|
1633
|
+
}
|
|
1634
|
+
if tags_changed:
|
|
1635
|
+
diff["tags"] = {
|
|
1636
|
+
"added": list(set(manifest.tags) - set(existing.tags)),
|
|
1637
|
+
"removed": list(set(existing.tags) - set(manifest.tags)),
|
|
1638
|
+
}
|
|
1639
|
+
if act_changed:
|
|
1640
|
+
old_act = set(a.type for a in existing.actuators)
|
|
1641
|
+
new_act = set(a.type for a in manifest.actuators)
|
|
1642
|
+
diff["actuators"] = {
|
|
1643
|
+
"added": list(new_act - old_act),
|
|
1644
|
+
"removed": list(old_act - new_act),
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
# ── Dr. Esteves classification ────────────────────────────────────────
|
|
1648
|
+
# firmware changed + caps changed → expected firmware update
|
|
1649
|
+
# firmware changed + caps stable → minor firmware upgrade
|
|
1650
|
+
# firmware stable + caps changed → ANOMALY — alert
|
|
1651
|
+
if fw_changed and caps_changed:
|
|
1652
|
+
change_type = "firmware_update"
|
|
1653
|
+
elif fw_changed and not caps_changed:
|
|
1654
|
+
change_type = "firmware_upgrade_minor"
|
|
1655
|
+
else: # not fw_changed and caps_changed
|
|
1656
|
+
change_type = "capability_anomaly"
|
|
1657
|
+
|
|
1658
|
+
# Update registry and DB
|
|
1659
|
+
self.registry.register(manifest)
|
|
1660
|
+
self.db.save_device(manifest.device_id, manifest.to_dict())
|
|
1661
|
+
|
|
1662
|
+
# Emit audit entry
|
|
1663
|
+
if change_type == "capability_anomaly":
|
|
1664
|
+
self.audit_log.append({
|
|
1665
|
+
"type": "device_capability_anomaly",
|
|
1666
|
+
"device_id": manifest.device_id,
|
|
1667
|
+
"device_name": manifest.device_name,
|
|
1668
|
+
"diff": diff,
|
|
1669
|
+
"note": "Capabilities changed without firmware version change — may indicate compromise",
|
|
1670
|
+
})
|
|
1671
|
+
# Fire alert_anomaly intent for security-relevant changes
|
|
1672
|
+
import asyncio
|
|
1673
|
+
from dosync.models import Intent, IntentClass, Urgency
|
|
1674
|
+
alert_intent = Intent(
|
|
1675
|
+
intent=IntentClass("alert_anomaly"),
|
|
1676
|
+
urgency=Urgency.ALERT,
|
|
1677
|
+
context={
|
|
1678
|
+
"trigger": "device_capability_anomaly",
|
|
1679
|
+
"device_id": manifest.device_id,
|
|
1680
|
+
"device_name": manifest.device_name,
|
|
1681
|
+
"diff": diff,
|
|
1682
|
+
},
|
|
1683
|
+
source="hub",
|
|
1684
|
+
)
|
|
1685
|
+
# Fire the alert intent. This is a SECURITY path (capabilities changed
|
|
1686
|
+
# without a firmware bump), so "best-effort" must not mean "silent":
|
|
1687
|
+
# registration is never blocked, but a failure to raise the alert is
|
|
1688
|
+
# logged loudly rather than swallowed.
|
|
1689
|
+
#
|
|
1690
|
+
# asyncio.get_event_loop() was deprecated in 3.10 and is scheduled to
|
|
1691
|
+
# raise when no loop is running. Under the old code that would have
|
|
1692
|
+
# made the no-loop branch unreachable and dropped the alert into a
|
|
1693
|
+
# bare `except: pass` — the POL-2 failure mode (a silent except
|
|
1694
|
+
# hiding a broken security path). Both cases are now explicit.
|
|
1695
|
+
try:
|
|
1696
|
+
loop = asyncio.get_running_loop()
|
|
1697
|
+
except RuntimeError:
|
|
1698
|
+
loop = None
|
|
1699
|
+
|
|
1700
|
+
# The alert needs an executor. Until 2026-07-21 this call passed none
|
|
1701
|
+
# and raised TypeError on EVERY anomaly — swallowed whole by a bare
|
|
1702
|
+
# `except Exception: pass`, so this security alert had never once
|
|
1703
|
+
# fired. The anomaly itself was always audited (above); only the
|
|
1704
|
+
# dispatch was dead. Missing executor is now reported, not hidden.
|
|
1705
|
+
if self.default_executor is None:
|
|
1706
|
+
log.error(
|
|
1707
|
+
"Capability-anomaly alert for %s NOT dispatched: no default_executor "
|
|
1708
|
+
"wired on the hub. The anomaly is recorded in the audit chain, but no "
|
|
1709
|
+
"intent was raised.", manifest.device_id)
|
|
1710
|
+
loop = None
|
|
1711
|
+
alert_intent = None
|
|
1712
|
+
|
|
1713
|
+
if alert_intent is not None and loop is not None:
|
|
1714
|
+
# Inside a running loop (normal server path): schedule it without
|
|
1715
|
+
# blocking registration, but attach a callback so a failure in the
|
|
1716
|
+
# detached task is reported instead of vanishing.
|
|
1717
|
+
task = asyncio.ensure_future(
|
|
1718
|
+
self.execute_intent(alert_intent, self.default_executor))
|
|
1719
|
+
|
|
1720
|
+
def _report_alert_outcome(t: "asyncio.Future") -> None:
|
|
1721
|
+
if t.cancelled():
|
|
1722
|
+
log.warning("Capability-anomaly alert for %s was cancelled",
|
|
1723
|
+
manifest.device_id)
|
|
1724
|
+
return
|
|
1725
|
+
exc = t.exception()
|
|
1726
|
+
if exc is not None:
|
|
1727
|
+
log.error("Capability-anomaly alert for %s FAILED to execute: %s",
|
|
1728
|
+
manifest.device_id, exc)
|
|
1729
|
+
|
|
1730
|
+
task.add_done_callback(_report_alert_outcome)
|
|
1731
|
+
elif alert_intent is not None:
|
|
1732
|
+
# No running loop (CLI, migration scripts, sync tests): run it to
|
|
1733
|
+
# completion, as the previous run_until_complete branch did.
|
|
1734
|
+
try:
|
|
1735
|
+
asyncio.run(self.execute_intent(alert_intent, self.default_executor))
|
|
1736
|
+
except Exception as e:
|
|
1737
|
+
log.error("Capability-anomaly alert for %s FAILED to execute: %s",
|
|
1738
|
+
manifest.device_id, e)
|
|
1739
|
+
else:
|
|
1740
|
+
self.audit_log.append({
|
|
1741
|
+
"type": "device_updated" if caps_changed else "device_firmware_updated",
|
|
1742
|
+
"device_id": manifest.device_id,
|
|
1743
|
+
"device_name": manifest.device_name,
|
|
1744
|
+
"change_type": change_type,
|
|
1745
|
+
"diff": diff,
|
|
1746
|
+
})
|
|
1747
|
+
|
|
1748
|
+
def unregister_device(self, device_id: str) -> None:
|
|
1749
|
+
self.registry.unregister(device_id)
|
|
1750
|
+
self.db.delete_device(device_id)
|
|
1751
|
+
self.audit_log.append({"type": "device_unregistered", "device_id": device_id})
|
|
1752
|
+
|
|
1753
|
+
# ── Intent execution ─────────────────────────────────────────────────────
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
# ── FailurePolicy execution strategies ────────────────────────────────────
|
|
1757
|
+
|
|
1758
|
+
async def _execute_with_policy_cb(self, plan, executor, intent, progress_cb=None):
|
|
1759
|
+
"""MCP-V13: wrap the executor so each completed action can be published as
|
|
1760
|
+
partial progress WITHOUT changing any strategy signature. The wrapper
|
|
1761
|
+
fires progress_cb(result) as each action resolves; the strategies below
|
|
1762
|
+
are untouched. progress_cb is best-effort — a failing callback must never
|
|
1763
|
+
affect execution (an observer cannot break the observed)."""
|
|
1764
|
+
if progress_cb is not None:
|
|
1765
|
+
_inner = executor
|
|
1766
|
+
_hub = self
|
|
1767
|
+
|
|
1768
|
+
class _ProgressExecutor:
|
|
1769
|
+
def __getattr__(self, n): return getattr(_inner, n)
|
|
1770
|
+
async def execute(self, action, urgency):
|
|
1771
|
+
r = await _inner.execute(action, urgency)
|
|
1772
|
+
try:
|
|
1773
|
+
progress_cb(r)
|
|
1774
|
+
except Exception as _cb_e:
|
|
1775
|
+
_hub.progress_cb_failures += 1
|
|
1776
|
+
log.warning("progress_cb raised (ignored, count=%d): %s",
|
|
1777
|
+
_hub.progress_cb_failures, _cb_e)
|
|
1778
|
+
return r
|
|
1779
|
+
executor = _ProgressExecutor()
|
|
1780
|
+
return await self._execute_with_policy(plan, executor, intent)
|
|
1781
|
+
|
|
1782
|
+
async def _execute_with_policy(self, plan, executor, intent):
|
|
1783
|
+
"""Dispatch to the correct execution strategy based on failure_policy.
|
|
1784
|
+
Emergency intents always force CONTINUE — protocol-level guarantee."""
|
|
1785
|
+
from .models import FailurePolicy, Urgency
|
|
1786
|
+
policy = plan.failure_policy or FailurePolicy.CONTINUE
|
|
1787
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
1788
|
+
if policy == FailurePolicy.ABORT:
|
|
1789
|
+
log.info("FailurePolicy.ABORT overridden to CONTINUE for emergency '%s'", intent.intent)
|
|
1790
|
+
policy = FailurePolicy.CONTINUE
|
|
1791
|
+
if policy == FailurePolicy.ABORT:
|
|
1792
|
+
r, f, a = await self._execute_abort(plan.actions, executor, intent)
|
|
1793
|
+
return r, f, a, "abort"
|
|
1794
|
+
elif policy == FailurePolicy.RETRY:
|
|
1795
|
+
max_r = plan.max_retries if plan.max_retries else 1
|
|
1796
|
+
if intent.urgency == Urgency.EMERGENCY:
|
|
1797
|
+
max_r = 1
|
|
1798
|
+
r, f, a = await self._execute_retry(plan.actions, executor, intent, max_r)
|
|
1799
|
+
return r, f, a, "retry"
|
|
1800
|
+
else:
|
|
1801
|
+
r, f, a = await self._execute_parallel(plan.actions, executor, intent)
|
|
1802
|
+
return r, f, a, "continue"
|
|
1803
|
+
|
|
1804
|
+
async def _execute_parallel(self, actions, executor, intent):
|
|
1805
|
+
"""CONTINUE: execute all actions in parallel, failures never stop execution."""
|
|
1806
|
+
import os as _os
|
|
1807
|
+
from .models import ActionResult
|
|
1808
|
+
_t = float(_os.environ.get("DOSYNC_INTENT_TIMEOUT",
|
|
1809
|
+
"5.0" if intent.urgency.value == "emergency" else "10.0"))
|
|
1810
|
+
tasks = {asyncio.ensure_future(executor.execute(a, intent.urgency)): a for a in actions}
|
|
1811
|
+
results = []
|
|
1812
|
+
if tasks:
|
|
1813
|
+
done, pending = await asyncio.wait(tasks.keys(), timeout=_t)
|
|
1814
|
+
for fut in done:
|
|
1815
|
+
results.append(fut.result())
|
|
1816
|
+
for fut in pending:
|
|
1817
|
+
action = tasks[fut]
|
|
1818
|
+
log.warning("Timeout: %s/%s after %.1fs", action.device_id, action.action, _t)
|
|
1819
|
+
self.health.mark_unreachable(action.device_id)
|
|
1820
|
+
if hasattr(self.resolver, "mark_unreachable"):
|
|
1821
|
+
self.resolver.mark_unreachable(action.device_id)
|
|
1822
|
+
results.append(ActionResult(device_id=action.device_id, action=action.action,
|
|
1823
|
+
success=False, error=f"timeout after {_t}s"))
|
|
1824
|
+
fut.cancel()
|
|
1825
|
+
return results, [r.device_id for r in results if not r.success], []
|
|
1826
|
+
|
|
1827
|
+
async def _execute_abort(self, actions, executor, intent):
|
|
1828
|
+
"""ABORT: execute in batches by relevance_score. Cancel remaining if any batch fails."""
|
|
1829
|
+
import os as _os
|
|
1830
|
+
from .models import ActionResult
|
|
1831
|
+
_t = float(_os.environ.get("DOSYNC_INTENT_TIMEOUT",
|
|
1832
|
+
"5.0" if intent.urgency.value == "emergency" else "10.0"))
|
|
1833
|
+
sorted_actions = sorted(actions, key=lambda a: a.relevance_score, reverse=True)
|
|
1834
|
+
batches = [sorted_actions[i:i+5] for i in range(0, len(sorted_actions), 5)]
|
|
1835
|
+
all_results, aborted = [], []
|
|
1836
|
+
abort_triggered = False
|
|
1837
|
+
for idx, batch in enumerate(batches):
|
|
1838
|
+
if abort_triggered:
|
|
1839
|
+
for a in batch:
|
|
1840
|
+
aborted.append(a.device_id)
|
|
1841
|
+
all_results.append(ActionResult(device_id=a.device_id, action=a.action,
|
|
1842
|
+
success=False, error="aborted — prior batch failed", aborted=True))
|
|
1843
|
+
continue
|
|
1844
|
+
tasks = {asyncio.ensure_future(executor.execute(a, intent.urgency)): a for a in batch}
|
|
1845
|
+
done, pending = await asyncio.wait(tasks.keys(), timeout=_t)
|
|
1846
|
+
batch_results = [fut.result() for fut in done]
|
|
1847
|
+
for fut in pending:
|
|
1848
|
+
a = tasks[fut]
|
|
1849
|
+
self.health.mark_unreachable(a.device_id)
|
|
1850
|
+
if hasattr(self.resolver, "mark_unreachable"):
|
|
1851
|
+
self.resolver.mark_unreachable(a.device_id)
|
|
1852
|
+
batch_results.append(ActionResult(device_id=a.device_id, action=a.action,
|
|
1853
|
+
success=False, error=f"timeout after {_t}s"))
|
|
1854
|
+
fut.cancel()
|
|
1855
|
+
all_results.extend(batch_results)
|
|
1856
|
+
if any(not r.success for r in batch_results):
|
|
1857
|
+
log.warning("ABORT triggered after batch %d/%d — failures: %s",
|
|
1858
|
+
idx+1, len(batches), [r.device_id for r in batch_results if not r.success])
|
|
1859
|
+
abort_triggered = True
|
|
1860
|
+
failed = [r.device_id for r in all_results if not r.success and not r.aborted]
|
|
1861
|
+
return all_results, failed, aborted
|
|
1862
|
+
|
|
1863
|
+
async def _execute_retry(self, actions, executor, intent, max_retries):
|
|
1864
|
+
"""RETRY: retry each failed action up to max_retries with exponential backoff."""
|
|
1865
|
+
import os as _os
|
|
1866
|
+
from .models import ActionResult
|
|
1867
|
+
_t = float(_os.environ.get("DOSYNC_INTENT_TIMEOUT",
|
|
1868
|
+
"5.0" if intent.urgency.value == "emergency" else "10.0"))
|
|
1869
|
+
|
|
1870
|
+
async def _with_retry(action):
|
|
1871
|
+
last = None
|
|
1872
|
+
for attempt in range(max_retries + 1):
|
|
1873
|
+
if attempt > 0:
|
|
1874
|
+
backoff = 0.5 * (2 ** (attempt - 1))
|
|
1875
|
+
log.info("RETRY %d/%d for %s (backoff %.1fs)", attempt, max_retries,
|
|
1876
|
+
action.device_id, backoff)
|
|
1877
|
+
await asyncio.sleep(backoff)
|
|
1878
|
+
try:
|
|
1879
|
+
r = await asyncio.wait_for(executor.execute(action, intent.urgency), timeout=_t)
|
|
1880
|
+
r.retries = attempt
|
|
1881
|
+
if r.success:
|
|
1882
|
+
return r
|
|
1883
|
+
last = r
|
|
1884
|
+
except asyncio.TimeoutError:
|
|
1885
|
+
self.health.mark_unreachable(action.device_id)
|
|
1886
|
+
if hasattr(self.resolver, "mark_unreachable"):
|
|
1887
|
+
self.resolver.mark_unreachable(action.device_id)
|
|
1888
|
+
last = ActionResult(device_id=action.device_id, action=action.action,
|
|
1889
|
+
success=False, error=f"timeout (attempt {attempt+1}/{max_retries+1})",
|
|
1890
|
+
retries=attempt)
|
|
1891
|
+
log.warning("RETRY exhausted for %s after %d attempt(s)", action.device_id, max_retries+1)
|
|
1892
|
+
return last or ActionResult(device_id=action.device_id, action=action.action,
|
|
1893
|
+
success=False, error=f"exhausted {max_retries} retries", retries=max_retries)
|
|
1894
|
+
|
|
1895
|
+
results = list(await asyncio.gather(*[_with_retry(a) for a in actions]))
|
|
1896
|
+
return results, [r.device_id for r in results if not r.success], []
|
|
1897
|
+
|
|
1898
|
+
def _validate_plan_params(self, plan, intent):
|
|
1899
|
+
"""Validate each action's params against its actuator's JSON Schema.
|
|
1900
|
+
|
|
1901
|
+
Returns (filtered_plan, rejected). Valid actions stay in the plan; each
|
|
1902
|
+
invalid action is dropped and recorded — both in the returned `rejected`
|
|
1903
|
+
list and in the audit log — so nothing is silently discarded.
|
|
1904
|
+
|
|
1905
|
+
Rejection here means "the mind asked for something the actuator declared
|
|
1906
|
+
it does not accept" — distinct from a device that fails to respond at
|
|
1907
|
+
execution time. The audit entry type makes that distinction explicit.
|
|
1908
|
+
"""
|
|
1909
|
+
from .models import ActionPlan as _AP
|
|
1910
|
+
from .validation import validate_params
|
|
1911
|
+
|
|
1912
|
+
valid, rejected = [], []
|
|
1913
|
+
for action in plan.actions:
|
|
1914
|
+
device = self.registry.get(action.device_id)
|
|
1915
|
+
schema = None
|
|
1916
|
+
if device:
|
|
1917
|
+
for act in device.actuators:
|
|
1918
|
+
if act.type == action.action:
|
|
1919
|
+
schema = getattr(act, "params_schema", None)
|
|
1920
|
+
break
|
|
1921
|
+
# No device or no schema for this action → nothing to validate against.
|
|
1922
|
+
if not schema:
|
|
1923
|
+
valid.append(action)
|
|
1924
|
+
continue
|
|
1925
|
+
|
|
1926
|
+
ok, err = validate_params(schema, action.params or {})
|
|
1927
|
+
if ok:
|
|
1928
|
+
valid.append(action)
|
|
1929
|
+
else:
|
|
1930
|
+
rejected.append((action, err))
|
|
1931
|
+
log.warning(
|
|
1932
|
+
"Action rejected by param validation: %s.%s — %s",
|
|
1933
|
+
action.device_id, action.action, err,
|
|
1934
|
+
)
|
|
1935
|
+
self.audit_log.append({
|
|
1936
|
+
"type": "action_rejected_invalid_params",
|
|
1937
|
+
"intent_id": intent.intent_id,
|
|
1938
|
+
"intent": intent.intent.value,
|
|
1939
|
+
"device_id": action.device_id,
|
|
1940
|
+
"action": action.action,
|
|
1941
|
+
"params": action.params,
|
|
1942
|
+
"reason": err,
|
|
1943
|
+
"source": getattr(intent, "source", "api"),
|
|
1944
|
+
})
|
|
1945
|
+
|
|
1946
|
+
filtered = _AP(intent_id=plan.intent_id, actions=valid, urgency=plan.urgency,
|
|
1947
|
+
failure_policy=getattr(plan, "failure_policy", None),
|
|
1948
|
+
max_retries=getattr(plan, "max_retries", 1))
|
|
1949
|
+
return filtered, rejected
|
|
1950
|
+
|
|
1951
|
+
# ── Long-running operations: split + write-ahead (execution_model) ────────
|
|
1952
|
+
# This is a SELF-CONTAINED sub-protocol layered on top of intent execution.
|
|
1953
|
+
# The instant path (every existing action) is untouched: these helpers only
|
|
1954
|
+
# ever handle actions whose actuator declares execution_model == "long_running".
|
|
1955
|
+
# An implementer in another language can read this block as one unit.
|
|
1956
|
+
|
|
1957
|
+
def _action_execution_model(self, action: "DeviceAction") -> tuple[str, bool]:
|
|
1958
|
+
"""Return (execution_model, emits_telemetry) for an action by looking up
|
|
1959
|
+
its actuator in the device manifest. Defaults to ("instant", False) when the
|
|
1960
|
+
device/actuator is unknown — an unknown action is treated as instant, never
|
|
1961
|
+
long-running, so a missing manifest can never strand an operation."""
|
|
1962
|
+
manifest = self.registry.get(action.device_id)
|
|
1963
|
+
if not manifest:
|
|
1964
|
+
return ("instant", False)
|
|
1965
|
+
for act in manifest.actuators:
|
|
1966
|
+
if act.type == action.action:
|
|
1967
|
+
return (getattr(act, "execution_model", "instant"),
|
|
1968
|
+
getattr(act, "emits_telemetry", False))
|
|
1969
|
+
return ("instant", False)
|
|
1970
|
+
|
|
1971
|
+
def _split_plan_by_execution_model(self, plan):
|
|
1972
|
+
"""Split a plan's actions into (instant_actions, long_running_actions).
|
|
1973
|
+
Each action goes to exactly one list — no action is ever in both."""
|
|
1974
|
+
instant, long_running = [], []
|
|
1975
|
+
for action in plan.actions:
|
|
1976
|
+
model, _ = self._action_execution_model(action)
|
|
1977
|
+
(long_running if model == "long_running" else instant).append(action)
|
|
1978
|
+
return instant, long_running
|
|
1979
|
+
|
|
1980
|
+
async def _start_long_running_actions(self, long_running_actions, executor, intent):
|
|
1981
|
+
"""For each long-running action: WRITE-AHEAD (create + persist the operation
|
|
1982
|
+
in `pending` BEFORE dispatching), then dispatch, then transition by the
|
|
1983
|
+
dispatch result. Returns a list of {operation_id, device_id, state} for the
|
|
1984
|
+
IntentResult. Never blocks waiting for the action to finish — it only starts.
|
|
1985
|
+
|
|
1986
|
+
Panel rules honored here:
|
|
1987
|
+
- write-ahead: persist `pending` before dispatch, so a crash mid-dispatch
|
|
1988
|
+
never leaves a running device with no operation record.
|
|
1989
|
+
- silence != success: a successful DISPATCH means the device ACCEPTED the
|
|
1990
|
+
command, not that it finished. For a telemetry device the operation waits
|
|
1991
|
+
in `in_progress` for telemetry to confirm/advance; for a core device
|
|
1992
|
+
(no telemetry) the successful dispatch is the only signal there will be,
|
|
1993
|
+
so it goes to `in_progress` and a later positive signal completes it.
|
|
1994
|
+
- graceful degradation: an adapter that doesn't cooperate just returns a
|
|
1995
|
+
normal ActionResult; the operation is resolved from it. No adapter is
|
|
1996
|
+
required to understand operations.
|
|
1997
|
+
"""
|
|
1998
|
+
from .operations import Operation, OperationState
|
|
1999
|
+
|
|
2000
|
+
started = []
|
|
2001
|
+
for action in long_running_actions:
|
|
2002
|
+
_, emits_telemetry = self._action_execution_model(action)
|
|
2003
|
+
op = Operation(
|
|
2004
|
+
device_id=action.device_id,
|
|
2005
|
+
action=action.action,
|
|
2006
|
+
telemetry_capable=emits_telemetry,
|
|
2007
|
+
)
|
|
2008
|
+
# WRITE-AHEAD: persist in `pending` before we touch the device.
|
|
2009
|
+
self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
|
|
2010
|
+
self.audit_log.append({
|
|
2011
|
+
"type": "operation_created",
|
|
2012
|
+
"operation_id": op.operation_id,
|
|
2013
|
+
"intent_id": intent.intent_id,
|
|
2014
|
+
"device_id": action.device_id,
|
|
2015
|
+
"action": action.action,
|
|
2016
|
+
"state": op.state.value,
|
|
2017
|
+
})
|
|
2018
|
+
|
|
2019
|
+
# Dispatch (start the action). We do NOT wait for it to finish.
|
|
2020
|
+
try:
|
|
2021
|
+
result = await executor.execute(action, intent.urgency)
|
|
2022
|
+
dispatch_ok = bool(getattr(result, "success", False))
|
|
2023
|
+
err = getattr(result, "error", None)
|
|
2024
|
+
except Exception as e: # an adapter blowing up must not strand the op
|
|
2025
|
+
dispatch_ok = False
|
|
2026
|
+
err = str(e)
|
|
2027
|
+
|
|
2028
|
+
if dispatch_ok:
|
|
2029
|
+
# Accepted by the device. NOT completed — started.
|
|
2030
|
+
op.transition_to(OperationState.IN_PROGRESS,
|
|
2031
|
+
reason="dispatch accepted by device")
|
|
2032
|
+
else:
|
|
2033
|
+
# The device refused / dispatch failed → the operation never ran.
|
|
2034
|
+
op.transition_to(OperationState.FAILED,
|
|
2035
|
+
reason=f"dispatch failed: {err}" if err else "dispatch failed")
|
|
2036
|
+
|
|
2037
|
+
self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
|
|
2038
|
+
self.audit_log.append({
|
|
2039
|
+
"type": "operation_transition",
|
|
2040
|
+
"operation_id": op.operation_id,
|
|
2041
|
+
"intent_id": intent.intent_id,
|
|
2042
|
+
"from_state": "pending",
|
|
2043
|
+
"to_state": op.state.value,
|
|
2044
|
+
})
|
|
2045
|
+
started.append({
|
|
2046
|
+
"operation_id": op.operation_id,
|
|
2047
|
+
"device_id": action.device_id,
|
|
2048
|
+
"state": op.state.value,
|
|
2049
|
+
})
|
|
2050
|
+
return started
|
|
2051
|
+
|
|
2052
|
+
def apply_telemetry(self, device_id: str, event, reason: str = "",
|
|
2053
|
+
phase: str = None, now: float = None) -> dict:
|
|
2054
|
+
"""Apply one telemetry fact to a device's active operation.
|
|
2055
|
+
|
|
2056
|
+
This is the GENERIC bridge between any telemetry-emitting adapter and the
|
|
2057
|
+
operation state machine — the first thing in the hub to actually drive the
|
|
2058
|
+
reconciler. It is deliberately device-agnostic: it speaks only the abstract
|
|
2059
|
+
TelemetryEvent vocabulary, knows nothing of MAVLink, drones, or flight. An
|
|
2060
|
+
oven that one day emits telemetry would call this identically. The adapter's
|
|
2061
|
+
job is to translate its device-native signal into a TelemetryEvent; the
|
|
2062
|
+
hub's job (here) is to find the operation, reconcile, persist, and audit.
|
|
2063
|
+
|
|
2064
|
+
Flow:
|
|
2065
|
+
1. Find the device's single active (non-terminal) operation.
|
|
2066
|
+
2. Rehydrate it faithfully from the DB (preserving time_in_state/history).
|
|
2067
|
+
3. Let the stateless reconciler apply the fact (telemetry wins; illegal
|
|
2068
|
+
or duplicate facts are no-ops, never crashes).
|
|
2069
|
+
4. Persist the (possibly unchanged) operation and audit the outcome.
|
|
2070
|
+
|
|
2071
|
+
Returns a small dict describing what happened — changed/no-op, the
|
|
2072
|
+
from/to states, and a note — so the caller (and tests) can see the result
|
|
2073
|
+
without re-reading the DB. Returns {"matched": False} when the device has
|
|
2074
|
+
no active operation (a stray telemetry packet for an idle device is not an
|
|
2075
|
+
error — it is simply ignored).
|
|
2076
|
+
|
|
2077
|
+
Safety note: a telemetry fact is the ONLY thing that advances an operation
|
|
2078
|
+
toward completion. Silence never does. A disconnection is not a fact and
|
|
2079
|
+
must not be passed here as one — it does not advance anything.
|
|
2080
|
+
"""
|
|
2081
|
+
from .operations import Operation
|
|
2082
|
+
from .reconciler import OperationReconciler, TelemetryEvent
|
|
2083
|
+
|
|
2084
|
+
if isinstance(event, str):
|
|
2085
|
+
event = TelemetryEvent(event)
|
|
2086
|
+
|
|
2087
|
+
# 1. Find the device's active operation. There should be at most one
|
|
2088
|
+
# non-terminal operation per device at a time; if several exist (a
|
|
2089
|
+
# pathological state), reconcile the most recent, which get_active_
|
|
2090
|
+
# operations returns first (ORDER BY created_at DESC).
|
|
2091
|
+
active = [o for o in self.db.get_active_operations()
|
|
2092
|
+
if o.get("device_id") == device_id]
|
|
2093
|
+
if not active:
|
|
2094
|
+
log.debug("apply_telemetry: no active operation for %s (event '%s' ignored)",
|
|
2095
|
+
device_id, event.value)
|
|
2096
|
+
return {"matched": False, "device_id": device_id, "event": event.value}
|
|
2097
|
+
|
|
2098
|
+
op_dict = active[0]
|
|
2099
|
+
op = Operation.from_dict(op_dict)
|
|
2100
|
+
|
|
2101
|
+
# Carry an updated sub-phase if the adapter reported one (e.g. "arming").
|
|
2102
|
+
# The protocol never interprets this string; it is domain detail.
|
|
2103
|
+
if phase is not None:
|
|
2104
|
+
op.phase = phase
|
|
2105
|
+
|
|
2106
|
+
# 2 + 3. Reconcile — pure logic, decides the state change (or no-op).
|
|
2107
|
+
reconciler = OperationReconciler()
|
|
2108
|
+
result = reconciler.reconcile(op, event, reason=reason, now=now)
|
|
2109
|
+
|
|
2110
|
+
# 4. Persist (even a no-op may have updated `phase`) and audit.
|
|
2111
|
+
self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
|
|
2112
|
+
self.audit_log.append({
|
|
2113
|
+
"type": "operation_telemetry",
|
|
2114
|
+
"operation_id": op.operation_id,
|
|
2115
|
+
"device_id": device_id,
|
|
2116
|
+
"event": event.value,
|
|
2117
|
+
"changed": result.changed,
|
|
2118
|
+
"from_state": result.from_state.value,
|
|
2119
|
+
"to_state": result.to_state.value,
|
|
2120
|
+
"note": result.note,
|
|
2121
|
+
})
|
|
2122
|
+
|
|
2123
|
+
return {
|
|
2124
|
+
"matched": True,
|
|
2125
|
+
"operation_id": op.operation_id,
|
|
2126
|
+
"device_id": device_id,
|
|
2127
|
+
"event": event.value,
|
|
2128
|
+
"changed": result.changed,
|
|
2129
|
+
"from_state": result.from_state.value,
|
|
2130
|
+
"to_state": result.to_state.value,
|
|
2131
|
+
"note": result.note,
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
# ── Composite intents (Level 2: the nervous system) ───────────────────────
|
|
2135
|
+
# A composition intent (e.g. inspect_area) does not resolve to a flat parallel
|
|
2136
|
+
# ActionPlan. It composes an ORDERED SEQUENCE of atomic operations that the
|
|
2137
|
+
# OperationSupervisor drives in a closed loop — dispatch a step, wait for its
|
|
2138
|
+
# confirmed arrival via reconciled telemetry, then the next, reacting to guards
|
|
2139
|
+
# each tick. This is the brain coordinating the body, not fire-and-forget.
|
|
2140
|
+
#
|
|
2141
|
+
# The pieces (all built and tested independently):
|
|
2142
|
+
# RouteComposer -> the ordered steps (geometry)
|
|
2143
|
+
# CompositeOperation -> the structure that holds them
|
|
2144
|
+
# OperationSupervisor -> the closed-loop driver
|
|
2145
|
+
# OperationGuards -> real-time in-flight monitoring
|
|
2146
|
+
# This method wires them to the live hub (PolicyEngine + executor + telemetry).
|
|
2147
|
+
|
|
2148
|
+
async def _dispatch_composite_step(self, step, executor, intent):
|
|
2149
|
+
"""Dispatch ONE composite step as a long-running atomic operation, AFTER the
|
|
2150
|
+
PolicyEngine admits it. Returns the operation_id the supervisor will watch.
|
|
2151
|
+
|
|
2152
|
+
CRITICAL (panel): the composition path does NOT bypass admission security.
|
|
2153
|
+
Every step is evaluated by the PolicyEngine first — the admission geofence,
|
|
2154
|
+
rate limits, manual-control lockout, everything — exactly as a normal intent
|
|
2155
|
+
is. A blocked step raises, the supervisor sees the failed dispatch and aborts.
|
|
2156
|
+
|
|
2157
|
+
Reuses the write-ahead pattern of _start_long_running_actions: persist the
|
|
2158
|
+
operation in `pending` BEFORE touching the device, dispatch, transition by the
|
|
2159
|
+
result. The operation then advances to `completed` later, when telemetry
|
|
2160
|
+
confirms arrival (apply_telemetry) — which is what the supervisor polls for.
|
|
2161
|
+
"""
|
|
2162
|
+
from .operations import Operation, OperationState
|
|
2163
|
+
from .models import DeviceAction, ActionPlan as _AP
|
|
2164
|
+
|
|
2165
|
+
action = DeviceAction(
|
|
2166
|
+
device_id=step.device_id, action=step.action, params=dict(step.params or {}))
|
|
2167
|
+
|
|
2168
|
+
# ── PolicyEngine admission for THIS step ──────────────────────────────
|
|
2169
|
+
if self.policy_engine:
|
|
2170
|
+
from .policies import PolicyDecision
|
|
2171
|
+
single_plan = _AP(intent_id=intent.intent_id, actions=[action],
|
|
2172
|
+
urgency=intent.urgency)
|
|
2173
|
+
result = self.policy_engine.evaluate(intent, single_plan)
|
|
2174
|
+
if result.decision == PolicyDecision.BLOCK:
|
|
2175
|
+
self.audit_log.append({
|
|
2176
|
+
"type": "composite_step_blocked",
|
|
2177
|
+
"intent_id": intent.intent_id,
|
|
2178
|
+
"device_id": step.device_id,
|
|
2179
|
+
"action": step.action,
|
|
2180
|
+
"policy": result.policy_name,
|
|
2181
|
+
"reason": result.reason,
|
|
2182
|
+
})
|
|
2183
|
+
raise PermissionError(
|
|
2184
|
+
f"step '{step.action}' blocked by policy '{result.policy_name}': "
|
|
2185
|
+
f"{result.reason}")
|
|
2186
|
+
if result.decision == PolicyDecision.MODIFY and result.modified_actions:
|
|
2187
|
+
action = result.modified_actions[0]
|
|
2188
|
+
|
|
2189
|
+
# ── Write-ahead: persist `pending` before dispatch ───────────────────
|
|
2190
|
+
_, emits_telemetry = self._action_execution_model(action)
|
|
2191
|
+
op = Operation(device_id=action.device_id, action=action.action,
|
|
2192
|
+
telemetry_capable=emits_telemetry)
|
|
2193
|
+
self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
|
|
2194
|
+
self.audit_log.append({
|
|
2195
|
+
"type": "operation_created", "operation_id": op.operation_id,
|
|
2196
|
+
"intent_id": intent.intent_id, "device_id": action.device_id,
|
|
2197
|
+
"action": action.action, "state": op.state.value,
|
|
2198
|
+
"composite": True,
|
|
2199
|
+
})
|
|
2200
|
+
|
|
2201
|
+
# ── Dispatch (start the step). Do NOT wait for it to finish here ──────
|
|
2202
|
+
try:
|
|
2203
|
+
res = await executor.execute(action, intent.urgency)
|
|
2204
|
+
dispatch_ok = bool(getattr(res, "success", False))
|
|
2205
|
+
err = getattr(res, "error", None)
|
|
2206
|
+
except Exception as e:
|
|
2207
|
+
dispatch_ok, err = False, str(e)
|
|
2208
|
+
|
|
2209
|
+
if dispatch_ok:
|
|
2210
|
+
op.transition_to(OperationState.IN_PROGRESS, reason="dispatch accepted")
|
|
2211
|
+
else:
|
|
2212
|
+
op.transition_to(OperationState.FAILED,
|
|
2213
|
+
reason=f"dispatch failed: {err}" if err else "dispatch failed")
|
|
2214
|
+
self.db.save_operation(op.to_dict(), terminal=op.is_terminal)
|
|
2215
|
+
self.audit_log.append({
|
|
2216
|
+
"type": "operation_transition", "operation_id": op.operation_id,
|
|
2217
|
+
"intent_id": intent.intent_id, "from_state": "pending",
|
|
2218
|
+
"to_state": op.state.value, "composite": True,
|
|
2219
|
+
})
|
|
2220
|
+
return op.operation_id
|
|
2221
|
+
|
|
2222
|
+
def _read_operation_state(self, operation_id):
|
|
2223
|
+
"""Read the reconciled atomic state of an operation (the state telemetry
|
|
2224
|
+
keeps current). The supervisor polls THIS — never the raw telemetry queue."""
|
|
2225
|
+
row = self.db.get_operation(operation_id)
|
|
2226
|
+
return row.get("state") if row else None
|
|
2227
|
+
|
|
2228
|
+
def _build_guard_context_provider(self, device_id):
|
|
2229
|
+
"""Build the GuardContext provider the supervisor calls each tick.
|
|
2230
|
+
|
|
2231
|
+
HONEST SCOPE (panel): it fills what the CURRENT data allows and leaves the
|
|
2232
|
+
rest None. The guards abstain on None (verified), so a guard whose signal is
|
|
2233
|
+
not yet flowing simply does not fire — no dead code, no false promise.
|
|
2234
|
+
|
|
2235
|
+
Active with real data TODAY:
|
|
2236
|
+
- manual_control_active: from the device's operation reaching `interrupted`
|
|
2237
|
+
(a human took control — apply_telemetry sets this from MANUAL_CONTROL_TAKEN)
|
|
2238
|
+
- seconds_in_step: from the active operation's time_in_state
|
|
2239
|
+
|
|
2240
|
+
Waiting on continuous position/battery telemetry (guards already built+tested,
|
|
2241
|
+
activate automatically once the provider starts filling these):
|
|
2242
|
+
- lat/lon/alt -> GeofenceGuard in flight
|
|
2243
|
+
- battery_percent -> BatteryGuard
|
|
2244
|
+
A future step publishes a per-device telemetry snapshot; until then these stay
|
|
2245
|
+
None and their guards correctly abstain.
|
|
2246
|
+
"""
|
|
2247
|
+
from .operation_guards import GuardContext
|
|
2248
|
+
|
|
2249
|
+
def provider(comp):
|
|
2250
|
+
manual = False
|
|
2251
|
+
seconds_in_step = None
|
|
2252
|
+
try:
|
|
2253
|
+
for op in self.db.get_active_operations():
|
|
2254
|
+
if op.get("device_id") != device_id:
|
|
2255
|
+
continue
|
|
2256
|
+
if op.get("state") == "interrupted":
|
|
2257
|
+
manual = True
|
|
2258
|
+
entered = op.get("state_entered_at")
|
|
2259
|
+
if entered is not None:
|
|
2260
|
+
import time as _t
|
|
2261
|
+
seconds_in_step = _t.time() - entered
|
|
2262
|
+
except Exception:
|
|
2263
|
+
pass # never let context-building crash the supervisor
|
|
2264
|
+
return GuardContext(
|
|
2265
|
+
device_id=device_id,
|
|
2266
|
+
manual_control_active=manual,
|
|
2267
|
+
seconds_in_step=seconds_in_step,
|
|
2268
|
+
# lat/lon/alt/battery/seconds_since_telemetry: None until a
|
|
2269
|
+
# continuous telemetry snapshot feeds them (guards abstain on None).
|
|
2270
|
+
)
|
|
2271
|
+
return provider
|
|
2272
|
+
|
|
2273
|
+
async def execute_composite_intent(self, intent, executor, context, guard_set=None,
|
|
2274
|
+
config=None):
|
|
2275
|
+
"""Execute a composition intent (e.g. inspect_area) as a supervised, closed-
|
|
2276
|
+
loop sequence. Composes the route, builds the CompositeOperation, wires the
|
|
2277
|
+
supervisor to the live hub (PolicyEngine-gated dispatch, reconciled-state
|
|
2278
|
+
polling, guard provider), and runs it.
|
|
2279
|
+
|
|
2280
|
+
Args:
|
|
2281
|
+
intent: the composition Intent (its .context carries center/radius/etc.)
|
|
2282
|
+
executor: the device executor (dispatches atomic steps)
|
|
2283
|
+
context: the resolution context for the composer (center, radius_m, ...)
|
|
2284
|
+
guard_set: an optional OperationGuards.GuardSet; if None, no guards beyond
|
|
2285
|
+
the supervisor's own stall backstop.
|
|
2286
|
+
config: optional SupervisorConfig (poll cadence, step timeout).
|
|
2287
|
+
|
|
2288
|
+
Returns the terminal CompositeState.
|
|
2289
|
+
"""
|
|
2290
|
+
from .route_composer import RouteComposer
|
|
2291
|
+
from .composite_operations import CompositeOperation
|
|
2292
|
+
from .operation_supervisor import OperationSupervisor
|
|
2293
|
+
|
|
2294
|
+
device_id = context.get("device_id")
|
|
2295
|
+
if not device_id:
|
|
2296
|
+
raise ValueError("execute_composite_intent requires context['device_id']")
|
|
2297
|
+
|
|
2298
|
+
# 1. Compose the ordered steps (geometry) — the cerebellum's translation.
|
|
2299
|
+
steps = RouteComposer().compose_inspect_area(device_id, context)
|
|
2300
|
+
|
|
2301
|
+
# 2. The structure that holds and tracks them.
|
|
2302
|
+
comp = CompositeOperation(device_id=device_id, intent=intent.intent.value,
|
|
2303
|
+
steps=steps, context=context)
|
|
2304
|
+
self.audit_log.append({
|
|
2305
|
+
"type": "composite_started", "composite_id": comp.composite_id,
|
|
2306
|
+
"intent_id": intent.intent_id, "intent": intent.intent.value,
|
|
2307
|
+
"device_id": device_id, "steps": len(steps),
|
|
2308
|
+
})
|
|
2309
|
+
|
|
2310
|
+
# 3. Wire the supervisor to the live hub.
|
|
2311
|
+
async def dispatch(step):
|
|
2312
|
+
return await self._dispatch_composite_step(step, executor, intent)
|
|
2313
|
+
|
|
2314
|
+
guard_fn = None
|
|
2315
|
+
if guard_set is not None:
|
|
2316
|
+
guard_fn = guard_set.make_guard_fn(
|
|
2317
|
+
self._build_guard_context_provider(device_id))
|
|
2318
|
+
|
|
2319
|
+
supervisor = OperationSupervisor(
|
|
2320
|
+
dispatch=dispatch,
|
|
2321
|
+
read_state=self._read_operation_state,
|
|
2322
|
+
guard=guard_fn,
|
|
2323
|
+
config=config,
|
|
2324
|
+
)
|
|
2325
|
+
|
|
2326
|
+
# 4. Run the closed loop to a terminal mission state.
|
|
2327
|
+
final = await supervisor.run(comp)
|
|
2328
|
+
|
|
2329
|
+
self.audit_log.append({
|
|
2330
|
+
"type": "composite_finished", "composite_id": comp.composite_id,
|
|
2331
|
+
"intent_id": intent.intent_id, "final_state": final.value,
|
|
2332
|
+
"steps_completed": sum(1 for s in comp.steps if s.done),
|
|
2333
|
+
"steps_total": len(comp.steps),
|
|
2334
|
+
})
|
|
2335
|
+
return final
|
|
2336
|
+
|
|
2337
|
+
async def _route_composite_intent(self, intent, executor, composition_kind):
|
|
2338
|
+
"""Route a composition intent to the right composer and run it as a closed-
|
|
2339
|
+
loop supervised sequence, wrapping the terminal CompositeState in the
|
|
2340
|
+
IntentResult the caller expects.
|
|
2341
|
+
|
|
2342
|
+
The selection is a simple `if`, not a generic registry (panel: no premature
|
|
2343
|
+
abstraction with one composer). An UNKNOWN kind fails EXPLICITLY — a declared
|
|
2344
|
+
composition the hub cannot compose is a configuration error that must shout,
|
|
2345
|
+
never a silent fall-through to the flat path.
|
|
2346
|
+
|
|
2347
|
+
The composition context (center, radius_m, device_id, ...) travels in
|
|
2348
|
+
intent.context — the same place every intent carries its context.
|
|
2349
|
+
"""
|
|
2350
|
+
if composition_kind != "perimeter":
|
|
2351
|
+
# Explicit failure — never silently degrade to the flat path.
|
|
2352
|
+
log.error("Intent '%s' declares composition_kind='%s' but no composer "
|
|
2353
|
+
"handles it.", intent.intent.value, composition_kind)
|
|
2354
|
+
self.audit_log.append({
|
|
2355
|
+
"type": "composite_unknown_kind",
|
|
2356
|
+
"intent_id": intent.intent_id,
|
|
2357
|
+
"intent": intent.intent.value,
|
|
2358
|
+
"composition_kind": composition_kind,
|
|
2359
|
+
})
|
|
2360
|
+
return IntentResult(
|
|
2361
|
+
intent_id=intent.intent_id, success=False, results=[],
|
|
2362
|
+
failed_devices=[], status="failed",
|
|
2363
|
+
)
|
|
2364
|
+
|
|
2365
|
+
from .composite_operations import CompositeState
|
|
2366
|
+
try:
|
|
2367
|
+
final_state = await self.execute_composite_intent(
|
|
2368
|
+
intent, executor, context=intent.context)
|
|
2369
|
+
except ValueError as e:
|
|
2370
|
+
# e.g. missing device_id / center in the context.
|
|
2371
|
+
log.error("Composite intent '%s' rejected: %s", intent.intent.value, e)
|
|
2372
|
+
self.audit_log.append({
|
|
2373
|
+
"type": "composite_rejected",
|
|
2374
|
+
"intent_id": intent.intent_id,
|
|
2375
|
+
"intent": intent.intent.value,
|
|
2376
|
+
"reason": str(e),
|
|
2377
|
+
})
|
|
2378
|
+
return IntentResult(
|
|
2379
|
+
intent_id=intent.intent_id, success=False, results=[],
|
|
2380
|
+
failed_devices=[], status="failed",
|
|
2381
|
+
)
|
|
2382
|
+
|
|
2383
|
+
# Map the terminal CompositeState to the IntentResult contract.
|
|
2384
|
+
success = final_state == CompositeState.COMPLETED
|
|
2385
|
+
return IntentResult(
|
|
2386
|
+
intent_id=intent.intent_id, success=success, results=[],
|
|
2387
|
+
failed_devices=[], status=final_state.value,
|
|
2388
|
+
)
|
|
2389
|
+
|
|
2390
|
+
def _resolve_verify_bindings(self, plan, intent) -> None:
|
|
2391
|
+
"""INDEPENDENT-OBSERVATION (panel D1): fill in each action's verify_with
|
|
2392
|
+
from the manifest (the manufacturer's natural pairing) and/or the intent
|
|
2393
|
+
context (a deployment cross-device binding, which overrides).
|
|
2394
|
+
|
|
2395
|
+
Intent context format:
|
|
2396
|
+
context["verify_with"] = {
|
|
2397
|
+
"lock-front": {"sensor_id": "door-sensor:bolt",
|
|
2398
|
+
"expected_reading": "locked", "deadline_s": 5},
|
|
2399
|
+
"lock-front:unlock": {...} # optional per-action override
|
|
2400
|
+
}
|
|
2401
|
+
Keys are matched most-specific-first: "device:action", then "device".
|
|
2402
|
+
Anything malformed is IGNORED with a warning — a bad binding must never
|
|
2403
|
+
break dispatch (verification is an observation, not a gate).
|
|
2404
|
+
"""
|
|
2405
|
+
from .models import VerifyBinding
|
|
2406
|
+
|
|
2407
|
+
ctx = (intent.context or {}).get("verify_with") or {}
|
|
2408
|
+
for action in plan.actions:
|
|
2409
|
+
binding = None
|
|
2410
|
+
|
|
2411
|
+
# 1. Manifest: the actuator's own declared pairing, if any.
|
|
2412
|
+
device = self.registry.get(action.device_id)
|
|
2413
|
+
if device:
|
|
2414
|
+
for act in getattr(device, "actuators", []):
|
|
2415
|
+
if act.type == action.action or act.id == action.action:
|
|
2416
|
+
binding = getattr(act, "verify_with", None)
|
|
2417
|
+
break
|
|
2418
|
+
|
|
2419
|
+
# 2. Intent context overrides (most specific key wins).
|
|
2420
|
+
raw = ctx.get(f"{action.device_id}:{action.action}") or ctx.get(action.device_id)
|
|
2421
|
+
if raw is not None:
|
|
2422
|
+
try:
|
|
2423
|
+
binding = raw if isinstance(raw, VerifyBinding) else VerifyBinding(
|
|
2424
|
+
sensor_id=raw["sensor_id"],
|
|
2425
|
+
expected_reading=raw["expected_reading"],
|
|
2426
|
+
deadline_s=float(raw.get("deadline_s", 5.0)))
|
|
2427
|
+
except Exception as e:
|
|
2428
|
+
log.warning("Ignoring malformed verify_with for %s/%s: %s",
|
|
2429
|
+
action.device_id, action.action, e)
|
|
2430
|
+
|
|
2431
|
+
if binding is not None:
|
|
2432
|
+
action.verify_with = binding
|
|
2433
|
+
|
|
2434
|
+
async def execute_intent(
|
|
2435
|
+
self,
|
|
2436
|
+
intent: Intent,
|
|
2437
|
+
executor: "DeviceExecutor",
|
|
2438
|
+
progress_cb=None,
|
|
2439
|
+
) -> IntentResult:
|
|
2440
|
+
log.info("Executing intent: %s [%s]", intent.intent.value, intent.urgency.value)
|
|
2441
|
+
|
|
2442
|
+
# Wrap the executor once to time every device action regardless of the
|
|
2443
|
+
# execution path (parallel/abort/retry/long-running). A timing wrapper
|
|
2444
|
+
# here means we don't have to instrument each call site — and it never
|
|
2445
|
+
# changes behavior: it awaits the real execute and records the elapsed
|
|
2446
|
+
# time by result. Metrics are optional; a failure to record is swallowed.
|
|
2447
|
+
if _M is not None and not getattr(executor, "_dosync_timed", False):
|
|
2448
|
+
executor = _TimedExecutor(executor, hub=self)
|
|
2449
|
+
|
|
2450
|
+
# ── Composition routing (Level 2) ─────────────────────────────────────
|
|
2451
|
+
# A composition intent (declared with composition_kind, e.g. inspect_area
|
|
2452
|
+
# -> "perimeter") does NOT resolve to a flat parallel plan. It composes an
|
|
2453
|
+
# ordered sequence the OperationSupervisor drives in a closed loop. The
|
|
2454
|
+
# routing decision lives HERE in the hub — not in the REST endpoint — so
|
|
2455
|
+
# every client (REST, MCP, tests) inherits the same behavior.
|
|
2456
|
+
#
|
|
2457
|
+
# An intent WITHOUT composition_kind falls straight through to the normal
|
|
2458
|
+
# flat path below — zero change for every existing intent. An UNKNOWN kind
|
|
2459
|
+
# fails explicitly (never silently falls to the flat path): a declared
|
|
2460
|
+
# composition the hub cannot compose is a configuration error that must shout.
|
|
2461
|
+
intent_class_row = self.db.get_intent_class(intent.intent.value)
|
|
2462
|
+
composition_kind = (intent_class_row or {}).get("composition_kind")
|
|
2463
|
+
if composition_kind:
|
|
2464
|
+
return await self._route_composite_intent(
|
|
2465
|
+
intent, executor, composition_kind)
|
|
2466
|
+
|
|
2467
|
+
_t0 = time.perf_counter()
|
|
2468
|
+
plan = self.resolver.resolve(intent)
|
|
2469
|
+
if _M is not None:
|
|
2470
|
+
_M.intent_resolution_seconds.observe(time.perf_counter() - _t0)
|
|
2471
|
+
|
|
2472
|
+
# ── Parameter validation (protocol v0.3) ──────────────────────────────
|
|
2473
|
+
# Validate each action's params against its actuator's JSON Schema BEFORE
|
|
2474
|
+
# dispatch. An action whose params violate the schema is rejected
|
|
2475
|
+
# individually, recorded in the audit log, and the rest of the plan
|
|
2476
|
+
# continues (partial result) — a single bad parameter never aborts a plan.
|
|
2477
|
+
#
|
|
2478
|
+
# Opt-out by latency: on the EMERGENCY path, validation is skipped so the
|
|
2479
|
+
# response is never delayed. Even when active, rejection-and-continue means
|
|
2480
|
+
# validation cannot tumble an emergency — only the invalid action drops.
|
|
2481
|
+
# Controlled by DOSYNC_VALIDATE_PARAMS (default "true"); emergencies always skip.
|
|
2482
|
+
rejected_actions = []
|
|
2483
|
+
_validate = os.environ.get("DOSYNC_VALIDATE_PARAMS", "true").lower() == "true"
|
|
2484
|
+
if _validate and intent.urgency != Urgency.EMERGENCY:
|
|
2485
|
+
plan, rejected_actions = self._validate_plan_params(plan, intent)
|
|
2486
|
+
|
|
2487
|
+
# Policy Engine evaluation
|
|
2488
|
+
if self.policy_engine:
|
|
2489
|
+
from .policies import PolicyDecision
|
|
2490
|
+
from .models import ActionPlan as _AP
|
|
2491
|
+
policy_result = self.policy_engine.evaluate(intent, plan)
|
|
2492
|
+
if policy_result.decision == PolicyDecision.BLOCK:
|
|
2493
|
+
# Determine if this is an emergency intent blocked by a non-bypassable policy
|
|
2494
|
+
# This is a security-notable event: operator explicitly overrides emergency bypass
|
|
2495
|
+
is_emergency_block = (
|
|
2496
|
+
intent.urgency == Urgency.EMERGENCY
|
|
2497
|
+
and not getattr(
|
|
2498
|
+
next((p for p in self.policy_engine._policies
|
|
2499
|
+
if p.name == policy_result.policy_name), None),
|
|
2500
|
+
"bypass_on_emergency", True
|
|
2501
|
+
)
|
|
2502
|
+
)
|
|
2503
|
+
audit_type = "emergency_intent_blocked_by_policy" if is_emergency_block else "intent_blocked"
|
|
2504
|
+
log.warning(
|
|
2505
|
+
"Intent %s BLOCKED by policy '%s' (urgency=%s, emergency_override=%s): %s",
|
|
2506
|
+
intent.intent.value, policy_result.policy_name,
|
|
2507
|
+
intent.urgency.value, is_emergency_block, policy_result.reason,
|
|
2508
|
+
)
|
|
2509
|
+
self.audit_log.append({
|
|
2510
|
+
"type": audit_type,
|
|
2511
|
+
"intent_id": intent.intent_id,
|
|
2512
|
+
"intent": intent.intent.value,
|
|
2513
|
+
"urgency": intent.urgency.value,
|
|
2514
|
+
"source": getattr(intent, "source", "api"),
|
|
2515
|
+
"policy": policy_result.policy_name,
|
|
2516
|
+
"reason": policy_result.reason,
|
|
2517
|
+
"emergency_override": is_emergency_block,
|
|
2518
|
+
})
|
|
2519
|
+
return IntentResult(intent_id=intent.intent_id, success=False, results=[], failed_devices=[])
|
|
2520
|
+
elif policy_result.decision == PolicyDecision.CONFIRM:
|
|
2521
|
+
log.info("Intent PENDING CONFIRMATION by policy '%s': %s",
|
|
2522
|
+
policy_result.policy_name, policy_result.reason)
|
|
2523
|
+
self.audit_log.append({
|
|
2524
|
+
"type": "intent_pending_confirmation",
|
|
2525
|
+
"intent_id": intent.intent_id,
|
|
2526
|
+
"intent": intent.intent.value,
|
|
2527
|
+
"policy": policy_result.policy_name,
|
|
2528
|
+
"reason": policy_result.reason,
|
|
2529
|
+
})
|
|
2530
|
+
return IntentResult(intent_id=intent.intent_id, success=False, results=[], failed_devices=[])
|
|
2531
|
+
elif policy_result.decision == PolicyDecision.MODIFY:
|
|
2532
|
+
# ── AUDIT-PROVENANCE (2026-07-18, from external review) ──────
|
|
2533
|
+
# Until today a MODIFY left its trace in the runtime log and
|
|
2534
|
+
# NOWHERE in the tamper-evident chain: BLOCK and CONFIRM were
|
|
2535
|
+
# chain-bound, but the most common policy decision — "the plan
|
|
2536
|
+
# ran, minus these devices" — was reconstructible only from a
|
|
2537
|
+
# rotating journal. The chain must bind the DECISION, not just
|
|
2538
|
+
# the commands sent: what was proposed, what was removed, which
|
|
2539
|
+
# policy decided, and the fingerprint of the exact policy file
|
|
2540
|
+
# that was loaded (the file on disk may change; the hash of
|
|
2541
|
+
# what this hub enforced does not).
|
|
2542
|
+
_pre_devices = sorted({a.device_id for a in plan.actions})
|
|
2543
|
+
_post_devices = sorted({a.device_id for a in policy_result.modified_actions})
|
|
2544
|
+
self.audit_log.append({
|
|
2545
|
+
"type": "policy_modified",
|
|
2546
|
+
"intent_id": intent.intent_id,
|
|
2547
|
+
"intent": intent.intent.value,
|
|
2548
|
+
"urgency": intent.urgency.value,
|
|
2549
|
+
"source": getattr(intent, "source", "api"),
|
|
2550
|
+
"policy": policy_result.policy_name,
|
|
2551
|
+
"reason": policy_result.reason,
|
|
2552
|
+
"pre_policy_devices": _pre_devices,
|
|
2553
|
+
"post_policy_devices": _post_devices,
|
|
2554
|
+
"removed_devices": sorted(set(_pre_devices) - set(_post_devices)),
|
|
2555
|
+
"policies_fingerprint": getattr(self.policy_engine, "policies_fingerprint", None),
|
|
2556
|
+
})
|
|
2557
|
+
plan = _AP(intent_id=plan.intent_id, actions=policy_result.modified_actions, urgency=plan.urgency)
|
|
2558
|
+
|
|
2559
|
+
# ── EMERGENCY-UNSAT-ESCALATION (same review) ─────────────────
|
|
2560
|
+
# Stacked absolute exclusions CAN empty an emergency plan. The
|
|
2561
|
+
# wrong fix is rejecting the plan — that would override the
|
|
2562
|
+
# operator's declared judgment, which is precisely what this
|
|
2563
|
+
# layer refuses to do. The failure mode is not obedience, it is
|
|
2564
|
+
# SILENCE: until today this executed zero actions with status
|
|
2565
|
+
# "completed" and nobody was told. Honor the rules; say so
|
|
2566
|
+
# loudly; leave a dedicated chain entry.
|
|
2567
|
+
if (intent.urgency == Urgency.EMERGENCY
|
|
2568
|
+
and _pre_devices and not plan.actions):
|
|
2569
|
+
log.critical(
|
|
2570
|
+
"EMERGENCY intent %s is UNSATISFIABLE: %d device(s) resolved, "
|
|
2571
|
+
"0 remain after policy filtering. Your standing rules made this "
|
|
2572
|
+
"emergency a no-op — review the deployment policy file.",
|
|
2573
|
+
intent.intent.value, len(_pre_devices),
|
|
2574
|
+
)
|
|
2575
|
+
self.audit_log.append({
|
|
2576
|
+
"type": "emergency_unsatisfiable",
|
|
2577
|
+
"intent_id": intent.intent_id,
|
|
2578
|
+
"intent": intent.intent.value,
|
|
2579
|
+
"source": getattr(intent, "source", "api"),
|
|
2580
|
+
"resolved_devices": _pre_devices,
|
|
2581
|
+
"policy": policy_result.policy_name,
|
|
2582
|
+
"policies_fingerprint": getattr(self.policy_engine, "policies_fingerprint", None),
|
|
2583
|
+
})
|
|
2584
|
+
|
|
2585
|
+
# Register active intent for conflict detection
|
|
2586
|
+
from .policies import get_intent_priority
|
|
2587
|
+
intent_value = intent.intent.value
|
|
2588
|
+
self._active_intents[intent_value] = get_intent_priority(intent_value)
|
|
2589
|
+
self._active_intent_devices[intent_value] = {a.device_id for a in plan.actions}
|
|
2590
|
+
|
|
2591
|
+
# ── Split by execution model (execution_model) ─────────────────────────
|
|
2592
|
+
# Long-running actions follow a separate sub-protocol: they are STARTED and
|
|
2593
|
+
# tracked as operations, not awaited to completion. Instant actions take the
|
|
2594
|
+
# existing path completely unchanged. An intent with no long-running actions
|
|
2595
|
+
# behaves exactly as before (started_operations stays empty).
|
|
2596
|
+
from .models import ActionPlan as _APlan
|
|
2597
|
+
_instant_actions, _long_running_actions = self._split_plan_by_execution_model(plan)
|
|
2598
|
+
started_operations = []
|
|
2599
|
+
if _long_running_actions:
|
|
2600
|
+
started_operations = await self._start_long_running_actions(
|
|
2601
|
+
_long_running_actions, executor, intent
|
|
2602
|
+
)
|
|
2603
|
+
# The instant path below operates only on the instant sub-plan.
|
|
2604
|
+
plan = _APlan(intent_id=plan.intent_id, actions=_instant_actions,
|
|
2605
|
+
urgency=plan.urgency,
|
|
2606
|
+
failure_policy=getattr(plan, "failure_policy", None))
|
|
2607
|
+
|
|
2608
|
+
# INDEPENDENT-OBSERVATION: resolve each action's verify_with binding
|
|
2609
|
+
# before dispatch. Manifest declares the manufacturer's natural pairing;
|
|
2610
|
+
# the intent context can add or OVERRIDE a cross-device binding, and wins
|
|
2611
|
+
# (panel decision D1 — the manufacturer cannot know sensors it does not
|
|
2612
|
+
# ship with). Opt-in throughout: an action with no binding from either
|
|
2613
|
+
# source stays verify_with=None and behaves exactly as before.
|
|
2614
|
+
self._resolve_verify_bindings(plan, intent)
|
|
2615
|
+
|
|
2616
|
+
# Execute with the plan's FailurePolicy
|
|
2617
|
+
results, failed, aborted, policy_applied = [], [], [], "continue"
|
|
2618
|
+
try:
|
|
2619
|
+
results, failed, aborted, policy_applied = await self._execute_with_policy_cb(
|
|
2620
|
+
plan, executor, intent, progress_cb=progress_cb
|
|
2621
|
+
)
|
|
2622
|
+
finally:
|
|
2623
|
+
_claim_devices = self._active_intent_devices.get(intent_value, set())
|
|
2624
|
+
self._active_intents.pop(intent_value, None)
|
|
2625
|
+
self._active_intent_devices.pop(intent_value, None)
|
|
2626
|
+
# Release any device claim this intent asserted at the arbiter layer, so
|
|
2627
|
+
# the short grace window starts now (see dosync/device_arbiter.py). The
|
|
2628
|
+
# rank guard ensures a lower-urgency intent completing first cannot start
|
|
2629
|
+
# the grace on a higher-urgency (emergency) claim on a shared device.
|
|
2630
|
+
_release = getattr(executor, "release_claim", None)
|
|
2631
|
+
if _release is not None and _claim_devices:
|
|
2632
|
+
try:
|
|
2633
|
+
_rank = {"info": 0, "warning": 1, "alert": 2, "emergency": 3}.get(
|
|
2634
|
+
getattr(intent.urgency, "value", str(intent.urgency)), 0)
|
|
2635
|
+
_release(_claim_devices, _rank)
|
|
2636
|
+
except Exception:
|
|
2637
|
+
pass
|
|
2638
|
+
|
|
2639
|
+
# INDEPENDENT-OBSERVATION (panel design 2026-07-21): a verification result
|
|
2640
|
+
# that CONTRADICTS or is UNVERIFIABLE is a first-class audit event, with
|
|
2641
|
+
# expected/observed/sensor/independence. The protocol REPORTS honestly and
|
|
2642
|
+
# does NOT act (no auto-retry, no auto-escalation — panel decision D2):
|
|
2643
|
+
# the response is deployment policy, never protocol-automatic. `success`
|
|
2644
|
+
# is untouched — the device accepted the command; verification answers the
|
|
2645
|
+
# separate question of whether the effect was independently observed.
|
|
2646
|
+
from .models import VerificationStatus as _VS
|
|
2647
|
+
for _r in results:
|
|
2648
|
+
_v = getattr(_r, "verification", None)
|
|
2649
|
+
if _v is None or _v.status in (_VS.VERIFIED, _VS.UNVERIFIED):
|
|
2650
|
+
continue
|
|
2651
|
+
_ev = ("action_contradicted" if _v.status == _VS.CONTRADICTED
|
|
2652
|
+
else "action_unverifiable")
|
|
2653
|
+
self.audit_log.append({
|
|
2654
|
+
"type": _ev,
|
|
2655
|
+
"intent_id": intent.intent_id,
|
|
2656
|
+
"device_id": _r.device_id,
|
|
2657
|
+
"action": _r.action,
|
|
2658
|
+
"sensor_id": _v.sensor_id,
|
|
2659
|
+
"expected": _v.expected,
|
|
2660
|
+
"observed": _v.observed,
|
|
2661
|
+
"independence": _v.independence,
|
|
2662
|
+
})
|
|
2663
|
+
if _v.status == _VS.CONTRADICTED:
|
|
2664
|
+
log.warning("Action %s on %s reported success but sensor %s CONTRADICTS: "
|
|
2665
|
+
"expected=%r observed=%r (%s) — reporting, not acting (policy decides)",
|
|
2666
|
+
_r.action, _r.device_id, _v.sensor_id, _v.expected,
|
|
2667
|
+
_v.observed, _v.independence)
|
|
2668
|
+
|
|
2669
|
+
# Rejected-by-validation actions count against full success: the plan did
|
|
2670
|
+
# not do everything the mind asked. Per the panel, this resolves to
|
|
2671
|
+
# `partial` even if every dispatched action succeeded — and is recorded
|
|
2672
|
+
# distinctly from device failures (see audit type above).
|
|
2673
|
+
has_rejected = len(rejected_actions) > 0
|
|
2674
|
+
has_operations = len(started_operations) > 0
|
|
2675
|
+
success = len(failed) == 0 and len(aborted) == 0 and not has_rejected
|
|
2676
|
+
if not results and not has_rejected and has_operations:
|
|
2677
|
+
# The intent only started long-running operations (no instant actions).
|
|
2678
|
+
# Not failed — accepted and running. `accepted` is the honest status:
|
|
2679
|
+
# nothing is done yet, but operations are underway.
|
|
2680
|
+
status = "accepted"
|
|
2681
|
+
elif not results and not has_rejected:
|
|
2682
|
+
status = "failed"
|
|
2683
|
+
elif aborted:
|
|
2684
|
+
status = "partial_abort"
|
|
2685
|
+
elif not results and has_rejected:
|
|
2686
|
+
# Every action was rejected by validation; nothing executed.
|
|
2687
|
+
status = "rejected_invalid_params"
|
|
2688
|
+
elif failed and len(failed) < len(results):
|
|
2689
|
+
status = "partial"
|
|
2690
|
+
elif failed:
|
|
2691
|
+
status = "failed"
|
|
2692
|
+
elif has_rejected:
|
|
2693
|
+
# Some actions executed, some rejected by validation → partial.
|
|
2694
|
+
status = "partial"
|
|
2695
|
+
elif any(getattr(r, "retries", 0) > 0 and not r.success for r in results):
|
|
2696
|
+
status = "retry_exhausted"
|
|
2697
|
+
elif has_operations:
|
|
2698
|
+
# Instant actions all succeeded AND long-running operations started:
|
|
2699
|
+
# the instant part is done but the intent as a whole is still running.
|
|
2700
|
+
status = "accepted"
|
|
2701
|
+
else:
|
|
2702
|
+
status = "success"
|
|
2703
|
+
|
|
2704
|
+
intent_result = IntentResult(
|
|
2705
|
+
intent_id=intent.intent_id,
|
|
2706
|
+
success=success,
|
|
2707
|
+
results=results,
|
|
2708
|
+
failed_devices=failed,
|
|
2709
|
+
aborted_devices=aborted,
|
|
2710
|
+
failure_policy_applied=policy_applied,
|
|
2711
|
+
status=status,
|
|
2712
|
+
rejected_actions=[
|
|
2713
|
+
{"device_id": a.device_id, "action": a.action, "reason": err}
|
|
2714
|
+
for a, err in rejected_actions
|
|
2715
|
+
],
|
|
2716
|
+
operations=started_operations,
|
|
2717
|
+
)
|
|
2718
|
+
# Audit log
|
|
2719
|
+
self.audit_log.append({
|
|
2720
|
+
"type": "intent_executed",
|
|
2721
|
+
"intent_id": intent.intent_id,
|
|
2722
|
+
"intent": intent.intent.value,
|
|
2723
|
+
"urgency": intent.urgency.value,
|
|
2724
|
+
"source": getattr(intent, "source", "api"),
|
|
2725
|
+
"actions": len(plan.actions),
|
|
2726
|
+
"failed": failed,
|
|
2727
|
+
"aborted": aborted,
|
|
2728
|
+
"failure_policy": policy_applied,
|
|
2729
|
+
"status": status,
|
|
2730
|
+
"success": success,
|
|
2731
|
+
})
|
|
2732
|
+
|
|
2733
|
+
return intent_result
|
|
2734
|
+
|
|
2735
|
+
# ── Event handling (device → AI) ─────────────────────────────────────────
|
|
2736
|
+
|
|
2737
|
+
def on_event(self, handler: Callable[[DeviceEvent], None]) -> None:
|
|
2738
|
+
self._event_handlers.append(handler)
|
|
2739
|
+
|
|
2740
|
+
async def receive_event(self, event: DeviceEvent) -> None:
|
|
2741
|
+
log.info("Event received: %s from %s [%s]",
|
|
2742
|
+
event.event_id, event.device_id, event.severity.value)
|
|
2743
|
+
|
|
2744
|
+
self.audit_log.append({
|
|
2745
|
+
"type": "device_event",
|
|
2746
|
+
"device_id": event.device_id,
|
|
2747
|
+
"event_id": event.event_id,
|
|
2748
|
+
"severity": event.severity.value,
|
|
2749
|
+
"data": event.data,
|
|
2750
|
+
})
|
|
2751
|
+
|
|
2752
|
+
for handler in self._event_handlers:
|
|
2753
|
+
if asyncio.iscoroutinefunction(handler):
|
|
2754
|
+
await handler(event)
|
|
2755
|
+
else:
|
|
2756
|
+
handler(event)
|
|
2757
|
+
|
|
2758
|
+
# ── Phased intent execution ──────────────────────────────────────────────
|
|
2759
|
+
|
|
2760
|
+
async def execute_phased(
|
|
2761
|
+
self,
|
|
2762
|
+
plan: PhasedActionPlan,
|
|
2763
|
+
executor: "DeviceExecutor",
|
|
2764
|
+
) -> list[IntentResult]:
|
|
2765
|
+
"""
|
|
2766
|
+
Ejecuta un PhasedActionPlan: cada fase en paralelo,
|
|
2767
|
+
las fases en secuencia con delay entre ellas.
|
|
2768
|
+
Ideal para emergencias donde el orden importa.
|
|
2769
|
+
"""
|
|
2770
|
+
all_results = []
|
|
2771
|
+
|
|
2772
|
+
for i, phase in enumerate(plan.phases):
|
|
2773
|
+
log.info(
|
|
2774
|
+
"Executing phase %d/%d: '%s' (%d actions)",
|
|
2775
|
+
i + 1, len(plan.phases), phase.name, len(phase.actions),
|
|
2776
|
+
)
|
|
2777
|
+
|
|
2778
|
+
from .models import ActionPlan as _PhAP, DeviceAction as _PhDA
|
|
2779
|
+
from .models import Intent as _PhI, IntentClass as _PhIC
|
|
2780
|
+
phase_plan = _PhAP(
|
|
2781
|
+
intent_id=f"{plan.intent_id}-phase{i+1}",
|
|
2782
|
+
actions=[_PhDA(device_id=a.device_id, action=a.action, params=a.params)
|
|
2783
|
+
for a in phase.actions],
|
|
2784
|
+
urgency=plan.urgency,
|
|
2785
|
+
failure_policy=getattr(plan, "failure_policy", None),
|
|
2786
|
+
max_retries=getattr(plan, "max_retries", 1),
|
|
2787
|
+
)
|
|
2788
|
+
phase_intent = _PhI(intent=_PhIC("report_status"), urgency=plan.urgency, context={})
|
|
2789
|
+
p_res, p_fail, p_abort, p_pol = await self._execute_with_policy(
|
|
2790
|
+
phase_plan, executor, phase_intent
|
|
2791
|
+
)
|
|
2792
|
+
p_ok = len(p_fail) == 0 and len(p_abort) == 0
|
|
2793
|
+
p_st = "success" if p_ok else ("partial_abort" if p_abort else "partial")
|
|
2794
|
+
phase_result = IntentResult(
|
|
2795
|
+
intent_id=f"{plan.intent_id}-phase{i+1}",
|
|
2796
|
+
success=p_ok,
|
|
2797
|
+
results=p_res,
|
|
2798
|
+
failed_devices=p_fail,
|
|
2799
|
+
aborted_devices=p_abort,
|
|
2800
|
+
failure_policy_applied=p_pol,
|
|
2801
|
+
status=p_st,
|
|
2802
|
+
)
|
|
2803
|
+
all_results.append(phase_result)
|
|
2804
|
+
self.audit_log.append({
|
|
2805
|
+
"type": "phase_executed",
|
|
2806
|
+
"intent_id": plan.intent_id,
|
|
2807
|
+
"phase": phase.name,
|
|
2808
|
+
"phase_num": i + 1,
|
|
2809
|
+
"actions": len(phase.actions),
|
|
2810
|
+
"failed": p_fail,
|
|
2811
|
+
"aborted": p_abort,
|
|
2812
|
+
"failure_policy": p_pol,
|
|
2813
|
+
"success": p_ok,
|
|
2814
|
+
})
|
|
2815
|
+
# ABORT propagation: cancel remaining phases if this one failed
|
|
2816
|
+
if not p_ok and getattr(plan, "failure_policy", None) and \
|
|
2817
|
+
getattr(plan.failure_policy, "value", "") == "abort":
|
|
2818
|
+
log.warning("ABORT: phase %d/%d failed — cancelling %d remaining",
|
|
2819
|
+
i+1, len(plan.phases), len(plan.phases)-i-1)
|
|
2820
|
+
for rp in plan.phases[i+1:]:
|
|
2821
|
+
all_results.append(IntentResult(
|
|
2822
|
+
intent_id=f"{plan.intent_id}-phase{plan.phases.index(rp)+1}",
|
|
2823
|
+
success=False, results=[], failed_devices=[],
|
|
2824
|
+
aborted_devices=[a.device_id for a in rp.actions],
|
|
2825
|
+
failure_policy_applied="abort", status="partial_abort",
|
|
2826
|
+
))
|
|
2827
|
+
break
|
|
2828
|
+
|
|
2829
|
+
if phase.delay_after_ms > 0 and i < len(plan.phases) - 1:
|
|
2830
|
+
log.info("Waiting %dms before next phase...", phase.delay_after_ms)
|
|
2831
|
+
await asyncio.sleep(phase.delay_after_ms / 1000)
|
|
2832
|
+
|
|
2833
|
+
return all_results
|
|
2834
|
+
|
|
2835
|
+
|
|
2836
|
+
# ── Occupancy Engine ──────────────────────────────────────────────────────────
|
|
2837
|
+
|
|
2838
|
+
class OccupancyEngine:
|
|
2839
|
+
"""
|
|
2840
|
+
Infers home occupancy state by aggregating signals from multiple
|
|
2841
|
+
context providers. Never relies on a single source — combines and weights them.
|
|
2842
|
+
|
|
2843
|
+
Supported signals and their default weights:
|
|
2844
|
+
Phone GPS outside perimeter → absence, weight 0.9
|
|
2845
|
+
Phone WiFi disconnected → absence, weight 0.7
|
|
2846
|
+
No PIR motion for 30+ min → absence, weight 0.4
|
|
2847
|
+
Smartwatch GPS outside perimeter → absence, weight 0.8
|
|
2848
|
+
Smart TV off → absence, weight 0.2
|
|
2849
|
+
"""
|
|
2850
|
+
|
|
2851
|
+
def __init__(self):
|
|
2852
|
+
self._signals: list[PresenceSignal] = []
|
|
2853
|
+
self._signal_ttl_seconds = 300 # signals expire after 5 minutes
|
|
2854
|
+
|
|
2855
|
+
def update(self, signal: PresenceSignal) -> None:
|
|
2856
|
+
"""Register or update a presence signal."""
|
|
2857
|
+
# Replace any previous signal from the same device
|
|
2858
|
+
self._signals = [
|
|
2859
|
+
s for s in self._signals
|
|
2860
|
+
if not (s.device_id == signal.device_id and
|
|
2861
|
+
s.signal_type == signal.signal_type)
|
|
2862
|
+
]
|
|
2863
|
+
self._signals.append(signal)
|
|
2864
|
+
log.info(
|
|
2865
|
+
"Presence signal: %s / %s → present=%s (confidence=%.2f)",
|
|
2866
|
+
signal.device_id, signal.signal_type.value,
|
|
2867
|
+
signal.present, signal.confidence,
|
|
2868
|
+
)
|
|
2869
|
+
|
|
2870
|
+
def _active_signals(self) -> list[PresenceSignal]:
|
|
2871
|
+
"""Filter out expired signals."""
|
|
2872
|
+
cutoff = time.time() - self._signal_ttl_seconds
|
|
2873
|
+
return [s for s in self._signals if s.timestamp >= cutoff]
|
|
2874
|
+
|
|
2875
|
+
def get_occupancy(self) -> OccupancyState:
|
|
2876
|
+
"""
|
|
2877
|
+
Calcula el estado de ocupacion actual.
|
|
2878
|
+
Retorna occupied=True si la confianza ponderada de presencia >= 0.5.
|
|
2879
|
+
"""
|
|
2880
|
+
signals = self._active_signals()
|
|
2881
|
+
if not signals:
|
|
2882
|
+
# No signals = unknown state; default to occupied for safety
|
|
2883
|
+
return OccupancyState(
|
|
2884
|
+
occupied=True,
|
|
2885
|
+
confidence=0.0,
|
|
2886
|
+
members_home=[],
|
|
2887
|
+
signals_used=0,
|
|
2888
|
+
)
|
|
2889
|
+
|
|
2890
|
+
# Calculate weighted presence confidence
|
|
2891
|
+
total_weight = sum(s.confidence for s in signals)
|
|
2892
|
+
presence_weight = sum(
|
|
2893
|
+
s.confidence for s in signals if s.present
|
|
2894
|
+
)
|
|
2895
|
+
|
|
2896
|
+
confidence_present = presence_weight / total_weight if total_weight > 0 else 0.5
|
|
2897
|
+
occupied = confidence_present >= 0.5
|
|
2898
|
+
|
|
2899
|
+
members_home = list({
|
|
2900
|
+
s.member_id for s in signals
|
|
2901
|
+
if s.present and s.member_id
|
|
2902
|
+
})
|
|
2903
|
+
|
|
2904
|
+
return OccupancyState(
|
|
2905
|
+
occupied=occupied,
|
|
2906
|
+
confidence=abs(confidence_present - 0.5) * 2, # 0=incertidumbre, 1=certeza
|
|
2907
|
+
members_home=members_home,
|
|
2908
|
+
signals_used=len(signals),
|
|
2909
|
+
)
|
|
2910
|
+
|
|
2911
|
+
def all_signals(self) -> list[dict]:
|
|
2912
|
+
return [
|
|
2913
|
+
{
|
|
2914
|
+
"device_id": s.device_id,
|
|
2915
|
+
"signal_type": s.signal_type.value,
|
|
2916
|
+
"present": s.present,
|
|
2917
|
+
"confidence": s.confidence,
|
|
2918
|
+
"member_id": s.member_id,
|
|
2919
|
+
"timestamp": s.timestamp,
|
|
2920
|
+
"age_seconds": round(time.time() - s.timestamp, 1),
|
|
2921
|
+
}
|
|
2922
|
+
for s in self._active_signals()
|
|
2923
|
+
]
|