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/server.py ADDED
@@ -0,0 +1,1911 @@
1
+ """
2
+ DoSync Hub — REST API Server
3
+ Corre con: uvicorn server:app --host 0.0.0.0 --port 47200 --reload
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from contextlib import asynccontextmanager
9
+ from typing import Any, Optional
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
15
+ from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from pydantic import BaseModel, field_validator
18
+
19
+ from dosync import __version__
20
+ from dosync.hub import DoSyncHub
21
+ from dosync import metrics as M
22
+ from dosync.executor import SimulatedExecutor
23
+ from dosync.auth import AuthManager, set_auth_manager, DeviceAuthManager, set_device_auth_manager
24
+ from dosync.auth_fastapi import require_auth
25
+ from dosync.security import get_status as get_pki_status
26
+ from dosync.models import (
27
+ ActuatorSpec, CapabilityManifest, CertTier, DeviceCategory,
28
+ DeviceEvent, EventSpec, Intent, IntentClass, SensorSpec, Urgency, Severity,
29
+ )
30
+
31
+ logging.basicConfig(
32
+ level=logging.INFO,
33
+ format="%(asctime)s %(name)-20s %(levelname)s %(message)s",
34
+ )
35
+
36
+ _certify_mode = os.environ.get("DOSYNC_CERTIFY", "").lower() in ("1", "true", "yes")
37
+
38
+
39
+ def _resolve_db_path() -> str:
40
+ """Where the hub stores its data.
41
+
42
+ DOSYNC_DB is the name the hub reads. DOSYNC_DB_PATH is accepted as an alias
43
+ because the shipped Dockerfile and docker-compose.yml set THAT name — and
44
+ the hub never read it, so a container wrote its database to the image's
45
+ working directory instead of the mounted volume, and every `docker compose
46
+ down` silently destroyed the audit chain. Found and fixed 2026-07-22: the
47
+ compose files now use DOSYNC_DB, and this alias keeps any deployment still
48
+ carrying the old variable working rather than quietly losing its data.
49
+ """
50
+ explicit = os.environ.get("DOSYNC_DB")
51
+ alias = os.environ.get("DOSYNC_DB_PATH")
52
+ if explicit:
53
+ return explicit
54
+ if alias:
55
+ logging.getLogger("dosync.server").warning(
56
+ "DOSYNC_DB_PATH is a deprecated alias for DOSYNC_DB — using %s. "
57
+ "Rename the variable to DOSYNC_DB.", alias)
58
+ return alias
59
+ return "dosync.db"
60
+
61
+
62
+ # ── Estado global del hub ─────────────────────────────────────────────────────
63
+
64
+ hub = DoSyncHub(
65
+ db_path=":memory:" if _certify_mode else _resolve_db_path()
66
+ )
67
+
68
+ # ── Async intent store ────────────────────────────────────────────────────────
69
+ # In-memory store for async intent results. TTL: 5 minutes.
70
+ import time as _time
71
+ _intent_store: dict = {}
72
+ _INTENT_STORE_TTL = 300 # seconds
73
+
74
+ def _store_cleanup():
75
+ """Remove intent results older than TTL."""
76
+ now = _time.time()
77
+ expired = [k for k, v in _intent_store.items() if now - v["created_at"] > _INTENT_STORE_TTL]
78
+ for k in expired:
79
+ del _intent_store[k]
80
+ # Idempotency keys share the same retention window as intent results.
81
+ idem_expired = [k for k, v in _idempotency_store.items() if now - v["created_at"] > _INTENT_STORE_TTL]
82
+ for k in idem_expired:
83
+ del _idempotency_store[k]
84
+
85
+
86
+ # ── Idempotency (protocol v0.2) ───────────────────────────────────────────────
87
+ # Maps a client-supplied idempotency key -> {body_hash, intent_id, created_at}.
88
+ # Delivery model: at-least-once with optional deduplication.
89
+ # - No key supplied -> every request is unique (v0.1 behavior, unchanged).
90
+ # - Key + same body -> return the cached intent_id, do NOT re-execute.
91
+ # - Key + different body -> 409 Conflict (anti-suppression: a key cannot be
92
+ # reused for different content, closing the intent-suppression attack).
93
+ # Retention matches _INTENT_STORE_TTL so a key lives as long as its result.
94
+ _idempotency_store: dict = {}
95
+
96
+ def _intent_body_hash(req: "IntentRequest") -> str:
97
+ """Stable SHA-256 of the semantically meaningful intent fields.
98
+ Excludes idempotency_key itself so the same logical intent hashes equally."""
99
+ import hashlib, json as _json
100
+ payload = {
101
+ "intent": req.intent,
102
+ "urgency": req.urgency,
103
+ "subject": req.subject,
104
+ "source": req.source,
105
+ "context": req.context,
106
+ }
107
+ encoded = _json.dumps(payload, sort_keys=True, separators=(",", ":"))
108
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
109
+
110
+ # ── Executor ──────────────────────────────────────────────────────────────────
111
+ # _certify_mode is defined before DoSyncHub initialization (see above)
112
+
113
+ if _certify_mode:
114
+ logging.getLogger("dosync.server").warning(
115
+ "DOSYNC_CERTIFY mode active — SimulatedExecutor in use. "
116
+ "No physical devices will be contacted. Do NOT use in production."
117
+ )
118
+ executor = SimulatedExecutor(failure_rate=0.0)
119
+ else:
120
+ try:
121
+ from dosync.adapters import AdapterExecutor
122
+ from dosync.adapters.wiz import WiZAdapter
123
+ executor = AdapterExecutor(hub, fallback_to_simulated=True)
124
+ executor.register(WiZAdapter(hub=hub))
125
+ # BLE adapter — opt-in. Only registered when DOSYNC_BLE_ENABLED=true, since
126
+ # not every hub has a Bluetooth radio. Universal: one adapter drives any
127
+ # BLE device via the manifest's action→characteristic map.
128
+ if os.environ.get("DOSYNC_BLE_ENABLED", "false").lower() == "true":
129
+ try:
130
+ from dosync.adapters.ble import BLEAdapter
131
+ executor.register(BLEAdapter(hub=hub))
132
+ logging.getLogger("dosync.server").info("BLEAdapter registered")
133
+ except Exception as _ble_e:
134
+ logging.getLogger("dosync.server").warning(
135
+ "BLEAdapter registration failed: %s", _ble_e)
136
+ # MAVLink adapter — opt-in. Only registered when DOSYNC_MAVLINK_ENABLED=true,
137
+ # since most hubs don't drive an aerial/ground vehicle and the adapter needs
138
+ # pymavlink. Each vehicle declares its own endpoint in its manifest
139
+ # adapter_config["connection"] (e.g. "udp:127.0.0.1:14550" for SITL, or a
140
+ # serial string for a real radio) — the adapter is identical for both; only
141
+ # the connection string changes. Kept off the default path so a hub without a
142
+ # drone never imports pymavlink or opens a MAVLink link.
143
+ if os.environ.get("DOSYNC_MAVLINK_ENABLED", "false").lower() == "true":
144
+ try:
145
+ from dosync.adapters.mavlink import MAVLinkAdapter
146
+ executor.register(MAVLinkAdapter(hub=hub))
147
+ logging.getLogger("dosync.server").info("MAVLinkAdapter registered")
148
+ except Exception as _mav_e:
149
+ logging.getLogger("dosync.server").warning(
150
+ "MAVLinkAdapter registration failed: %s", _mav_e)
151
+ from dosync.adapters.homeassistant import HABridge
152
+ _ha_url = os.environ.get("HA_URL", "http://localhost:8123")
153
+ _ha_token = os.environ.get("HA_TOKEN", "")
154
+ if _ha_token:
155
+ ha_bridge = HABridge(ha_url=_ha_url, ha_token=_ha_token, hub=hub)
156
+ executor.register(ha_bridge)
157
+ logging.getLogger("dosync.server").info("HABridge registered")
158
+ logging.getLogger("dosync.server").info(
159
+ "AdapterExecutor initialized with WiZAdapter"
160
+ )
161
+ except Exception as _e:
162
+ logging.getLogger("dosync.server").warning(
163
+ "AdapterExecutor init failed (%s) — falling back to SimulatedExecutor", _e
164
+ )
165
+ executor = SimulatedExecutor(failure_rate=0.0)
166
+
167
+ # ── Policy Engine ────────────────────────────────────────────────────────────
168
+ # Imported out here: the handler below must be able to name it, and a broken
169
+ # deployment policy file has to stop the hub rather than be caught by the generic
170
+ # handler and downgraded to a warning.
171
+ from dosync.policy_config import PolicyConfigError
172
+
173
+ try:
174
+ # Only infrastructure policies are constructed here. The deployment-specific
175
+ # ones (NeverAfterHours, RequireConfirmation, DeviceExclusion, Geofence...)
176
+ # are built by policy_config from the deployer's file — see below.
177
+ from dosync.policies import (
178
+ PolicyEngine, IntentRateLimitPolicy, DeviceActuatorRateLimitPolicy,
179
+ )
180
+ policy_engine = PolicyEngine()
181
+ from dosync.policies import ConflictResolutionPolicy, ContextualWeightingPolicy
182
+ policy_engine.add(IntentRateLimitPolicy()) # priority 0 — source rate limit
183
+ _device_rate_policy = DeviceActuatorRateLimitPolicy()
184
+ _device_rate_policy.set_db(hub.db) # wire DB for restart-safe persistence
185
+ policy_engine.add(_device_rate_policy) # priority 5 — per-device rate limit
186
+ policy_engine.add(ContextualWeightingPolicy())
187
+ policy_engine.add(ConflictResolutionPolicy(hub))
188
+
189
+ # ── Deployment policies (POL-1) ──────────────────────────────────────────
190
+ # Everything above is INFRASTRUCTURE: rate limiting, conflict resolution and
191
+ # contextual weighting carry no deployment values — they are part of what the
192
+ # reference hub is, and every hub wants them.
193
+ #
194
+ # What used to sit HERE was not: a NeverAfterHoursPolicy blocking unlock
195
+ # between 00:00 and 06:00, and a RequireConfirmationPolicy on alarms. Real
196
+ # preferences of one house, baked into the reference implementation that
197
+ # everyone else runs. Per the 2026-07-12 panel, device preferences are
198
+ # DEPLOYMENT configuration: the protocol defines how intent maps to
199
+ # capability; what should act in YOUR building is yours to declare.
200
+ #
201
+ # They now live in a file the deployer owns (see examples/policies.deployment.json,
202
+ # which contains exactly those two, ready to use):
203
+ #
204
+ # DOSYNC_POLICIES=/etc/dosync/policies.json
205
+ #
206
+ # No file configured = no deployment policies. That is a legitimate state, not
207
+ # a degraded one: the protocol has no opinion about your house. A file that is
208
+ # configured but broken raises and stops the hub — see policy_config for why a
209
+ # policy must never fail quietly.
210
+ from dosync import policy_config
211
+ _policies_path = policy_config.configured_path()
212
+ if _policies_path:
213
+ _loaded = policy_config.load_into(policy_engine, _policies_path, hub=hub)
214
+ logging.getLogger("dosync.server").info(
215
+ "Deployment policies loaded from %s: %s",
216
+ _policies_path, ", ".join(p.name for p in _loaded) or "(none declared)")
217
+ # EMERGENCY-UNSAT part b: tell the operator NOW if these rules would
218
+ # empty an emergency for the current registry — not the night it fires.
219
+ for _w in policy_config.lint_emergency_satisfiability(hub, _loaded):
220
+ logging.getLogger("dosync.server").warning("POLICY LINT: %s", _w)
221
+ else:
222
+ logging.getLogger("dosync.server").info(
223
+ "No deployment policies configured (DOSYNC_POLICIES unset) — "
224
+ "running with infrastructure policies only")
225
+ # Metrics: count policy decisions without touching policies.py (same wrap
226
+ # pattern used below for audit_log.append).
227
+ _original_policy_evaluate = policy_engine.evaluate
228
+ def _metered_policy_evaluate(intent, plan):
229
+ result = _original_policy_evaluate(intent, plan)
230
+ try:
231
+ M.policy_decisions_total.inc({"decision": result.decision.value})
232
+ except Exception:
233
+ pass
234
+ return result
235
+ policy_engine.evaluate = _metered_policy_evaluate
236
+ hub.policy_engine = policy_engine
237
+ logging.getLogger("dosync.server").info("PolicyEngine initialized with %d policies", len(policy_engine.list_policies()))
238
+ except PolicyConfigError:
239
+ # NEVER degrade a broken policy file into "no policies". The deployer
240
+ # explicitly declared restrictions; if they cannot be honored, the only safe
241
+ # answer is to refuse to start. Reaching the generic handler below would log a
242
+ # warning and run the hub UNPROTECTED — with the operator believing otherwise.
243
+ # (2026-07-14: the loader was written to fail loudly and this outer
244
+ # `except Exception` silently defeated it. The hub started, minus the policies.)
245
+ raise
246
+ except Exception:
247
+ # POL-2, closed 2026-07-15 — by incident, not by argument. This handler used
248
+ # to log one warning and continue. Then a NameError in the setup block (a
249
+ # log call before `log` existed) was swallowed right before the
250
+ # `hub.policy_engine = policy_engine` line: the engine was built, all seven
251
+ # policies registered and logged, and the hub NEVER ATTACHED to it. Production
252
+ # ran with hub.policy_engine=None — no deployment policies, no rate limits,
253
+ # no conflict resolution — while an emergency intent drove devices the
254
+ # operator had absolutely excluded. Nothing in this block has environmental
255
+ # failure modes (core imports, in-memory construction; the deployment file is
256
+ # already fatal via PolicyConfigError above), so any failure here means the
257
+ # hub would run without its policy layer. That hub must not run.
258
+ logging.getLogger("dosync.server").critical(
259
+ "PolicyEngine setup failed — refusing to start without the policy layer",
260
+ exc_info=True)
261
+ raise
262
+
263
+ # ── Notification adapter ──────────────────────────────────────────────────────
264
+ try:
265
+ from dosync.adapters.notifications import NotificationAdapter
266
+ notifier = NotificationAdapter()
267
+ executor.register(notifier)
268
+ logging.getLogger("dosync.server").info("NotificationAdapter registered")
269
+ except Exception as _e:
270
+ notifier = None
271
+ logging.getLogger("dosync.server").warning("Notifications not available: %s", _e)
272
+
273
+ # ── Auth setup ────────────────────────────────────────────────────────────────
274
+ _auth_enabled = os.environ.get("DOSYNC_AUTH", "true").lower() != "false"
275
+ _auth_manager = AuthManager(hub.db, enabled=_auth_enabled)
276
+ set_auth_manager(_auth_manager)
277
+
278
+ # ── MQTT Adapter (optional) ──────────────────────────────────────────────────
279
+ # Activated via DOSYNC_MQTT_BROKER env var. Requires: pip install paho-mqtt
280
+ # and a running MQTT broker (Mosquitto recommended).
281
+ _mqtt_broker = os.environ.get("DOSYNC_MQTT_BROKER", "")
282
+ if _mqtt_broker:
283
+ try:
284
+ from dosync.adapters.mqtt import MQTTAdapter
285
+ _mqtt_adapter = MQTTAdapter(hub=hub)
286
+ executor.register(_mqtt_adapter)
287
+ # connect() is async — deferred to lifespan startup
288
+ logging.getLogger("dosync.server").info(
289
+ "MQTTAdapter registered — will connect to %s on startup", _mqtt_broker
290
+ )
291
+ except Exception as _mqtt_e:
292
+ logging.getLogger("dosync.server").warning(
293
+ "MQTTAdapter init failed (%s) — MQTT transport disabled", _mqtt_e
294
+ )
295
+ else:
296
+ _mqtt_adapter = None
297
+
298
+ # ── Device Arbiter — emergency preemption at the execution layer ──────────────
299
+ # Wrap the fully-configured executor so an emergency-urgency write is device-final
300
+ # with respect to any lower-urgency action it overlaps with (dosync/device_arbiter.py,
301
+ # spec/CONSISTENCY-MODEL.md §3). Wrapped in ALL modes (adapter / simulated / certify)
302
+ # so behaviour is identical in production and certification. The hub releases claims
303
+ # on intent completion. `_adapter_executor` keeps the unwrapped reference for the
304
+ # isinstance checks below (the arbiter delegates everything else transparently).
305
+ from dosync.device_arbiter import DeviceArbiter
306
+ _adapter_executor = executor
307
+ executor = DeviceArbiter(executor, audit_hook=hub.audit_log.append)
308
+
309
+ # Hub-initiated intents (the capability-anomaly security alert) have no caller to
310
+ # supply an executor. Wire the fully-wrapped one so those alerts run through the
311
+ # SAME arbitration and auditing as any other intent — not a side channel.
312
+ hub.default_executor = executor
313
+ logging.getLogger("dosync.server").info("DeviceArbiter active — emergency preemption enabled")
314
+
315
+ # ── External Resolver (optional) ─────────────────────────────────────────────
316
+ # If DOSYNC_RESOLVER_URL is set, the hub delegates intent resolution to an
317
+ # external HTTP service implementing the DoSync External Resolver Protocol.
318
+ # See spec/RESOLVER-SPEC-v0.3.md §5 and docs/ADAPTER-GUIDE.md for the contract.
319
+ # Falls back to CapabilityMatchingResolver if the external service is unreachable.
320
+ _resolver_url = os.environ.get("DOSYNC_RESOLVER_URL", "")
321
+ if _resolver_url:
322
+ try:
323
+ from dosync.hub import ExternalResolver
324
+ _hub_id = getattr(hub, "hub_id", "") # app.state not available at module level
325
+ hub.resolver = ExternalResolver(hub.registry, _resolver_url, hub_id=_hub_id, hub=hub)
326
+ logging.getLogger("dosync.server").info(
327
+ "ExternalResolver configured: %s", _resolver_url
328
+ )
329
+ except Exception as _ext_e:
330
+ logging.getLogger("dosync.server").warning(
331
+ "ExternalResolver init failed (%s) — using CapabilityMatchingResolver", _ext_e
332
+ )
333
+
334
+ # Device authentication manager
335
+ hub.db.init_device_tokens_table()
336
+ hub.db.init_emergency_snapshots_table()
337
+ hub.db.init_operations_table()
338
+ _device_auth_manager = DeviceAuthManager(hub.db)
339
+ set_device_auth_manager(_device_auth_manager)
340
+
341
+ # ── Multi-hub monitor (Phase A) ───────────────────────────────────────────────
342
+ # Active only when this hub is configured as a standby. On a primary it stays
343
+ # None and all multi-hub endpoints report role=primary with no monitor. The
344
+ # monitor is the pure decision core (dosync/hub_monitor.py); the polling loop
345
+ # that feeds it observations is started in the lifespan/startup when standby.
346
+ from dosync.hub_monitor import HubMonitor, HeartbeatObservation, MonitorState
347
+
348
+ _hub_role = os.environ.get("DOSYNC_HUB_ROLE", "primary").lower()
349
+ _primary_url = os.environ.get("DOSYNC_PRIMARY_URL") # peer to watch (standby only)
350
+ _hub_monitor: Optional[HubMonitor] = None
351
+ if _hub_role == "standby":
352
+ _failure_threshold = int(os.environ.get("DOSYNC_FAILURE_THRESHOLD", "3"))
353
+ _hub_monitor = HubMonitor(
354
+ failure_threshold=_failure_threshold,
355
+ local_device_count=len(hub.registry.all()),
356
+ )
357
+
358
+
359
+ # ── WebSocket manager ─────────────────────────────────────────────────────────
360
+
361
+ class ConnectionManager:
362
+ """Gestiona todas las conexiones WebSocket activas."""
363
+
364
+ def __init__(self):
365
+ self._connections: list[WebSocket] = []
366
+
367
+ async def connect(self, ws: WebSocket) -> None:
368
+ await ws.accept()
369
+ self._connections.append(ws)
370
+ logging.getLogger("dosync.ws").info(
371
+ "Client connected — total: %d", len(self._connections)
372
+ )
373
+
374
+ def disconnect(self, ws: WebSocket) -> None:
375
+ try:
376
+ self._connections.remove(ws)
377
+ except ValueError:
378
+ pass
379
+ logging.getLogger("dosync.ws").info(
380
+ "Client disconnected — total: %d", len(self._connections)
381
+ )
382
+
383
+ async def broadcast(self, event_type: str, data: dict) -> None:
384
+ """Emite un evento a todos los clientes conectados."""
385
+ if not self._connections:
386
+ return
387
+ message = json.dumps({"type": event_type, "data": data})
388
+ dead = []
389
+ for ws in self._connections:
390
+ try:
391
+ await ws.send_text(message)
392
+ except Exception:
393
+ dead.append(ws)
394
+ for ws in dead:
395
+ self._connections.remove(ws)
396
+
397
+ @property
398
+ def active_connections(self) -> int:
399
+ return len(self._connections)
400
+
401
+
402
+ ws_manager = ConnectionManager()
403
+
404
+
405
+ # ── Hook hub events into WebSocket broadcast ──────────────────────────────────
406
+
407
+ _original_on_event = hub.receive_event.__wrapped__ if hasattr(hub.receive_event, '__wrapped__') else None
408
+
409
+ async def _ws_event_handler(event):
410
+ await ws_manager.broadcast("device_event", {
411
+ "device_id": event.device_id,
412
+ "event_id": event.event_id,
413
+ "severity": event.severity.value,
414
+ "data": event.data,
415
+ "timestamp": event.timestamp,
416
+ })
417
+
418
+ hub.on_event(_ws_event_handler)
419
+
420
+
421
+ # Patch audit log to broadcast intent executions
422
+ _original_audit_append = hub.audit_log.append
423
+
424
+ def _patched_audit_append(entry: dict) -> str:
425
+ result = _original_audit_append(entry)
426
+ import asyncio
427
+ entry_type = entry.get("type", "")
428
+ if entry_type in ("intent_executed", "phase_executed", "presence_updated"):
429
+ # WebSocket fan-out is genuinely optional: no subscriber, nothing to do,
430
+ # and a broadcast failure must never affect the audit append that just
431
+ # succeeded. get_running_loop() replaces the deprecated get_event_loop()
432
+ # and states the requirement directly — this only makes sense with a loop.
433
+ try:
434
+ loop = asyncio.get_running_loop()
435
+ except RuntimeError:
436
+ loop = None
437
+ if loop is not None:
438
+ try:
439
+ loop.create_task(ws_manager.broadcast(entry_type, entry))
440
+ except Exception as e:
441
+ logging.getLogger("dosync.server").debug(
442
+ "WebSocket broadcast of %s not scheduled: %s", entry_type, e)
443
+ return result
444
+
445
+ hub.audit_log.append = _patched_audit_append
446
+
447
+ def on_event(event: DeviceEvent):
448
+ logging.getLogger("dosync.server").info(
449
+ "Event received: %s from %s [%s]",
450
+ event.event_id, event.device_id, event.severity.value
451
+ )
452
+
453
+ hub.on_event(on_event)
454
+
455
+
456
+ # ── Schemas de entrada ────────────────────────────────────────────────────────
457
+
458
+ class SensorIn(BaseModel):
459
+ id: str
460
+ type: str
461
+ description: str = ""
462
+ unit: Optional[str] = None
463
+ poll_interval_ms: int = 30000
464
+ # SENSOR-KIND: "environment" (measures the world) | "device_state" (reports
465
+ # the device's own condition). Default keeps every existing client valid.
466
+ kind: str = "environment"
467
+
468
+ class ActuatorIn(BaseModel):
469
+ id: str
470
+ type: str
471
+ description: str = ""
472
+ params_schema: dict = {} # JSON Schema (draft 2020-12) for this action's params
473
+
474
+ class EventSpecIn(BaseModel):
475
+ id: str
476
+ severity: str
477
+ description: str = ""
478
+
479
+ class RegisterDeviceRequest(BaseModel):
480
+ device_id: str
481
+ device_name: str
482
+ manufacturer: str
483
+ model: str
484
+ firmware: str
485
+ category: str
486
+ tags: list[str]
487
+ sensors: list[SensorIn] = []
488
+ actuators: list[ActuatorIn] = []
489
+ events: list[EventSpecIn] = []
490
+ emergency_capable: bool = False
491
+ cert_tier: str = "basic"
492
+ adapter: Optional[str] = None # which adapter drives this device (e.g. "mavlink", "wiz")
493
+ adapter_config: dict = {} # adapter-specific config (e.g. {"connection": "udp:127.0.0.1:14550"})
494
+ device_token: Optional[str] = None # token de autenticación del dispositivo
495
+ certificate_pem: Optional[str] = None # PEM certificate for mTLS auth (optional)
496
+
497
+ class PresenceSignalRequest(BaseModel):
498
+ device_id: str
499
+ signal_type: str
500
+ present: bool
501
+ confidence: float = 0.7
502
+ member_id: Optional[str] = None
503
+
504
+ class IntentRequest(BaseModel):
505
+ intent: str
506
+ urgency: str = "info"
507
+ subject: Optional[str] = None
508
+ source: str = "api"
509
+ context: dict[str, Any] = {}
510
+ idempotency_key: Optional[str] = None # opt-in dedup (protocol v0.2). UUID recommended.
511
+
512
+ class EventRequest(BaseModel):
513
+ device_id: str
514
+ event_id: str
515
+ severity: str
516
+ data: dict[str, Any] = {}
517
+
518
+
519
+ # Heartbeat report bounds (parada técnica 2026-07-21, Sosa). Module-level, not
520
+ # class attributes: in Pydantic v2 a name starting with "_" inside a BaseModel
521
+ # becomes a ModelPrivateAttr, not an int. Bounds fit a generous real self-report
522
+ # (battery, rssi, firmware, uptime, a few custom fields) with wide margin while
523
+ # making abuse impossible — a report is telemetry about ONE device, not a data
524
+ # channel.
525
+ _HEARTBEAT_MAX_REPORT_KEYS = 32
526
+ _HEARTBEAT_MAX_REPORT_BYTES = 4096
527
+
528
+
529
+ class HeartbeatRequest(BaseModel):
530
+ device_id: str
531
+ # A device MAY volunteer a structured self-report (battery %, rssi, firmware,
532
+ # uptime…). Optional and free-form: the hub stores it verbatim and takes no
533
+ # position on its CONTENTS — but it does bound its SIZE. "No position on the
534
+ # contents" is not "accepts unbounded input": a compromised or buggy device
535
+ # could otherwise push a multi-megabyte report on every heartbeat, persisted.
536
+ report: dict[str, Any] = {}
537
+
538
+ @field_validator("report")
539
+ @classmethod
540
+ def _bound_report(cls, v: dict) -> dict:
541
+ if len(v) > _HEARTBEAT_MAX_REPORT_KEYS:
542
+ raise ValueError(
543
+ f"heartbeat report has {len(v)} keys; max {_HEARTBEAT_MAX_REPORT_KEYS}")
544
+ try:
545
+ size = len(json.dumps(v).encode("utf-8"))
546
+ except (TypeError, ValueError) as e:
547
+ raise ValueError(f"heartbeat report is not JSON-serializable: {e}") from e
548
+ if size > _HEARTBEAT_MAX_REPORT_BYTES:
549
+ raise ValueError(
550
+ f"heartbeat report is {size} bytes; max {_HEARTBEAT_MAX_REPORT_BYTES}")
551
+ return v
552
+
553
+
554
+ # ── App ───────────────────────────────────────────────────────────────────────
555
+
556
+ @asynccontextmanager
557
+ async def lifespan(app: FastAPI):
558
+ log = logging.getLogger("dosync.server")
559
+ # The port was hardcoded here and lied whenever the hub ran anywhere else
560
+ # (`dosync-hub --port 8080` still announced 47200). Report what is actually
561
+ # in use. The database path is logged too: an installed `dosync-hub` writes
562
+ # to the CURRENT DIRECTORY by default, which surprises people who run it
563
+ # from different places and wonder where their devices went.
564
+ log.info("DoSync Hub started — port %s, database %s",
565
+ os.environ.get("DOSYNC_PORT", "47200"),
566
+ ":memory: (certify mode)" if _certify_mode else _resolve_db_path())
567
+ first_token = _auth_manager.ensure_default_key()
568
+ if first_token:
569
+ print("\n" + "="*60)
570
+ print(" DoSync Hub — First Run")
571
+ print(" Your API key (save this — shown only once):")
572
+ print(f"\n {first_token}\n")
573
+ print(" Use: Authorization: Bearer <token>")
574
+ print(" Or: DOSYNC_AUTH=false to disable (dev only)")
575
+ print("="*60 + "\n")
576
+ elif not _auth_enabled:
577
+ log.warning("Auth DISABLED — do not use in production")
578
+ # PKI status check
579
+ try:
580
+ from dosync.security import get_status as get_pki_status
581
+ _pki = get_pki_status()
582
+ if _pki.is_ready:
583
+ log.info("=== TLS/PKI: ACTIVE === CA valid %d days · Hub cert valid %d days · %d adapter cert(s)",
584
+ _pki.ca_info.days_until_expiry, _pki.hub_info.days_until_expiry, len(_pki.adapter_certs))
585
+ if _pki.hub_info.is_expiring_soon:
586
+ log.warning("Hub cert expires in %d days — run: python3 -m dosync.security renew hub", _pki.hub_info.days_until_expiry)
587
+ else:
588
+ log.warning("=== TLS/PKI: NOT CONFIGURED === Hub running on plain HTTP. Run: bash setup_pki.sh")
589
+ except Exception as _pki_e:
590
+ log.warning("PKI status check failed: %s", _pki_e)
591
+ # ── Startup recovery — re-disparar emergencias activas pre-corte ─────────
592
+ try:
593
+ active = hub.db.get_active_emergency_snapshots()
594
+ if active:
595
+ log.warning("STARTUP RECOVERY: %d emergency intent(s) were active before shutdown", len(active))
596
+ for snap in active:
597
+ age_minutes = ((__import__('time').time() - snap['fired_at']) / 60)
598
+ if age_minutes < 60: # solo re-disparar si fue hace menos de 1 hora
599
+ log.warning("Re-firing intent '%s' (was active %.1f min ago)", snap['intent_class'], age_minutes)
600
+ try:
601
+ from dosync.models import Intent, IntentClass, Urgency
602
+ import uuid, time as _time
603
+ recovery_intent = Intent(
604
+ intent=IntentClass(snap['intent_class']),
605
+ intent_id=f"recovery-{uuid.uuid4().hex[:8]}",
606
+ urgency=Urgency(snap['urgency']),
607
+ source="recovery",
608
+ context={**snap['context'], "recovery": True, "original_intent_id": snap['intent_id']},
609
+ )
610
+ hub.fire_intent(recovery_intent)
611
+ hub.db.resolve_emergency_snapshot(snap['intent_id'])
612
+ log.info("Recovery intent '%s' fired successfully", snap['intent_class'])
613
+ except Exception as _re:
614
+ log.error("Failed to re-fire recovery intent '%s': %s", snap['intent_class'], _re)
615
+ else:
616
+ log.info("Skipping stale emergency snapshot '%s' (%.1f min ago — too old)", snap['intent_class'], age_minutes)
617
+ hub.db.resolve_emergency_snapshot(snap['intent_id'])
618
+ hub.db.clear_old_snapshots(max_age_hours=24)
619
+ except Exception as _recovery_e:
620
+ log.error("Startup recovery failed: %s", _recovery_e)
621
+
622
+ # ── Background state refresher ──────────────────────────────────────────
623
+ # Periodically queries device state via get_state() — active health probing.
624
+ # Hub-owned, so it runs under ANY resolver (it was gated on the resolver
625
+ # being StateAwareResolver, which production never is: it never ran).
626
+ _refresh_task = None
627
+ try:
628
+ from dosync.adapters import AdapterExecutor
629
+ # The refresher is HUB-owned and must run under ANY resolver. It used to
630
+ # be gated on `isinstance(hub.resolver, StateAwareResolver)`, which is
631
+ # always False in production (ExternalResolver) — so it never ran, and
632
+ # said so only at debug level. The only real requirement is an
633
+ # AdapterExecutor, which is what sources the adapters to probe.
634
+ if isinstance(_adapter_executor, AdapterExecutor):
635
+ _refresh_task = asyncio.create_task(
636
+ hub.start_state_refresh(_adapter_executor)
637
+ )
638
+ log.info("Background state refresher started (hub-owned, active health probing)")
639
+ else:
640
+ log.warning("Background state refresher NOT started: executor is %s, "
641
+ "not an AdapterExecutor — device health will be passive only",
642
+ type(_adapter_executor).__name__)
643
+ except Exception as _refresh_e:
644
+ log.warning("Failed to start background state refresher: %s", _refresh_e)
645
+
646
+ # Purge old terminal operations. clear_old_snapshots was wired here and
647
+ # clear_old_operations never was, so the operations table grew forever.
648
+ # Own try/except on purpose: a failure here must be reported as itself, not
649
+ # swallowed by an unrelated handler. Active (non-terminal) operations are
650
+ # never purged by age — a long-running op is exactly what must survive for
651
+ # reconciliation.
652
+ try:
653
+ _purged_ops = hub.db.clear_old_operations(max_age_hours=24)
654
+ if _purged_ops:
655
+ log.info("Purged %d terminal operation(s) older than 24h", _purged_ops)
656
+ except Exception as _ops_purge_e:
657
+ log.warning("Failed to purge old operations: %s", _ops_purge_e)
658
+
659
+ # Purge expired rate limit events from DB (prevents unbounded table growth)
660
+ try:
661
+ purged = hub.db.purge_rate_limit_events(window_seconds=60)
662
+ if purged > 0:
663
+ log.info("Startup: purged %d expired rate limit event(s) from DB", purged)
664
+ except Exception as _pe:
665
+ log.warning("Could not purge rate limit events: %s", _pe)
666
+
667
+ # MQTT adapter startup (async connect, after event loop is running)
668
+ if _mqtt_adapter is not None:
669
+ try:
670
+ await _mqtt_adapter.connect()
671
+ except Exception as _mc:
672
+ log.warning("MQTTAdapter startup connect failed: %s", _mc)
673
+
674
+ yield
675
+
676
+ # Cancel background refresher on shutdown
677
+ if _refresh_task and not _refresh_task.done():
678
+ _refresh_task.cancel()
679
+ try:
680
+ await _refresh_task
681
+ except asyncio.CancelledError:
682
+ pass
683
+
684
+ # MQTT adapter shutdown
685
+ if _mqtt_adapter is not None:
686
+ try:
687
+ await _mqtt_adapter.disconnect()
688
+ except Exception:
689
+ pass
690
+
691
+ log.info("DoSync Hub shutting down")
692
+
693
+ app = FastAPI(
694
+ title="DoSync Hub",
695
+ description=(
696
+ "DoSync Protocol — REST API\n\n"
697
+ "El hub central que conecta la IA con los gadgets del hogar.\n"
698
+ "Protocolo abierto · Apache 2.0 · github.com/dosync/protocol"
699
+ ),
700
+ version=__version__,
701
+ lifespan=lifespan,
702
+ )
703
+
704
+ # ── API versioning ─────────────────────────────────────────────────────────────
705
+ # Protocol version: the DoSync semantic protocol version (intent format, manifest schema)
706
+ # API version: the REST API version (URL prefix /v1/)
707
+ # These are exposed as response headers on every request so clients can detect
708
+ # the version without parsing the URL or the response body.
709
+ DOSYNC_PROTOCOL_VERSION = "0.4"
710
+ DOSYNC_API_VERSION = "1"
711
+
712
+ from starlette.middleware.base import BaseHTTPMiddleware
713
+ from starlette.requests import Request as StarletteRequest
714
+
715
+ class VersionHeaderMiddleware(BaseHTTPMiddleware):
716
+ """
717
+ Adds DoSync version headers to every HTTP response.
718
+
719
+ Headers:
720
+ X-DoSync-Protocol-Version The semantic protocol version (e.g. 0.1)
721
+ X-DoSync-API-Version The REST API version (e.g. 1, matching /v1/)
722
+
723
+ Clients SHOULD read these headers to detect protocol version without
724
+ parsing the URL. Future breaking API changes will increment the API version
725
+ and introduce a new URL prefix (/v2/) while keeping /v1/ active during
726
+ the deprecation window.
727
+ """
728
+ async def dispatch(self, request: StarletteRequest, call_next):
729
+ response = await call_next(request)
730
+ response.headers["X-DoSync-Protocol-Version"] = DOSYNC_PROTOCOL_VERSION
731
+ response.headers["X-DoSync-API-Version"] = DOSYNC_API_VERSION
732
+ return response
733
+
734
+ app.add_middleware(VersionHeaderMiddleware)
735
+
736
+
737
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
738
+
739
+ @app.websocket("/ws")
740
+ async def websocket_endpoint(ws: WebSocket):
741
+ token = ws.query_params.get("token", "")
742
+ if _auth_enabled and not _auth_manager.verify(token):
743
+ await ws.close(code=4001, reason="Unauthorized")
744
+ return
745
+
746
+ await ws_manager.connect(ws)
747
+ try:
748
+ await ws.send_text(json.dumps({
749
+ "type": "connected",
750
+ "data": {
751
+ "devices": len(hub.registry.all()),
752
+ "hub_version": __version__,
753
+ "protocol": f"dosync/{DOSYNC_PROTOCOL_VERSION}",
754
+ }
755
+ }))
756
+ import asyncio
757
+ while True:
758
+ await asyncio.sleep(30)
759
+ try:
760
+ await ws.send_text(json.dumps({"type": "ping", "data": {}}))
761
+ except Exception:
762
+ break
763
+ except WebSocketDisconnect:
764
+ pass
765
+ except Exception:
766
+ pass
767
+ finally:
768
+ ws_manager.disconnect(ws)
769
+
770
+
771
+
772
+
773
+ @app.get("/", response_class=FileResponse, tags=["Status"])
774
+ def dashboard():
775
+ from pathlib import Path
776
+ dashboard_path = Path(__file__).parent / "dashboard.html"
777
+ if dashboard_path.exists():
778
+ return FileResponse(dashboard_path, media_type="text/html")
779
+ return FileResponse.__new__(FileResponse)
780
+
781
+
782
+ @app.get("/api", tags=["Status"])
783
+ def root():
784
+ return {
785
+ "name": "DoSync Hub",
786
+ "version": __version__,
787
+ "protocol": f"dosync/{DOSYNC_PROTOCOL_VERSION}",
788
+ "status": "running",
789
+ "devices_registered": len(hub.registry.all()),
790
+ }
791
+
792
+
793
+ @app.post("/v1/devices/register", tags=["Devices"])
794
+ def register_device(req: RegisterDeviceRequest, auth: str = Depends(require_auth)):
795
+ # ── Device authentication ──────────────────────────────────────────────
796
+ from dosync.auth import get_device_auth_manager
797
+ device_auth = get_device_auth_manager()
798
+ if device_auth:
799
+ if req.device_token:
800
+ valid, reason = device_auth.verify(req.device_id, req.device_token)
801
+ if not valid:
802
+ raise HTTPException(status_code=403, detail=f"Device auth failed: {reason}")
803
+ elif device_auth.strict:
804
+ raise HTTPException(
805
+ status_code=403,
806
+ detail=f"Device '{req.device_id}' requires a device_token (strict mode)"
807
+ )
808
+
809
+ # ── Certificate authentication (Option C) ─────────────────────────────
810
+ # Device can include certificate_pem in the register body.
811
+ # This PEM is verified against the local CA — same logic as /v1/devices/verify-cert.
812
+ # If valid, the manifest is marked cert_authenticated=True.
813
+ # Devices without certificate_pem fall back to token-based auth (backward compatible).
814
+ cert_authenticated = False
815
+ cert_pem = getattr(req, 'certificate_pem', None)
816
+ if cert_pem:
817
+ try:
818
+ import tempfile
819
+ from pathlib import Path
820
+ from dosync.security import verify_chain, _cert_info
821
+ with tempfile.NamedTemporaryFile(
822
+ mode='w', suffix='.crt', delete=False
823
+ ) as f:
824
+ f.write(cert_pem.strip())
825
+ tmp_path = Path(f.name)
826
+ try:
827
+ chain_valid = verify_chain(tmp_path)
828
+ cert_info = _cert_info(tmp_path)
829
+ finally:
830
+ tmp_path.unlink(missing_ok=True)
831
+ if chain_valid and cert_info and not cert_info.is_expired:
832
+ cert_authenticated = True
833
+ logging.getLogger("dosync.server").info("Device %s authenticated via certificate (expires in %d days)",
834
+ req.device_id, cert_info.days_until_expiry)
835
+ except Exception as _cert_e:
836
+ logging.getLogger("dosync.server").debug("Certificate verification for %s: %s", req.device_id, _cert_e)
837
+
838
+ # ── Registro normal ────────────────────────────────────────────────────
839
+ try:
840
+ manifest = CapabilityManifest(
841
+ device_id=req.device_id,
842
+ device_name=req.device_name,
843
+ manufacturer=req.manufacturer,
844
+ model=req.model,
845
+ firmware=req.firmware,
846
+ category=DeviceCategory(req.category),
847
+ tags=req.tags,
848
+ sensors=[SensorSpec(s.id, s.type, s.description, s.unit,
849
+ poll_interval_ms=s.poll_interval_ms,
850
+ kind=s.kind)
851
+ for s in req.sensors],
852
+ actuators=[ActuatorSpec(a.id, a.type, a.description, a.params_schema)
853
+ for a in req.actuators],
854
+ events=[EventSpec(e.id, Severity(e.severity), e.description)
855
+ for e in req.events],
856
+ emergency_capable=req.emergency_capable,
857
+ cert_tier=CertTier(req.cert_tier),
858
+ adapter=req.adapter,
859
+ adapter_config=dict(req.adapter_config or {}),
860
+ )
861
+ # Store mTLS authentication status in adapter_config
862
+ if cert_authenticated:
863
+ manifest.adapter_config = {
864
+ **(manifest.adapter_config or {}),
865
+ "cert_authenticated": True,
866
+ }
867
+ # Validate that each actuator's params_schema is well-formed JSON Schema
868
+ # (draft 2020-12). Protects the integrity of the standard: a manifest that
869
+ # claims JSON Schema must actually be valid. Skipped gracefully if the
870
+ # jsonschema library is absent (logs a warning, does not fail).
871
+ from dosync.validation import validate_manifest_schemas
872
+ schema_problems = validate_manifest_schemas(manifest)
873
+ if schema_problems:
874
+ raise HTTPException(
875
+ status_code=422,
876
+ detail="Invalid actuator params_schema: " + "; ".join(schema_problems),
877
+ )
878
+ hub.register_device(manifest)
879
+ return {
880
+ "status": "registered",
881
+ "device_id": req.device_id,
882
+ "cert_authenticated": cert_authenticated,
883
+ }
884
+ except ValueError as e:
885
+ raise HTTPException(status_code=422, detail=str(e))
886
+
887
+
888
+ @app.get("/v1/intents/{intent_class}/explain", tags=["intents"])
889
+ async def explain_intent(
890
+ intent_class: str,
891
+ urgency: str = "info",
892
+ location: str = "",
893
+ auth=Depends(require_auth),
894
+ ):
895
+ """
896
+ Explainability endpoint — muestra el razonamiento del resolver para un intent.
897
+
898
+ Para cada dispositivo registrado, detalla:
899
+ - Score total y desglose (tag overlap, location bonus, emergency bonus, actuator match)
900
+ - Por qué fue incluido o excluido del ActionPlan
901
+ - Tags que matchearon con las resolution tags del intent
902
+
903
+ Nota: este endpoint muestra el scoring del resolver. El PolicyEngine puede
904
+ modificar el plan antes de la ejecución — ver el audit log para el resultado real.
905
+
906
+ Diseñado para ser consumido tanto por humanos como por sistemas de IA
907
+ que interpreten el comportamiento del hub. Ver docs/DESIGN-PRINCIPLES.md.
908
+ """
909
+ from dosync.models import Intent, IntentClass, Urgency as _Urgency
910
+ import uuid, time as _time
911
+
912
+ # Validar intent_class
913
+ try:
914
+ intent_cls = IntentClass(intent_class)
915
+ except ValueError:
916
+ registered = [r["name"] for r in hub.db.list_intent_classes()]
917
+ raise HTTPException(
918
+ status_code=400,
919
+ detail=(f"Invalid intent class name '{intent_class}' "
920
+ f"(must match ^[a-z][a-z0-9_]*$). Registered classes: {registered}")
921
+ )
922
+
923
+ # Validar urgency
924
+ try:
925
+ urgency_cls = _Urgency(urgency)
926
+ except ValueError:
927
+ raise HTTPException(
928
+ status_code=400,
929
+ detail=f"Invalid urgency '{urgency}'. Valid values: info, warning, alert, emergency"
930
+ )
931
+
932
+ # Construir intent de prueba
933
+ context = {}
934
+ if location:
935
+ context["location"] = location
936
+
937
+ intent = Intent(
938
+ intent=intent_cls,
939
+ urgency=urgency_cls,
940
+ context=context,
941
+ )
942
+
943
+ # Obtener explicación del resolver
944
+ explanation = hub.resolver.explain(intent)
945
+ return explanation
946
+
947
+
948
+ @app.get("/v1/health/devices", tags=["health"])
949
+ async def get_device_health(
950
+ threshold: float = 0.7,
951
+ last_n: int = 100,
952
+ auth=Depends(require_auth),
953
+ ):
954
+ """
955
+ Device Health Monitor — execution success-rate statistics per device.
956
+
957
+ Returns each device's success rate over the last `last_n` executions; devices
958
+ below `threshold` appear in `alerts`. Use this to find devices that FAIL
959
+ OFTEN and need attention. The decision of what to do is always the operator's.
960
+
961
+ ── Which health endpoint do I want? ─────────────────────────────────────────
962
+ * /v1/health/devices (this one) — HISTORICAL success rate of actions.
963
+ "Has this device been failing lately?"
964
+ * /v1/health/reachability — CURRENT reachable/unreachable state, including
965
+ device-initiated heartbeats. "Is this device
966
+ responding right now, and how do I know?"
967
+ They answer different questions; a device can be reachable now yet have a poor
968
+ historical success rate, or vice versa.
969
+ """
970
+ all_health = hub.db.get_all_health(last_n=last_n)
971
+ alerts = hub.db.get_health_alerts(threshold=threshold, last_n=last_n)
972
+
973
+ return {
974
+ "devices": all_health,
975
+ "alerts": alerts,
976
+ "threshold": threshold,
977
+ "last_n": last_n,
978
+ "total_devices_monitored": len(all_health),
979
+ "total_alerts": len(alerts),
980
+ }
981
+
982
+
983
+ @app.get("/v1/health/devices/{device_id}", tags=["health"])
984
+ async def get_single_device_health(
985
+ device_id: str,
986
+ last_n: int = 100,
987
+ auth=Depends(require_auth),
988
+ ):
989
+ """Historical execution success-rate for one device (see /v1/health/devices
990
+ for the full note on which health endpoint answers which question; for
991
+ current reachability + heartbeat state use /v1/health/reachability)."""
992
+ health = hub.db.get_device_health(device_id, last_n=last_n)
993
+ if health["total"] == 0:
994
+ raise HTTPException(status_code=404, detail=f"No execution history for device '{device_id}'")
995
+ return health
996
+
997
+
998
+ @app.get("/v1/health/reachability", tags=["health"])
999
+ async def get_reachability(auth=Depends(require_auth)):
1000
+ """Current reachability of every device the hub has interacted with.
1001
+
1002
+ Complements /v1/health/devices (historical success rate) with the CURRENT
1003
+ reachable/unreachable state. Two signals feed it: PASSIVE (derived from real
1004
+ actions — the last interaction) and ACTIVE PUSH (device-initiated heartbeats,
1005
+ POST /v1/heartbeat — see the `last_heartbeat` field and the per-device
1006
+ `note`). It never asserts a device is "powered off" (it cannot tell an off
1007
+ device from a
1008
+ network-unreachable one). Read-only.
1009
+ """
1010
+ snapshots = [hub.health.snapshot(d.device_id) for d in hub.registry.all()]
1011
+ unreachable = [s for s in snapshots if s["reachable"] is False]
1012
+ # DEVICE-HEALTH-ACTIVE (c): for each unreachable device, cross-reference the
1013
+ # heartbeat signal to attribute a cause WITH its evidence and confidence —
1014
+ # actionable for the operator ("is this something I need to go fix?") without
1015
+ # pretending to certainty the transport cannot provide.
1016
+ assessments = {s["device_id"]: hub.health.reachability_assessment(s["device_id"])
1017
+ for s in unreachable}
1018
+ return {
1019
+ "devices": snapshots,
1020
+ "unreachable": [s["device_id"] for s in unreachable],
1021
+ "assessments": assessments,
1022
+ "total_devices": len(snapshots),
1023
+ "total_unreachable": len(unreachable),
1024
+ "note": ("Passive + active health. 'unreachable' means no response to the last "
1025
+ "action. For each unreachable device, `assessments` attributes a likely "
1026
+ "cause (network_or_app / likely_powered_off / indeterminate) with its "
1027
+ "evidence and confidence, cross-referenced against heartbeats — never a "
1028
+ "bare guess."),
1029
+ }
1030
+
1031
+
1032
+ @app.get("/v1/devices", tags=["Devices"])
1033
+ def list_devices(auth: str = Depends(require_auth)):
1034
+ return {
1035
+ "count": len(hub.registry.all()),
1036
+ "devices": [d.to_public_dict() for d in hub.registry.all()],
1037
+ }
1038
+
1039
+
1040
+ @app.get("/v1/devices/{device_id}", tags=["Devices"])
1041
+ def get_device(device_id: str, auth: str = Depends(require_auth)):
1042
+ device = hub.registry.get(device_id)
1043
+ if not device:
1044
+ raise HTTPException(status_code=404, detail=f"Device '{device_id}' not found")
1045
+ return device.to_public_dict()
1046
+
1047
+
1048
+ @app.post("/v1/devices/provision", tags=["Devices"])
1049
+ def provision_device(body: dict, auth: str = Depends(require_auth)):
1050
+ """
1051
+ Pre-registra un device_id y genera su token de autenticación.
1052
+ El token se muestra UNA SOLA VEZ — guardarlo de inmediato.
1053
+ """
1054
+ from dosync.auth import get_device_auth_manager
1055
+ device_auth = get_device_auth_manager()
1056
+ if not device_auth:
1057
+ raise HTTPException(status_code=503, detail="Device auth not configured")
1058
+ device_id = body.get("device_id")
1059
+ if not device_id:
1060
+ raise HTTPException(status_code=422, detail="device_id required")
1061
+ label = body.get("label", device_id)
1062
+ token = device_auth.provision(device_id, label)
1063
+ return {
1064
+ "device_id": device_id,
1065
+ "device_token": token,
1066
+ "warning": "Store this token immediately — it will not be shown again.",
1067
+ "usage": "Include device_token in your /v1/devices/register request"
1068
+ }
1069
+
1070
+
1071
+ @app.post("/v1/devices/verify-cert", tags=["Devices"])
1072
+ async def verify_device_cert(body: dict, auth: str = Depends(require_auth)):
1073
+ """
1074
+ Verify a device's client certificate against the local CA.
1075
+
1076
+ The device presents its certificate PEM and the hub verifies:
1077
+ - It is a valid X.509 certificate
1078
+ - It was signed by the local DoSync CA
1079
+ - It has not expired
1080
+ - It identifies as a DoSync adapter (CN starts with 'dosync-adapter-')
1081
+
1082
+ Returns cert_authenticated=True if all checks pass.
1083
+ The device should include certificate_pem in /v1/devices/register
1084
+ to record the cert_authenticated status in its manifest.
1085
+
1086
+ Example usage (device side):
1087
+ cert_pem = open("certs/adapters/gpio.crt").read()
1088
+ r = requests.post("/v1/devices/verify-cert", json={"certificate_pem": cert_pem})
1089
+ if r.json()["cert_authenticated"]:
1090
+ # include certificate_pem in /v1/devices/register body
1091
+ """
1092
+ import tempfile, os as _os
1093
+ from pathlib import Path
1094
+ from dosync.security import verify_chain, _cert_info
1095
+
1096
+ cert_pem = body.get("certificate_pem", "").strip()
1097
+ if not cert_pem:
1098
+ raise HTTPException(status_code=422, detail="certificate_pem is required")
1099
+
1100
+ try:
1101
+ # Write to temp file for openssl verification
1102
+ with tempfile.NamedTemporaryFile(
1103
+ mode='w', suffix='.crt', delete=False
1104
+ ) as f:
1105
+ f.write(cert_pem)
1106
+ tmp_path = Path(f.name)
1107
+
1108
+ try:
1109
+ # Verify chain against local CA
1110
+ chain_valid = verify_chain(tmp_path)
1111
+ cert_info = _cert_info(tmp_path)
1112
+ finally:
1113
+ tmp_path.unlink(missing_ok=True)
1114
+
1115
+ if not cert_info:
1116
+ return {
1117
+ "cert_authenticated": False,
1118
+ "reason": "Could not parse certificate",
1119
+ }
1120
+
1121
+ if cert_info.is_expired:
1122
+ return {
1123
+ "cert_authenticated": False,
1124
+ "reason": f"Certificate expired {abs(cert_info.days_until_expiry)} days ago",
1125
+ }
1126
+
1127
+ if not chain_valid:
1128
+ return {
1129
+ "cert_authenticated": False,
1130
+ "reason": "Certificate not signed by local DoSync CA",
1131
+ }
1132
+
1133
+ logging.getLogger("dosync.server").info("Device cert verified: %s (expires in %d days)",
1134
+ cert_info.subject, cert_info.days_until_expiry)
1135
+
1136
+ return {
1137
+ "cert_authenticated": True,
1138
+ "subject": cert_info.subject,
1139
+ "issuer": cert_info.issuer,
1140
+ "expires": cert_info.not_after,
1141
+ "days_until_expiry": cert_info.days_until_expiry,
1142
+ "serial": cert_info.serial,
1143
+ }
1144
+
1145
+ except Exception as e:
1146
+ logging.getLogger("dosync.server").warning("verify-cert error: %s", e)
1147
+ raise HTTPException(status_code=500, detail=f"Certificate verification error: {e}")
1148
+
1149
+
1150
+ @app.get("/v1/devices/provisioned", tags=["Devices"])
1151
+ def list_provisioned_devices(auth: str = Depends(require_auth)):
1152
+ """Lista todos los device_ids pre-registrados."""
1153
+ from dosync.auth import get_device_auth_manager
1154
+ device_auth = get_device_auth_manager()
1155
+ if not device_auth:
1156
+ return {"provisioned": []}
1157
+ return {"provisioned": device_auth.list_provisioned()}
1158
+
1159
+
1160
+ @app.delete("/v1/devices/{device_id}/token", tags=["Devices"])
1161
+ def revoke_device_token(device_id: str, auth: str = Depends(require_auth)):
1162
+ """Revoca el token de un dispositivo — deberá ser re-provisionado."""
1163
+ from dosync.auth import get_device_auth_manager
1164
+ device_auth = get_device_auth_manager()
1165
+ if not device_auth:
1166
+ raise HTTPException(status_code=503, detail="Device auth not configured")
1167
+ revoked = device_auth.revoke(device_id)
1168
+ if not revoked:
1169
+ raise HTTPException(status_code=404, detail=f"Device '{device_id}' not provisioned")
1170
+ return {"status": "revoked", "device_id": device_id}
1171
+
1172
+
1173
+ @app.delete("/v1/devices/{device_id}", tags=["Devices"])
1174
+ def unregister_device(device_id: str, auth: str = Depends(require_auth)):
1175
+ if not hub.registry.get(device_id):
1176
+ raise HTTPException(status_code=404, detail=f"Device '{device_id}' not found")
1177
+ hub.unregister_device(device_id)
1178
+ # Clear retained MQTT registration message so the device cannot auto-reconnect
1179
+ if _mqtt_adapter is not None and _mqtt_adapter.is_connected:
1180
+ _mqtt_adapter.clear_device_registration(device_id)
1181
+ return {"status": "unregistered", "device_id": device_id}
1182
+
1183
+
1184
+
1185
+ # ── Custom Intent Classes endpoints ──────────────────────────────────────────
1186
+
1187
+ # Intent class names are stored in the DB — no hardcoded list needed
1188
+
1189
+ class CustomIntentClassRequest(BaseModel):
1190
+ name: str
1191
+ urgency: str = "info"
1192
+ resolution_tags: list[str]
1193
+ resolution_actuators: list[str] = []
1194
+ description: str = ""
1195
+ domain: str = "general"
1196
+ composition_kind: Optional[str] = None # e.g. "perimeter"; None = flat intent
1197
+
1198
+ @app.post("/v1/intent-classes", tags=["Protocol"], summary="Register a custom intent class")
1199
+ async def register_intent_class(
1200
+ req: CustomIntentClassRequest,
1201
+ auth: str = Depends(require_auth),
1202
+ ):
1203
+ # Validate name format: ^[a-z][a-z0-9_]*$
1204
+ name = req.name.strip().lower().replace(" ", "_").replace("-", "_")
1205
+ if not name:
1206
+ raise HTTPException(status_code=400, detail="name cannot be empty")
1207
+ if not re.match(r'^[a-z][a-z0-9_]*$', name):
1208
+ raise HTTPException(
1209
+ status_code=422,
1210
+ detail=f"Invalid intent class name '{name}'. "
1211
+ "Must match ^[a-z][a-z0-9_]*$ "
1212
+ "(lowercase letters, digits, underscores only — no special characters)"
1213
+ )
1214
+ # Protect universal intents from being overridden
1215
+ existing = hub.db.get_intent_class(name)
1216
+ if existing and existing.get("is_universal"):
1217
+ raise HTTPException(
1218
+ status_code=409,
1219
+ detail=f"'{name}' is a universal intent class and cannot be overridden. "
1220
+ "Universal intents are defined by the DoSync protocol."
1221
+ )
1222
+ # Validate urgency
1223
+ if req.urgency not in ("emergency", "alert", "info"):
1224
+ raise HTTPException(
1225
+ status_code=400,
1226
+ detail="urgency must be one of: emergency, alert, warning, info"
1227
+ )
1228
+ # Validate tags
1229
+ if not req.resolution_tags:
1230
+ raise HTTPException(status_code=400, detail="resolution_tags cannot be empty")
1231
+
1232
+ # Validate composition_kind: only kinds the hub can actually compose may be
1233
+ # declared. This mirrors the hub's routing (which fails explicitly on an unknown
1234
+ # kind) — refusing the declaration up front is clearer than accepting an intent
1235
+ # that would fail every time it fires. NULL = a normal flat intent.
1236
+ _KNOWN_COMPOSITION_KINDS = {"perimeter"}
1237
+ if req.composition_kind is not None and req.composition_kind not in _KNOWN_COMPOSITION_KINDS:
1238
+ raise HTTPException(
1239
+ status_code=422,
1240
+ detail=f"Unknown composition_kind '{req.composition_kind}'. "
1241
+ f"Known kinds: {sorted(_KNOWN_COMPOSITION_KINDS)}. "
1242
+ "Omit composition_kind for a normal (flat) intent."
1243
+ )
1244
+
1245
+ hub.db.save_intent_class(
1246
+ name=name,
1247
+ urgency=req.urgency,
1248
+ resolution_tags=req.resolution_tags,
1249
+ resolution_actuators=req.resolution_actuators,
1250
+ description=req.description,
1251
+ domain=req.domain,
1252
+ composition_kind=req.composition_kind,
1253
+ )
1254
+ return {
1255
+ "status": "registered",
1256
+ "name": name,
1257
+ "urgency": req.urgency,
1258
+ "resolution_tags": req.resolution_tags,
1259
+ "resolution_actuators": req.resolution_actuators,
1260
+ "description": req.description,
1261
+ "domain": req.domain,
1262
+ "composition_kind": req.composition_kind,
1263
+ }
1264
+
1265
+
1266
+ @app.get("/v1/intent-classes", tags=["Protocol"], summary="List all intent classes")
1267
+ async def list_intent_classes(auth: str = Depends(require_auth)):
1268
+ """List all registered intent classes — universal and domain-specific.
1269
+ No distinction between built-in and custom: all live in the DB."""
1270
+ classes = hub.db.list_intent_classes()
1271
+ return {
1272
+ "intent_classes": classes,
1273
+ "total": len(classes),
1274
+ }
1275
+
1276
+
1277
+ @app.delete("/v1/intent-classes/{name}", tags=["Protocol"], summary="Delete a custom intent class")
1278
+ async def delete_intent_class(name: str, auth: str = Depends(require_auth)):
1279
+ row = hub.db.get_intent_class(name)
1280
+ if row and row.get("is_universal"):
1281
+ raise HTTPException(
1282
+ status_code=409,
1283
+ detail=f"'{name}' is a universal intent class and cannot be deleted. "
1284
+ "Universal intents are defined by the DoSync protocol."
1285
+ )
1286
+ deleted = hub.db.delete_intent_class(name)
1287
+ if not deleted:
1288
+ raise HTTPException(status_code=404, detail=f"Custom intent class '{name}' not found")
1289
+ return {"status": "deleted", "name": name}
1290
+
1291
+
1292
+ @app.post("/v1/intent", tags=["AI"], include_in_schema=False)
1293
+ async def execute_intent_legacy(req: IntentRequest, auth: str = Depends(require_auth)):
1294
+ """Deprecated — use POST /v1/intent/async instead."""
1295
+ from fastapi.responses import Response
1296
+ return Response(status_code=308, headers={"Location": "/v1/intent/async"})
1297
+
1298
+
1299
+ @app.post("/v1/intent/async", tags=["AI"])
1300
+ async def execute_intent_async(req: IntentRequest, auth: str = Depends(require_auth)):
1301
+ # Validate format: ^[a-z][a-z0-9_]*$
1302
+ try:
1303
+ intent_class = IntentClass(req.intent)
1304
+ except ValueError as e:
1305
+ # Cardinality rule: rejected intents carry arbitrary user strings — never
1306
+ # use them as a label value. Count them under the fixed "_invalid" class.
1307
+ M.intents_total.inc({"intent_class": "_invalid", "urgency": req.urgency if req.urgency in ("emergency", "alert", "info") else "_invalid", "outcome": "rejected"})
1308
+ raise HTTPException(status_code=422, detail=str(e))
1309
+ # Validate intent class is registered in DB
1310
+ if not hub.db.get_intent_class(req.intent):
1311
+ registered = [r["name"] for r in hub.db.list_intent_classes()]
1312
+ M.intents_total.inc({"intent_class": "_invalid", "urgency": req.urgency if req.urgency in ("emergency", "alert", "info") else "_invalid", "outcome": "rejected"})
1313
+ raise HTTPException(
1314
+ status_code=422,
1315
+ detail=f"Intent '{req.intent}' is not registered. "
1316
+ f"Register via POST /v1/intent-classes or use one of: {registered}"
1317
+ )
1318
+ try:
1319
+ urgency = Urgency(req.urgency)
1320
+ except ValueError:
1321
+ M.intents_total.inc({"intent_class": req.intent, "urgency": "_invalid", "outcome": "rejected"})
1322
+ raise HTTPException(status_code=422, detail=f"Urgency '{req.urgency}' not valid. Use: emergency, alert, warning, info")
1323
+
1324
+ # ── Idempotency check (protocol v0.2, opt-in) ─────────────────────────
1325
+ # If the client supplied an idempotency key, deduplicate against prior
1326
+ # requests within the retention window. This makes the retry advised by
1327
+ # the consistency model (§6) safe for physical actions.
1328
+ if req.idempotency_key:
1329
+ _store_cleanup()
1330
+ body_hash = _intent_body_hash(req)
1331
+ prior = _idempotency_store.get(req.idempotency_key)
1332
+ if prior is not None:
1333
+ if prior["body_hash"] == body_hash:
1334
+ # Legitimate retry: same key, same body. Return cached intent_id,
1335
+ # do NOT re-execute (a lock must not unlock twice).
1336
+ return {
1337
+ "intent_id": prior["intent_id"],
1338
+ "status": "accepted",
1339
+ "idempotent_replay": True,
1340
+ }
1341
+ # Anti-suppression: same key, different body → reject. A key cannot
1342
+ # be reused to suppress a different intent.
1343
+ raise HTTPException(
1344
+ status_code=409,
1345
+ detail="Idempotency key reused with a different request body.",
1346
+ )
1347
+
1348
+ intent = Intent(
1349
+ intent=intent_class,
1350
+ urgency=urgency,
1351
+ subject=req.subject,
1352
+ source=req.source,
1353
+ context=req.context,
1354
+ )
1355
+
1356
+ # Register the idempotency key now (before execution) so a fast retry that
1357
+ # arrives while the first is still running also deduplicates.
1358
+ if req.idempotency_key:
1359
+ _idempotency_store[req.idempotency_key] = {
1360
+ "body_hash": _intent_body_hash(req),
1361
+ "intent_id": intent.intent_id,
1362
+ "created_at": _time.time(),
1363
+ }
1364
+
1365
+ _store_cleanup()
1366
+ _intent_store[intent.intent_id] = {
1367
+ "status": "pending",
1368
+ "result": None,
1369
+ "created_at": _time.time(),
1370
+ "intent": req.intent,
1371
+ "urgency": req.urgency,
1372
+ # MCP-V13: live partial progress, updated as each action completes, so a
1373
+ # poll of a still-pending intent can report what has ALREADY happened
1374
+ # instead of an opaque "still processing". Total planned count is filled
1375
+ # in once resolution is done (below); until then it is None.
1376
+ "progress": {"completed": [], "planned": None},
1377
+ }
1378
+
1379
+ def _on_action(r):
1380
+ prog = _intent_store.get(intent.intent_id, {}).get("progress")
1381
+ if prog is not None:
1382
+ prog["completed"].append({
1383
+ "device_id": r.device_id, "action": r.action,
1384
+ "success": bool(getattr(r, "success", False)),
1385
+ })
1386
+
1387
+ async def _run_intent():
1388
+ result = await hub.execute_intent(intent, executor, progress_cb=_on_action)
1389
+ _snap_intents = {"ensure_safety", "notify_family", "alert_anomaly"}
1390
+ if req.intent in _snap_intents or req.urgency in ("emergency", "alert"):
1391
+ try:
1392
+ hub.db.save_emergency_snapshot(
1393
+ intent_id=result.intent_id,
1394
+ intent_class=req.intent,
1395
+ urgency=req.urgency,
1396
+ context=req.context,
1397
+ )
1398
+ except Exception as _snap_e:
1399
+ logging.getLogger("dosync.server").warning("Failed to save emergency snapshot: %s", _snap_e)
1400
+
1401
+ _intent_store[intent.intent_id] = {
1402
+ "status": result.status if hasattr(result, "status") else ("success" if result.success else "partial"),
1403
+ "result": {
1404
+ "intent_id": result.intent_id,
1405
+ "success": result.success,
1406
+ "actions_taken": len(result.results),
1407
+ "failed_devices": result.failed_devices,
1408
+ "results": [
1409
+ {"device_id": r.device_id, "action": r.action, "success": r.success,
1410
+ "response": r.response, "error": r.error}
1411
+ for r in result.results
1412
+ ],
1413
+ },
1414
+ "created_at": _time.time(),
1415
+ "intent": req.intent,
1416
+ "urgency": req.urgency,
1417
+ }
1418
+
1419
+ # ── Metrics: execution outcome + per-action results ──────────────
1420
+ M.intent_executions_total.inc({"outcome": _intent_store[intent.intent_id]["status"]})
1421
+ for r in result.results:
1422
+ if r.success:
1423
+ _res = "success"
1424
+ elif r.error and str(r.error).startswith("superseded"):
1425
+ _res = "superseded"
1426
+ M.device_preemptions_total.inc()
1427
+ else:
1428
+ _res = "failed"
1429
+ M.intent_actions_total.inc({"result": _res})
1430
+
1431
+ M.intents_total.inc({"intent_class": req.intent, "urgency": req.urgency, "outcome": "accepted"})
1432
+ if urgency == Urgency.EMERGENCY:
1433
+ M.emergency_intents_total.inc()
1434
+
1435
+ asyncio.create_task(_run_intent())
1436
+
1437
+ return {
1438
+ "intent_id": intent.intent_id,
1439
+ "status": "pending",
1440
+ "intent": req.intent,
1441
+ "urgency": req.urgency,
1442
+ }
1443
+
1444
+
1445
+ @app.get("/v1/intent/{intent_id}", tags=["AI"])
1446
+ async def get_intent_result(intent_id: str, auth: str = Depends(require_auth)):
1447
+ """Poll the result of an async intent execution."""
1448
+ entry = _intent_store.get(intent_id)
1449
+ if not entry:
1450
+ raise HTTPException(status_code=404, detail=f"Intent '{intent_id}' not found")
1451
+ if entry["status"] == "pending":
1452
+ prog = entry.get("progress") or {}
1453
+ completed = prog.get("completed", [])
1454
+ return {"intent_id": intent_id, "status": "pending",
1455
+ "intent": entry.get("intent"), "urgency": entry.get("urgency"),
1456
+ # MCP-V13: partial progress so far — never an opaque pending.
1457
+ "partial": {
1458
+ "actions_completed": len(completed),
1459
+ "actions_planned": prog.get("planned"),
1460
+ "results": completed,
1461
+ }}
1462
+ return {"intent_id": intent_id, "status": entry["status"], **entry["result"]}
1463
+
1464
+
1465
+ @app.post("/v1/event", tags=["Devices"])
1466
+ async def receive_event(req: EventRequest, auth: str = Depends(require_auth)):
1467
+ if not hub.registry.get(req.device_id):
1468
+ raise HTTPException(
1469
+ status_code=404,
1470
+ detail=f"Device '{req.device_id}' not registered. Register it first."
1471
+ )
1472
+
1473
+ try:
1474
+ severity = Severity(req.severity)
1475
+ except ValueError:
1476
+ raise HTTPException(status_code=422, detail=f"Severity '{req.severity}' not valid. Use: info, warning, alert, emergency")
1477
+
1478
+ event = DeviceEvent(
1479
+ device_id=req.device_id,
1480
+ event_id=req.event_id,
1481
+ severity=severity,
1482
+ data=req.data,
1483
+ )
1484
+ await hub.receive_event(event)
1485
+
1486
+ return {
1487
+ "status": "received",
1488
+ "device_id": req.device_id,
1489
+ "event_id": req.event_id,
1490
+ "severity": req.severity,
1491
+ }
1492
+
1493
+
1494
+ @app.post("/v1/heartbeat", tags=["Devices"])
1495
+ async def receive_heartbeat(req: HeartbeatRequest, auth: str = Depends(require_auth)):
1496
+ """DEVICE-HEALTH-ACTIVE (b): a device proactively reports it is alive.
1497
+
1498
+ Complements the hub's periodic pull-probe with device-initiated push, which
1499
+ is the only liveness signal available for devices the hub cannot poll
1500
+ (behind NAT, sleeping, inbound-blocked). Positive signal only: it marks the
1501
+ device reachable and stamps last_heartbeat; it never marks a device
1502
+ unreachable. Unknown devices are rejected — a heartbeat is an assertion of
1503
+ identity and must come from a registered device.
1504
+ """
1505
+ if not hub.registry.get(req.device_id):
1506
+ raise HTTPException(
1507
+ status_code=404,
1508
+ detail=f"Device '{req.device_id}' not registered. Register it first.")
1509
+ hub.health.record_heartbeat(req.device_id, req.report or None)
1510
+ snap = hub.health.snapshot(req.device_id)
1511
+ return {
1512
+ "status": "acknowledged",
1513
+ "device_id": req.device_id,
1514
+ "last_heartbeat": snap["last_heartbeat"],
1515
+ "reachable": snap["reachable"],
1516
+ }
1517
+
1518
+
1519
+ @app.get("/v1/keys", tags=["Security"])
1520
+ def list_keys(auth: str = Depends(require_auth)):
1521
+ return {"keys": _auth_manager.list_keys()}
1522
+
1523
+
1524
+ @app.post("/v1/keys", tags=["Security"])
1525
+ def create_key(label: str = "new_key", auth: str = Depends(require_auth)):
1526
+ token = _auth_manager.generate_key(label)
1527
+ return {
1528
+ "token": token,
1529
+ "label": label,
1530
+ "warning": "Save this token — it will not be shown again.",
1531
+ }
1532
+
1533
+
1534
+ @app.get("/v1/audit", tags=["Security"])
1535
+ def get_audit_log(auth: str = Depends(require_auth)):
1536
+ entries = hub.audit_log.entries()
1537
+ return {
1538
+ "count": len(entries),
1539
+ "integrity": hub.audit_log.verify(),
1540
+ "entries": entries,
1541
+ }
1542
+
1543
+
1544
+ @app.post("/v1/presence", tags=["Context"])
1545
+ async def update_presence(req: PresenceSignalRequest, auth: str = Depends(require_auth)):
1546
+ from dosync.models import PresenceSignal, ContextSignalType
1547
+ try:
1548
+ signal_type = ContextSignalType(req.signal_type)
1549
+ except ValueError:
1550
+ valid = [t.value for t in ContextSignalType]
1551
+ raise HTTPException(status_code=422,
1552
+ detail=f"signal_type '{req.signal_type}' not valid. Valid: {valid}")
1553
+
1554
+ signal = PresenceSignal(
1555
+ device_id=req.device_id,
1556
+ signal_type=signal_type,
1557
+ present=req.present,
1558
+ confidence=req.confidence,
1559
+ member_id=req.member_id,
1560
+ )
1561
+ state = hub.update_presence(signal)
1562
+ return {
1563
+ "status": "updated",
1564
+ "occupied": state.occupied,
1565
+ "confidence": round(state.confidence, 3),
1566
+ "members_home": state.members_home,
1567
+ "signals_used": state.signals_used,
1568
+ }
1569
+
1570
+
1571
+ @app.get("/v1/presence", tags=["Context"])
1572
+ def get_presence(auth: str = Depends(require_auth)):
1573
+ state = hub.get_occupancy()
1574
+ signals = hub.occupancy.all_signals()
1575
+ return {
1576
+ "occupied": state.occupied,
1577
+ "confidence": round(state.confidence, 3),
1578
+ "members_home": state.members_home,
1579
+ "signals_used": state.signals_used,
1580
+ "signals": signals,
1581
+ }
1582
+
1583
+
1584
+ @app.post("/v1/discovery/run", tags=["Discovery"])
1585
+ async def run_discovery(auth: str = Depends(require_auth)):
1586
+ from dosync.discovery import Discovery
1587
+ disc = Discovery(hub, timeout_override=5.0)
1588
+ new_count = await disc.run()
1589
+ return {
1590
+ "status": "complete",
1591
+ "new_devices": new_count,
1592
+ "total_devices": len(hub.registry.all()),
1593
+ }
1594
+
1595
+
1596
+ @app.get("/v1/discovery/scan", tags=["Discovery"])
1597
+ async def scan_devices(auth: str = Depends(require_auth)):
1598
+ from dosync.discovery import discover_wiz
1599
+ wiz_devices = await discover_wiz(timeout=5.0)
1600
+ return {
1601
+ "found": [
1602
+ {
1603
+ "adapter": d.adapter,
1604
+ "device_id": d.device_id,
1605
+ "device_name": d.device_name,
1606
+ "ip": d.ip,
1607
+ "registered": hub.registry.get(d.device_id) is not None,
1608
+ }
1609
+ for d in wiz_devices
1610
+ ],
1611
+ "count": len(wiz_devices),
1612
+ }
1613
+
1614
+
1615
+ @app.post("/v1/device/action", tags=["Devices"])
1616
+ async def device_action(
1617
+ req: dict,
1618
+ auth: str = Depends(require_auth),
1619
+ ):
1620
+ from dosync.models import DeviceAction, Urgency
1621
+ device_id = req.get("device_id")
1622
+ action = req.get("action")
1623
+ params = req.get("params", {})
1624
+
1625
+ if not device_id or not action:
1626
+ raise HTTPException(status_code=422,
1627
+ detail="device_id and action are required")
1628
+
1629
+ device = hub.registry.get(device_id)
1630
+ if not device:
1631
+ raise HTTPException(status_code=404,
1632
+ detail=f"Device '{device_id}' not found")
1633
+
1634
+ dev_action = DeviceAction(
1635
+ device_id=device_id,
1636
+ action=action,
1637
+ params=params,
1638
+ )
1639
+ result = await executor.execute(dev_action, Urgency.INFO)
1640
+
1641
+ await ws_manager.broadcast("device_action", {
1642
+ "device_id": result.device_id,
1643
+ "action": result.action,
1644
+ "success": result.success,
1645
+ "response": result.response,
1646
+ })
1647
+
1648
+ return {
1649
+ "device_id": result.device_id,
1650
+ "action": result.action,
1651
+ "success": result.success,
1652
+ "response": result.response,
1653
+ "error": result.error,
1654
+ }
1655
+
1656
+
1657
+ @app.get("/v1/hub/heartbeat", tags=["Hub"], summary="Hub heartbeat for monitoring and multi-hub failover detection")
1658
+ async def hub_heartbeat():
1659
+ """
1660
+ Lightweight health check for hub monitoring and multi-hub failover detection.
1661
+
1662
+ Returns minimal hub state to allow standby hubs and monitoring systems to
1663
+ determine whether this hub is healthy and should be treated as the active hub.
1664
+
1665
+ Response fields:
1666
+ hub_id: Unique identifier for this hub instance (stable across restarts)
1667
+ status: "healthy" | "degraded" — degraded if audit log integrity fails
1668
+ protocol_version: DoSync semantic protocol version
1669
+ api_version: REST API version
1670
+ timestamp: Current UTC timestamp (ISO 8601)
1671
+ uptime_seconds: Seconds since hub process started
1672
+ devices: Number of registered devices
1673
+ role: "primary" | "standby" (from DOSYNC_HUB_ROLE env var)
1674
+
1675
+ Clients and standby hubs SHOULD poll this endpoint to detect primary failure.
1676
+ Recommended polling interval: 5 seconds. A hub is considered failed after
1677
+ 3 consecutive missed heartbeats (15 seconds of no response).
1678
+ """
1679
+ import time as _time_hb
1680
+ import uuid as _uuid
1681
+ from datetime import datetime, timezone
1682
+
1683
+ # Generate a stable hub_id from the host machine identity
1684
+ # Uses a hash of the DB path + process start time as a stable identifier
1685
+ hub_id = getattr(app.state, "hub_id", None)
1686
+ if not hub_id:
1687
+ import hashlib
1688
+ raw = f"{os.environ.get('DOSYNC_DB', 'dosync.db')}-{os.getpid()}"
1689
+ app.state.hub_id = hashlib.sha256(raw.encode()).hexdigest()[:16]
1690
+ hub_id = app.state.hub_id
1691
+
1692
+ # Determine health status
1693
+ audit_ok = hub.audit_log.verify() if hasattr(hub.audit_log, "verify") else True
1694
+ status = "healthy" if audit_ok else "degraded"
1695
+
1696
+ # Calculate uptime
1697
+ start_time = getattr(app.state, "start_time", None)
1698
+ if not start_time:
1699
+ app.state.start_time = _time_hb.time()
1700
+ start_time = app.state.start_time
1701
+ uptime = int(_time_hb.time() - start_time)
1702
+
1703
+ role = os.environ.get("DOSYNC_HUB_ROLE", "primary").lower()
1704
+
1705
+ return {
1706
+ "hub_id": hub_id,
1707
+ "status": status,
1708
+ "protocol_version": DOSYNC_PROTOCOL_VERSION,
1709
+ "api_version": DOSYNC_API_VERSION,
1710
+ "timestamp": datetime.now(timezone.utc).isoformat(),
1711
+ "uptime_seconds": uptime,
1712
+ "devices": len(hub.registry.all()),
1713
+ "role": role,
1714
+ "multi_hub_capable": True,
1715
+ "transports": {
1716
+ "mqtt": {
1717
+ "enabled": _mqtt_adapter is not None,
1718
+ "connected": _mqtt_adapter.is_connected if _mqtt_adapter else False,
1719
+ "broker": _mqtt_adapter.broker if _mqtt_adapter else None,
1720
+ }
1721
+ },
1722
+ }
1723
+
1724
+
1725
+ @app.get("/v1/hub/peers", tags=["Hub"], summary="Multi-hub monitor view (Phase A)")
1726
+ async def hub_peers():
1727
+ """Return this hub's multi-hub coordination view.
1728
+
1729
+ On a primary, the monitor is inert. On a standby, returns the monitor's
1730
+ current state, how many heartbeats the primary has missed, and whether
1731
+ promotion would be safe or destructive (state divergence).
1732
+ """
1733
+ role = os.environ.get("DOSYNC_HUB_ROLE", "primary").lower()
1734
+ if _hub_monitor is None:
1735
+ return {"role": role, "monitor_state": "n/a", "primary_url": None}
1736
+ snap = _hub_monitor.snapshot()
1737
+ snap["role"] = role
1738
+ snap["primary_url"] = _primary_url
1739
+ return snap
1740
+
1741
+
1742
+ @app.post("/v1/hub/promote", tags=["Hub"], summary="Operator-assisted standby promotion (Phase A)")
1743
+ async def hub_promote(body: dict = None, auth=Depends(require_auth)):
1744
+ """Promote this standby to primary. Operator-assisted, never automatic.
1745
+
1746
+ Requires the monitor to be in PRIMARY_DOWN. If promotion would be
1747
+ destructive (the primary held more devices than this standby — no Phase B
1748
+ replication yet), requires {"force": true} and returns 409 otherwise. This
1749
+ is the human-in-the-loop gate that prevents split-brain and silent device
1750
+ loss.
1751
+ """
1752
+ body = body or {}
1753
+ if _hub_monitor is None:
1754
+ raise HTTPException(status_code=400, detail="This hub is not a standby (no monitor running).")
1755
+
1756
+ if _hub_monitor.state is not MonitorState.PRIMARY_DOWN:
1757
+ raise HTTPException(
1758
+ status_code=409,
1759
+ detail=f"Cannot promote: monitor state is {_hub_monitor.state.value}, expected PRIMARY_DOWN.",
1760
+ )
1761
+
1762
+ proposal = _hub_monitor.promotion_proposal()
1763
+ if proposal.destructive and not body.get("force"):
1764
+ raise HTTPException(
1765
+ status_code=409,
1766
+ detail={
1767
+ "error": "Promotion would lose state",
1768
+ "reason": proposal.reason,
1769
+ "local_devices": proposal.local_devices,
1770
+ "primary_devices_last_known": proposal.primary_devices_last_known,
1771
+ "hint": "Re-issue with {\"force\": true} to promote anyway.",
1772
+ },
1773
+ )
1774
+
1775
+ # Promote: flip role to primary. (Phase A: role failover only; state
1776
+ # replication is Phase B. A forced destructive promotion serves a registry
1777
+ # missing the primary's devices — the operator was warned.)
1778
+ os.environ["DOSYNC_HUB_ROLE"] = "primary"
1779
+ return {
1780
+ "promoted": True,
1781
+ "new_role": "primary",
1782
+ "was_destructive": proposal.destructive,
1783
+ "devices_served": proposal.local_devices,
1784
+ "warning": proposal.reason if proposal.destructive else None,
1785
+ }
1786
+
1787
+
1788
+ # ── Metrics (Prometheus text format) ─────────────────────────────────────────
1789
+ # Operational feature of the reference implementation — NOT part of the
1790
+ # normative protocol (a hub without /metrics is still conforming). Auth-protected:
1791
+ # unlike /v1/status (minimal public health for monitors/standby hubs), /metrics
1792
+ # exposes operational detail, so it requires a token. Point your Prometheus
1793
+ # scraper at it with: authorization: { credentials: "<token>" }.
1794
+
1795
+ _metrics_start_time = _time.time()
1796
+
1797
+ M.REGISTRY.gauge_func(
1798
+ "dosync_devices_registered",
1799
+ "Devices currently registered in the capability registry",
1800
+ lambda: len(hub.registry.all()),
1801
+ )
1802
+ M.REGISTRY.gauge_func(
1803
+ "dosync_devices_emergency_capable",
1804
+ "Registered devices declaring emergency_capable=true",
1805
+ lambda: sum(1 for d in hub.registry.all() if getattr(d, "emergency_capable", False)),
1806
+ )
1807
+ M.REGISTRY.gauge_func(
1808
+ "dosync_audit_entries",
1809
+ "Entries in the tamper-evident audit log",
1810
+ lambda: len(hub.audit_log.entries()),
1811
+ )
1812
+ M.REGISTRY.gauge_func(
1813
+ "dosync_audit_integrity",
1814
+ "1 if the audit log SHA-256 chain verifies, 0 if broken",
1815
+ lambda: 1 if hub.audit_log.verify() else 0,
1816
+ )
1817
+ M.REGISTRY.gauge_func(
1818
+ "dosync_ws_connections",
1819
+ "Active WebSocket connections",
1820
+ lambda: ws_manager.active_connections,
1821
+ )
1822
+ M.REGISTRY.gauge_func(
1823
+ "dosync_hub_uptime_seconds",
1824
+ "Seconds since the hub process started",
1825
+ lambda: _time.time() - _metrics_start_time,
1826
+ )
1827
+
1828
+ def _device_success_rate_samples():
1829
+ # device_id label is acceptable here ONLY because device health is a bounded,
1830
+ # low-volume gauge (see cardinality rule in dosync/metrics.py). Detailed
1831
+ # per-device history stays in /v1/health/devices.
1832
+ out = []
1833
+ for d in hub.db.get_all_health(last_n=100):
1834
+ dev = d.get("device_id")
1835
+ rate = d.get("success_rate")
1836
+ if dev is not None and rate is not None:
1837
+ out.append(({"device_id": dev}, rate))
1838
+ return out
1839
+
1840
+ M.REGISTRY.gauge_func(
1841
+ "dosync_device_success_rate",
1842
+ "Per-device action success rate over the last 100 executions (0.0-1.0)",
1843
+ _device_success_rate_samples,
1844
+ )
1845
+
1846
+
1847
+ @app.get("/metrics", tags=["Status"], summary="Prometheus metrics (reference implementation feature, non-normative)")
1848
+ def get_metrics(auth: str = Depends(require_auth)):
1849
+ return PlainTextResponse(M.REGISTRY.render(), media_type="text/plain; version=0.0.4; charset=utf-8")
1850
+
1851
+
1852
+ @app.get("/v1/status", tags=["Status"])
1853
+ def get_status():
1854
+ db_stats = hub.db.stats()
1855
+ occupancy = hub.get_occupancy()
1856
+ return {
1857
+ "name": "DoSync Hub",
1858
+ "version": __version__,
1859
+ "protocol": f"dosync/{DOSYNC_PROTOCOL_VERSION}",
1860
+ "status": "running",
1861
+ "certify_mode": _certify_mode,
1862
+ "protocol_version": DOSYNC_PROTOCOL_VERSION,
1863
+ "api_version": DOSYNC_API_VERSION,
1864
+ "devices": len(hub.registry.all()),
1865
+ "audit_entries": len(hub.audit_log.entries()),
1866
+ "audit_integrity": hub.audit_log.verify(),
1867
+ # AUDIT-ARCHIVE: surface whether the chain is anchored (segmented) so a
1868
+ # conformance test can verify the anchor is honored end to end.
1869
+ "audit_anchored": hub.audit_log.anchor_prev_hash != "0" * 64,
1870
+ "audit_anchor_prefix": hub.audit_log.anchor_prev_hash[:16],
1871
+ # v13 hygiene: nonzero means a progress callback has been failing —
1872
+ # swallowed so it never breaks execution, but surfaced here so a real
1873
+ # bug is visible instead of hidden in logs.
1874
+ "progress_cb_failures": getattr(hub, "progress_cb_failures", 0),
1875
+ "occupied": occupancy.occupied,
1876
+ "ws_connections": ws_manager.active_connections,
1877
+ "db": db_stats,
1878
+ "family_profile": hub.family_profile.family_name
1879
+ if hub.family_profile else None,
1880
+ }
1881
+
1882
+
1883
+ # ── Long-running operations (execution_model) ─────────────────────────────────
1884
+ # Read-only query surface for operations started by execute_intent. The intent
1885
+ # response returns each operation's id; these endpoints let a client follow it.
1886
+ # Cancellation is intentionally NOT here yet — querying is safe and additive;
1887
+ # cancel is a state-changing action that belongs with the adapter that can carry
1888
+ # it out (e.g. the future MAVLink adapter). These endpoints never mutate state.
1889
+
1890
+ @app.get("/v1/operations", tags=["Operations"], summary="List active long-running operations")
1891
+ def list_operations(auth: str = Depends(require_auth)):
1892
+ """Active (non-terminal) long-running operations the hub is tracking. These are
1893
+ the operations still in flight — the ones that would be reconciled after a
1894
+ restart. Completed/failed/cancelled operations are not 'active' and are not
1895
+ listed here (query them individually by id)."""
1896
+ active = hub.db.get_active_operations()
1897
+ return {
1898
+ "count": len(active),
1899
+ "operations": active,
1900
+ }
1901
+
1902
+
1903
+ @app.get("/v1/operations/{operation_id}", tags=["Operations"], summary="Get one operation by id")
1904
+ def get_operation(operation_id: str, auth: str = Depends(require_auth)):
1905
+ """Full state of a single operation by id — current state, timing, and the
1906
+ complete transition history (the audit trail of its lifecycle). Works for both
1907
+ active and terminal operations until they are cleaned up."""
1908
+ op = hub.db.get_operation(operation_id)
1909
+ if op is None:
1910
+ raise HTTPException(status_code=404, detail=f"Operation '{operation_id}' not found")
1911
+ return op