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