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/adapters/mqtt.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync MQTT Adapter
|
|
3
|
+
===================
|
|
4
|
+
Layer 1 transport: MQTT broker as the communication channel.
|
|
5
|
+
|
|
6
|
+
Enables devices communicating via MQTT to participate in DoSync intents
|
|
7
|
+
without HTTP, without REST, and without internet — just a local MQTT broker.
|
|
8
|
+
Ideal for microcontrollers (ESP32, Arduino), Tasmota devices, Zigbee2MQTT,
|
|
9
|
+
and any sensor network that speaks MQTT natively.
|
|
10
|
+
|
|
11
|
+
Configuration (environment variables):
|
|
12
|
+
DOSYNC_MQTT_BROKER localhost Broker hostname or IP
|
|
13
|
+
DOSYNC_MQTT_PORT 1883 Broker port (8883 for TLS)
|
|
14
|
+
DOSYNC_MQTT_USER Optional username
|
|
15
|
+
DOSYNC_MQTT_PASSWORD Optional password
|
|
16
|
+
DOSYNC_MQTT_PREFIX dosync Topic prefix
|
|
17
|
+
DOSYNC_MQTT_QOS 1 QoS level (0, 1, or 2)
|
|
18
|
+
|
|
19
|
+
Topic structure:
|
|
20
|
+
{prefix}/devices/{device_id}/commands Hub → device (action)
|
|
21
|
+
{prefix}/devices/{device_id}/events Device → hub (sensor event)
|
|
22
|
+
{prefix}/devices/{device_id}/status Device → hub (state, retained)
|
|
23
|
+
{prefix}/devices/{device_id}/register Device → hub (self-registration)
|
|
24
|
+
{prefix}/hub/status Hub heartbeat + LWT
|
|
25
|
+
|
|
26
|
+
Command payload (hub → device):
|
|
27
|
+
{"action": "turn_on", "params": {...}, "urgency": "info", "timestamp": 0.0}
|
|
28
|
+
|
|
29
|
+
Event payload (device → hub):
|
|
30
|
+
{"event_id": "motion_detected", "severity": "alert", "data": {...}}
|
|
31
|
+
|
|
32
|
+
Registration payload (retained, device → hub):
|
|
33
|
+
Full CapabilityManifest JSON — see spec/schemas/capability-manifest.schema.json
|
|
34
|
+
|
|
35
|
+
Install broker on Pi:
|
|
36
|
+
sudo apt-get install -y mosquitto mosquitto-clients
|
|
37
|
+
sudo systemctl enable mosquitto && sudo systemctl start mosquitto
|
|
38
|
+
|
|
39
|
+
Install Python library:
|
|
40
|
+
pip install paho-mqtt
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import asyncio
|
|
44
|
+
import json
|
|
45
|
+
import logging
|
|
46
|
+
import os
|
|
47
|
+
import time
|
|
48
|
+
|
|
49
|
+
from . import DoSyncAdapter
|
|
50
|
+
from ..models import ActionResult, DeviceAction, Urgency
|
|
51
|
+
|
|
52
|
+
log = logging.getLogger("dosync.adapters.mqtt")
|
|
53
|
+
|
|
54
|
+
# ── Configuration ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
BROKER = os.environ.get("DOSYNC_MQTT_BROKER", "localhost")
|
|
57
|
+
PORT = int(os.environ.get("DOSYNC_MQTT_PORT", "1883"))
|
|
58
|
+
USER = os.environ.get("DOSYNC_MQTT_USER", "")
|
|
59
|
+
PASSWORD = os.environ.get("DOSYNC_MQTT_PASSWORD", "")
|
|
60
|
+
PREFIX = os.environ.get("DOSYNC_MQTT_PREFIX", "dosync")
|
|
61
|
+
QOS = int(os.environ.get("DOSYNC_MQTT_QOS", "1"))
|
|
62
|
+
# Shared secret for device registration authorization.
|
|
63
|
+
# If set, devices MUST include {"dosync_secret": "<value>"} in their register payload.
|
|
64
|
+
# Devices that do not present the correct secret are silently rejected.
|
|
65
|
+
# Set this to a random string and configure all devices with the same value.
|
|
66
|
+
MQTT_SECRET = os.environ.get("DOSYNC_MQTT_SECRET", "")
|
|
67
|
+
|
|
68
|
+
# ── Optional import ───────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
import paho.mqtt.client as _mqtt
|
|
72
|
+
_PAHO_AVAILABLE = True
|
|
73
|
+
except ImportError:
|
|
74
|
+
_PAHO_AVAILABLE = False
|
|
75
|
+
log.warning(
|
|
76
|
+
"paho-mqtt not installed — MQTTAdapter disabled. "
|
|
77
|
+
"Install with: pip install paho-mqtt"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Adapter ───────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
class MQTTAdapter(DoSyncAdapter):
|
|
84
|
+
"""
|
|
85
|
+
DoSync adapter for MQTT devices.
|
|
86
|
+
|
|
87
|
+
Publishes DeviceActions as MQTT command messages and subscribes to
|
|
88
|
+
device topics to receive events and self-registrations.
|
|
89
|
+
|
|
90
|
+
Integrates paho-mqtt (threaded) with DoSync's asyncio event loop
|
|
91
|
+
via asyncio.run_coroutine_threadsafe.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, hub=None):
|
|
95
|
+
self._hub = hub
|
|
96
|
+
self._client = None
|
|
97
|
+
self._connected = False
|
|
98
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def adapter_name(self) -> str:
|
|
102
|
+
return "mqtt"
|
|
103
|
+
|
|
104
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async def connect(self, config: dict | None = None) -> None:
|
|
107
|
+
"""Connect to the MQTT broker and start background loop."""
|
|
108
|
+
if not _PAHO_AVAILABLE:
|
|
109
|
+
log.warning("MQTTAdapter: paho-mqtt not installed — skipping connect")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
self._loop = asyncio.get_running_loop()
|
|
113
|
+
|
|
114
|
+
client_id = f"dosync-hub-{int(time.time()) % 10000}"
|
|
115
|
+
self._client = _mqtt.Client(
|
|
116
|
+
_mqtt.CallbackAPIVersion.VERSION2,
|
|
117
|
+
client_id=client_id,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if USER:
|
|
121
|
+
self._client.username_pw_set(USER, PASSWORD)
|
|
122
|
+
|
|
123
|
+
# Last Will Testament — signals hub went offline unexpectedly
|
|
124
|
+
self._client.will_set(
|
|
125
|
+
f"{PREFIX}/hub/status",
|
|
126
|
+
json.dumps({"status": "offline", "timestamp": time.time()}),
|
|
127
|
+
qos=QOS,
|
|
128
|
+
retain=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Exponential backoff on reconnect: 1s → 120s with jitter (paho built-in)
|
|
132
|
+
self._client.reconnect_delay_set(min_delay=1, max_delay=120)
|
|
133
|
+
|
|
134
|
+
self._client.on_connect = self._on_connect
|
|
135
|
+
self._client.on_message = self._on_message
|
|
136
|
+
self._client.on_disconnect = self._on_disconnect
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
self._client.connect_async(BROKER, PORT, keepalive=60)
|
|
140
|
+
self._client.loop_start()
|
|
141
|
+
log.info("MQTTAdapter: connecting to %s:%d (prefix=%s)", BROKER, PORT, PREFIX)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
log.warning("MQTTAdapter: connect failed (%s) — MQTT transport disabled", exc)
|
|
144
|
+
|
|
145
|
+
async def disconnect(self) -> None:
|
|
146
|
+
"""Disconnect from broker and stop the paho loop."""
|
|
147
|
+
if self._client:
|
|
148
|
+
if self._connected:
|
|
149
|
+
self._client.publish(
|
|
150
|
+
f"{PREFIX}/hub/status",
|
|
151
|
+
json.dumps({"status": "offline", "timestamp": time.time()}),
|
|
152
|
+
qos=QOS,
|
|
153
|
+
retain=True,
|
|
154
|
+
)
|
|
155
|
+
self._client.loop_stop()
|
|
156
|
+
self._client.disconnect()
|
|
157
|
+
self._connected = False
|
|
158
|
+
log.info("MQTTAdapter: disconnected")
|
|
159
|
+
|
|
160
|
+
# ── paho callbacks (run in paho thread) ───────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def _on_connect(self, client, userdata, flags, reason_code, properties=None):
|
|
163
|
+
if reason_code == 0:
|
|
164
|
+
self._connected = True
|
|
165
|
+
# Subscribe to all device sub-topics
|
|
166
|
+
for suffix in ("events", "register", "status"):
|
|
167
|
+
client.subscribe(f"{PREFIX}/devices/+/{suffix}", qos=QOS)
|
|
168
|
+
# Announce hub online
|
|
169
|
+
client.publish(
|
|
170
|
+
f"{PREFIX}/hub/status",
|
|
171
|
+
json.dumps({"status": "online", "timestamp": time.time()}),
|
|
172
|
+
qos=QOS,
|
|
173
|
+
retain=True,
|
|
174
|
+
)
|
|
175
|
+
log.info(
|
|
176
|
+
"MQTTAdapter: connected to %s:%d — subscribed to %s/devices/+/{events,register,status}",
|
|
177
|
+
BROKER, PORT, PREFIX,
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
log.warning("MQTTAdapter: connection refused — reason_code=%s", reason_code)
|
|
181
|
+
|
|
182
|
+
def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties=None):
|
|
183
|
+
self._connected = False
|
|
184
|
+
log.warning("MQTTAdapter: disconnected (reason_code=%s)", reason_code)
|
|
185
|
+
|
|
186
|
+
def _on_message(self, client, userdata, msg):
|
|
187
|
+
"""Bridge paho thread → asyncio event loop."""
|
|
188
|
+
if self._loop is None or self._loop.is_closed():
|
|
189
|
+
return
|
|
190
|
+
asyncio.run_coroutine_threadsafe(
|
|
191
|
+
self._dispatch(msg.topic, msg.payload),
|
|
192
|
+
self._loop,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# ── Message dispatch (asyncio thread) ────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
async def _dispatch(self, topic: str, payload: bytes) -> None:
|
|
198
|
+
try:
|
|
199
|
+
data = json.loads(payload)
|
|
200
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
201
|
+
log.warning("MQTTAdapter: invalid JSON on topic %s", topic)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# topic = {prefix}/devices/{device_id}/{msg_type}
|
|
205
|
+
parts = topic.split("/")
|
|
206
|
+
if len(parts) < 4:
|
|
207
|
+
return
|
|
208
|
+
device_id = parts[-2]
|
|
209
|
+
msg_type = parts[-1]
|
|
210
|
+
|
|
211
|
+
if msg_type == "register": await self._handle_registration(device_id, data)
|
|
212
|
+
elif msg_type == "events": await self._handle_event(device_id, data)
|
|
213
|
+
elif msg_type == "status": self._handle_status(device_id, data)
|
|
214
|
+
|
|
215
|
+
async def _handle_registration(self, device_id: str, data: dict) -> None:
|
|
216
|
+
"""Auto-register a device that published its manifest to the register topic.
|
|
217
|
+
|
|
218
|
+
Handles both payload formats:
|
|
219
|
+
- Flat: sensors/actuators/events as top-level lists
|
|
220
|
+
- Nested: capabilities: {sensors, actuators, events, context_signals}
|
|
221
|
+
Converts all dict items to their proper dataclass types before constructing
|
|
222
|
+
the CapabilityManifest.
|
|
223
|
+
"""
|
|
224
|
+
# Validate shared secret before accepting any registration
|
|
225
|
+
if MQTT_SECRET:
|
|
226
|
+
presented = data.get("dosync_secret", "")
|
|
227
|
+
if presented != MQTT_SECRET:
|
|
228
|
+
log.warning(
|
|
229
|
+
"MQTTAdapter: rejected registration from '%s' — invalid or missing secret. "
|
|
230
|
+
"Set DOSYNC_MQTT_SECRET on both hub and device.", device_id
|
|
231
|
+
)
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
if self._hub is None:
|
|
235
|
+
log.warning("MQTTAdapter: received registration from %s but hub is not set", device_id)
|
|
236
|
+
return
|
|
237
|
+
try:
|
|
238
|
+
import dataclasses
|
|
239
|
+
from ..models import (
|
|
240
|
+
CapabilityManifest, SensorSpec, ActuatorSpec, EventSpec,
|
|
241
|
+
DeviceCategory, CertTier, Urgency, Severity,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
d = dict(data)
|
|
245
|
+
d["adapter"] = "mqtt"
|
|
246
|
+
d["device_id"] = device_id
|
|
247
|
+
d.setdefault("dosync_version", "0.1")
|
|
248
|
+
|
|
249
|
+
# Flatten nested capabilities → top-level lists (public API format)
|
|
250
|
+
caps = d.pop("capabilities", {})
|
|
251
|
+
d.setdefault("sensors", caps.get("sensors", []))
|
|
252
|
+
d.setdefault("actuators", caps.get("actuators", []))
|
|
253
|
+
d.setdefault("events", caps.get("events", []))
|
|
254
|
+
d.setdefault("context_signals", caps.get("context_signals", []))
|
|
255
|
+
|
|
256
|
+
# Convert list items from dicts to typed dataclasses
|
|
257
|
+
d["sensors"] = [SensorSpec(**s) if isinstance(s, dict) else s for s in d["sensors"]]
|
|
258
|
+
d["actuators"] = [ActuatorSpec(**a) if isinstance(a, dict) else a for a in d["actuators"]]
|
|
259
|
+
d["events"] = [
|
|
260
|
+
EventSpec(
|
|
261
|
+
id=e["id"],
|
|
262
|
+
severity=Severity(e.get("severity", "info")),
|
|
263
|
+
description=e.get("description", ""),
|
|
264
|
+
) if isinstance(e, dict) else e
|
|
265
|
+
for e in d["events"]
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
# Handle enum fields
|
|
269
|
+
if isinstance(d.get("category"), str):
|
|
270
|
+
try: d["category"] = DeviceCategory(d["category"])
|
|
271
|
+
except ValueError: d["category"] = DeviceCategory.HYBRID
|
|
272
|
+
if isinstance(d.get("cert_tier"), str):
|
|
273
|
+
try: d["cert_tier"] = CertTier(d["cert_tier"])
|
|
274
|
+
except ValueError: d.pop("cert_tier", None)
|
|
275
|
+
|
|
276
|
+
# Remove any fields unknown to CapabilityManifest
|
|
277
|
+
valid = {f.name for f in dataclasses.fields(CapabilityManifest)}
|
|
278
|
+
d = {k: v for k, v in d.items() if k in valid}
|
|
279
|
+
|
|
280
|
+
manifest = CapabilityManifest(**d)
|
|
281
|
+
self._hub.register_device(manifest)
|
|
282
|
+
log.info("MQTTAdapter: auto-registered device '%s'", device_id)
|
|
283
|
+
|
|
284
|
+
# Acknowledge registration
|
|
285
|
+
if self._client and self._connected:
|
|
286
|
+
self._client.publish(
|
|
287
|
+
f"{PREFIX}/devices/{device_id}/ack",
|
|
288
|
+
json.dumps({"registered": True, "hub_timestamp": time.time()}),
|
|
289
|
+
qos=QOS,
|
|
290
|
+
)
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
log.warning("MQTTAdapter: failed to register device '%s': %s", device_id, exc)
|
|
293
|
+
|
|
294
|
+
async def _handle_event(self, device_id: str, data: dict) -> None:
|
|
295
|
+
"""Forward a device event to the hub so it reaches the audit log AND the
|
|
296
|
+
event stream (WebSocket broadcast) that autonomous agents subscribe to.
|
|
297
|
+
|
|
298
|
+
Previously this called a non-existent `process_event` guarded by hasattr,
|
|
299
|
+
so MQTT device events were silently dropped and never reached an agent."""
|
|
300
|
+
if self._hub is None:
|
|
301
|
+
return
|
|
302
|
+
try:
|
|
303
|
+
from ..models import DeviceEvent, Severity
|
|
304
|
+
try:
|
|
305
|
+
severity = Severity(data.get("severity", "info"))
|
|
306
|
+
except ValueError:
|
|
307
|
+
severity = Severity("info")
|
|
308
|
+
event = DeviceEvent(
|
|
309
|
+
device_id=device_id,
|
|
310
|
+
event_id=data.get("event_id", "mqtt_event"),
|
|
311
|
+
severity=severity,
|
|
312
|
+
data=data.get("data", {}),
|
|
313
|
+
)
|
|
314
|
+
await self._hub.receive_event(event)
|
|
315
|
+
log.debug("MQTTAdapter: event '%s' from '%s'", event.event_id, device_id)
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
log.warning("MQTTAdapter: failed to process event from '%s': %s", device_id, exc)
|
|
318
|
+
|
|
319
|
+
def _handle_status(self, device_id: str, data: dict) -> None:
|
|
320
|
+
"""Update in-memory state cache for StateAwareResolver."""
|
|
321
|
+
log.debug("MQTTAdapter: status update from '%s': %s", device_id, data)
|
|
322
|
+
|
|
323
|
+
# ── Execute (called by AdapterExecutor) ───────────────────────────────────
|
|
324
|
+
|
|
325
|
+
async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
|
|
326
|
+
"""Publish a command to the device's MQTT command topic."""
|
|
327
|
+
if not _PAHO_AVAILABLE:
|
|
328
|
+
return ActionResult(
|
|
329
|
+
device_id=action.device_id, action=action.action,
|
|
330
|
+
success=False, error="paho-mqtt not installed",
|
|
331
|
+
)
|
|
332
|
+
if not self._connected:
|
|
333
|
+
return ActionResult(
|
|
334
|
+
device_id=action.device_id, action=action.action,
|
|
335
|
+
success=False, error="MQTT broker not connected",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
topic = f"{PREFIX}/devices/{action.device_id}/commands"
|
|
339
|
+
payload = json.dumps({
|
|
340
|
+
"action": action.action,
|
|
341
|
+
"params": action.params,
|
|
342
|
+
"urgency": urgency.value,
|
|
343
|
+
"timestamp": time.time(),
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
info = self._client.publish(topic, payload, qos=QOS)
|
|
348
|
+
# Non-blocking for QoS 0; brief wait for QoS 1/2
|
|
349
|
+
if QOS > 0:
|
|
350
|
+
info.wait_for_publish(timeout=2.0)
|
|
351
|
+
log.debug("MQTTAdapter: published '%s' → %s", action.action, topic)
|
|
352
|
+
return ActionResult(
|
|
353
|
+
device_id=action.device_id,
|
|
354
|
+
action=action.action,
|
|
355
|
+
success=True,
|
|
356
|
+
response={"topic": topic, "qos": QOS, "mid": info.mid},
|
|
357
|
+
)
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
log.warning("MQTTAdapter: publish failed for '%s': %s", action.device_id, exc)
|
|
360
|
+
return ActionResult(
|
|
361
|
+
device_id=action.device_id, action=action.action,
|
|
362
|
+
success=False, error=str(exc),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def is_connected(self) -> bool:
|
|
369
|
+
return self._connected
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def broker(self) -> str:
|
|
373
|
+
return f"{BROKER}:{PORT}"
|
|
374
|
+
|
|
375
|
+
def publish_raw(self, topic: str, payload: dict, retain: bool = False) -> None:
|
|
376
|
+
"""Publish an arbitrary message — for hub-initiated announcements."""
|
|
377
|
+
if self._client and self._connected:
|
|
378
|
+
self._client.publish(topic, json.dumps(payload), qos=QOS, retain=retain)
|
|
379
|
+
|
|
380
|
+
def clear_device_registration(self, device_id: str) -> None:
|
|
381
|
+
"""Clear the retained MQTT registration message for a device.
|
|
382
|
+
|
|
383
|
+
Publishes an empty payload (byte string b"") to the device's register
|
|
384
|
+
topic with retain=True. This is the correct MQTT mechanism for deleting
|
|
385
|
+
a retained message — the broker removes it and future subscribers will
|
|
386
|
+
not receive a stale registration.
|
|
387
|
+
|
|
388
|
+
Called automatically when DELETE /v1/devices/{device_id} is invoked.
|
|
389
|
+
"""
|
|
390
|
+
if not (self._client and self._connected):
|
|
391
|
+
log.debug("MQTTAdapter: cannot clear registration for '%s' — not connected", device_id)
|
|
392
|
+
return
|
|
393
|
+
topic = f"{PREFIX}/devices/{device_id}/register"
|
|
394
|
+
self._client.publish(topic, payload=b"", qos=QOS, retain=True)
|
|
395
|
+
log.info("MQTTAdapter: cleared retained registration for device '%s'", device_id)
|
|
396
|
+
|
|
397
|
+
def purge_stale_device_topics(self, active_device_ids: list[str]) -> None:
|
|
398
|
+
"""Clear retained registrations for devices no longer in the hub registry.
|
|
399
|
+
Call on hub startup after registry is restored from DB.
|
|
400
|
+
Not called automatically — invoke explicitly if needed.
|
|
401
|
+
"""
|
|
402
|
+
# Cannot enumerate retained messages from paho — no-op without external tooling
|
|
403
|
+
# This is a known limitation of MQTT: retained message cleanup requires
|
|
404
|
+
# knowing which topics exist. Subscribe and filter is the only approach.
|
|
405
|
+
log.debug(
|
|
406
|
+
"MQTTAdapter: purge_stale_device_topics called (%d active devices) "
|
|
407
|
+
"— manual cleanup only; use 'mosquitto_sub -t dosync/# -v' to audit.",
|
|
408
|
+
len(active_device_ids),
|
|
409
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Notification Adapter (SMS via Twilio)
|
|
3
|
+
===============================================
|
|
4
|
+
Envía SMS cuando se disparan intents de emergencia o alertas.
|
|
5
|
+
|
|
6
|
+
Uso:
|
|
7
|
+
# Agregar al servidor como middleware de notificaciones
|
|
8
|
+
from dosync.adapters.notifications import NotificationAdapter
|
|
9
|
+
notifier = NotificationAdapter()
|
|
10
|
+
await notifier.notify_emergency(intent, context)
|
|
11
|
+
|
|
12
|
+
Variables de entorno (.env):
|
|
13
|
+
TWILIO_ACCOUNT_SID — Account SID de Twilio
|
|
14
|
+
TWILIO_AUTH_TOKEN — Auth Token de Twilio
|
|
15
|
+
TWILIO_FROM — Número Twilio (+1XXXXXXXXXX)
|
|
16
|
+
DOSYNC_EMERGENCY_CONTACT — Número destino (+54XXXXXXXXXX)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("dosync.notifications")
|
|
24
|
+
|
|
25
|
+
# Cargar .env si existe
|
|
26
|
+
try:
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
env_file = Path(__file__).parent.parent.parent / ".env"
|
|
29
|
+
if env_file.exists():
|
|
30
|
+
for line in env_file.read_text().splitlines():
|
|
31
|
+
if "=" in line and not line.startswith("#"):
|
|
32
|
+
key, val = line.split("=", 1)
|
|
33
|
+
os.environ.setdefault(key.strip(), val.strip())
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
TWILIO_SID = os.environ.get("TWILIO_ACCOUNT_SID", "")
|
|
38
|
+
TWILIO_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN", "")
|
|
39
|
+
TWILIO_FROM = os.environ.get("TWILIO_FROM", "")
|
|
40
|
+
EMERGENCY_TO = os.environ.get("DOSYNC_EMERGENCY_CONTACT", "")
|
|
41
|
+
|
|
42
|
+
# Intents que disparan notificaciones
|
|
43
|
+
EMERGENCY_INTENTS = {"ensure_safety", "alert_anomaly", "notify"}
|
|
44
|
+
WARNING_INTENTS = {"report_status", "remind_chore"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotificationAdapter:
|
|
48
|
+
adapter_name = "notifications"
|
|
49
|
+
"""Sends SMS via Twilio for critical DoSync intents."""
|
|
50
|
+
|
|
51
|
+
def __init__(self):
|
|
52
|
+
self._available = bool(TWILIO_SID and TWILIO_TOKEN and TWILIO_FROM)
|
|
53
|
+
if not self._available:
|
|
54
|
+
log.warning("Twilio not configured — SMS notifications disabled")
|
|
55
|
+
else:
|
|
56
|
+
log.info("NotificationAdapter ready — SMS to %s", EMERGENCY_TO or "?")
|
|
57
|
+
|
|
58
|
+
def _get_client(self):
|
|
59
|
+
try:
|
|
60
|
+
from twilio.rest import Client
|
|
61
|
+
return Client(TWILIO_SID, TWILIO_TOKEN)
|
|
62
|
+
except ImportError:
|
|
63
|
+
log.error("twilio not installed — run: pip install twilio")
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def _build_message(self, intent: str, urgency: str, context: dict) -> str:
|
|
67
|
+
"""Build the SMS message body for the given intent."""
|
|
68
|
+
location = context.get("location", "")
|
|
69
|
+
trigger = context.get("trigger", "")
|
|
70
|
+
temp = context.get("temperature")
|
|
71
|
+
|
|
72
|
+
if intent == "ensure_safety":
|
|
73
|
+
loc = f" en {location}" if location else ""
|
|
74
|
+
member = context.get("member", "")
|
|
75
|
+
if member:
|
|
76
|
+
return (
|
|
77
|
+
f"DoSync — {member} llegaron a casa.\n"
|
|
78
|
+
f"El sensor de movimiento los detecto y el hogar respondio automaticamente."
|
|
79
|
+
)
|
|
80
|
+
return (
|
|
81
|
+
f"DOSYNC EMERGENCIA{loc}\n"
|
|
82
|
+
f"Se detecto una situacion de emergencia en el hogar.\n"
|
|
83
|
+
f"El sistema activo el protocolo de seguridad.\n"
|
|
84
|
+
f"Verificar inmediatamente. Llamar al 107 (SAME) si es necesario."
|
|
85
|
+
)
|
|
86
|
+
elif intent == "alert_anomaly" and temp:
|
|
87
|
+
return (
|
|
88
|
+
f"DOSYNC ALERTA\n"
|
|
89
|
+
f"Temperatura anormal: {temp}C\n"
|
|
90
|
+
f"Verificar el hogar."
|
|
91
|
+
)
|
|
92
|
+
elif intent == "report_status" and trigger == "motion_detected":
|
|
93
|
+
loc = f" en {location}" if location else ""
|
|
94
|
+
return f"DOSYNC INFO\nMovimiento detectado{loc}."
|
|
95
|
+
elif intent == "notify":
|
|
96
|
+
msg = context.get("message", "Notification from DoSync")
|
|
97
|
+
return f"DOSYNC\n{msg}"
|
|
98
|
+
else:
|
|
99
|
+
return f"DOSYNC {urgency.upper()}\nIntent: {intent}"
|
|
100
|
+
|
|
101
|
+
async def execute(self, action, urgency):
|
|
102
|
+
from ..models import ActionResult
|
|
103
|
+
params = action.params or {}
|
|
104
|
+
message = params.get('message', 'Notificación DoSync')
|
|
105
|
+
try:
|
|
106
|
+
await self.notify(
|
|
107
|
+
intent=action.action,
|
|
108
|
+
urgency=urgency.value if hasattr(urgency, 'value') else str(urgency),
|
|
109
|
+
context={'message': message},
|
|
110
|
+
to=None
|
|
111
|
+
)
|
|
112
|
+
return ActionResult(device_id=action.device_id, action=action.action, success=True, response={'status': 'sent'})
|
|
113
|
+
except Exception as e:
|
|
114
|
+
return ActionResult(device_id=action.device_id, action=action.action, success=False, error=str(e))
|
|
115
|
+
|
|
116
|
+
async def notify(self, intent: str, urgency: str, context: dict,
|
|
117
|
+
to: str = None) -> bool:
|
|
118
|
+
"""Send an SMS. Returns True on success."""
|
|
119
|
+
if not self._available:
|
|
120
|
+
log.warning("SMS not sent — Twilio not configured")
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
destination = to or EMERGENCY_TO
|
|
124
|
+
if not destination:
|
|
125
|
+
log.warning("SMS not sent — no destination number configured")
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
# Solo notificar para intents relevantes
|
|
129
|
+
if intent not in EMERGENCY_INTENTS and urgency not in ("emergency", "alert"):
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
client = self._get_client()
|
|
133
|
+
if not client:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
message = self._build_message(intent, urgency, context)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
msg = client.messages.create(
|
|
140
|
+
body=message,
|
|
141
|
+
from_=TWILIO_FROM,
|
|
142
|
+
to=destination,
|
|
143
|
+
)
|
|
144
|
+
log.info("SMS sent to %s — SID: %s", destination, msg.sid)
|
|
145
|
+
return True
|
|
146
|
+
except Exception as e:
|
|
147
|
+
log.error("SMS failed: %s", e)
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
async def notify_emergency(self, intent: str, context: dict,
|
|
151
|
+
to: str = None) -> bool:
|
|
152
|
+
"""Shortcut para notificaciones de emergencia."""
|
|
153
|
+
return await self.notify(intent, "emergency", context, to)
|