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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Matter Adapter
|
|
3
|
+
=======================
|
|
4
|
+
Adapter para dispositivos Matter via python-matter-server o Home Assistant bridge.
|
|
5
|
+
|
|
6
|
+
Matter es el estándar de interoperabilidad IoT de la Connectivity Standards Alliance.
|
|
7
|
+
Este adapter soporta dos modos:
|
|
8
|
+
|
|
9
|
+
Modo 1 — via Home Assistant (recomendado para v0.2):
|
|
10
|
+
Aprovecha el HABridge existente. Si HA tiene integración Matter,
|
|
11
|
+
los dispositivos Matter aparecen como entidades HA y se controlan
|
|
12
|
+
via el HABridge. No requiere setup adicional.
|
|
13
|
+
|
|
14
|
+
Modo 2 — via python-matter-server (standalone, experimental):
|
|
15
|
+
Conecta directamente a un python-matter-server corriendo localmente.
|
|
16
|
+
Requiere: pip install matter-server-client
|
|
17
|
+
|
|
18
|
+
Instalación:
|
|
19
|
+
Modo HA: No requiere instalación adicional
|
|
20
|
+
Modo standalone: pip install matter-server-client (experimental)
|
|
21
|
+
|
|
22
|
+
Registro de un dispositivo Matter en DoSync:
|
|
23
|
+
from dosync.adapters.matter import MatterAdapter, matter_manifest
|
|
24
|
+
|
|
25
|
+
executor.register(MatterAdapter(mode="ha", ha_url="http://localhost:8123",
|
|
26
|
+
ha_token="..."))
|
|
27
|
+
|
|
28
|
+
hub.register_device(matter_manifest(
|
|
29
|
+
device_id="matter-light-01",
|
|
30
|
+
device_name="Matter Bulb Living",
|
|
31
|
+
entity_id="light.matter_bulb_living", # entity_id en HA
|
|
32
|
+
tags=["light", "living-room"],
|
|
33
|
+
))
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
from typing import Optional
|
|
39
|
+
|
|
40
|
+
from ..adapters import DoSyncAdapter
|
|
41
|
+
from ..models import ActionResult, DeviceAction, Urgency
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("dosync.adapters.matter")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Manifest helper ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def matter_manifest(
|
|
49
|
+
device_id: str,
|
|
50
|
+
device_name: str,
|
|
51
|
+
entity_id: str,
|
|
52
|
+
device_type: str = "light",
|
|
53
|
+
tags: Optional[list[str]] = None,
|
|
54
|
+
room: str = "",
|
|
55
|
+
emergency_capable: bool = False,
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
Genera un CapabilityManifest para un dispositivo Matter.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
device_id: identificador único (ej: "matter-light-01")
|
|
62
|
+
device_name: nombre visible
|
|
63
|
+
entity_id: entity_id en Home Assistant (ej: "light.matter_bulb")
|
|
64
|
+
device_type: tipo: "light" | "switch" | "cover" | "lock" | "climate"
|
|
65
|
+
tags: tags adicionales
|
|
66
|
+
room: habitación
|
|
67
|
+
emergency_capable: si puede actuar en emergencias
|
|
68
|
+
"""
|
|
69
|
+
from ..models import (
|
|
70
|
+
ActuatorSpec, CapabilityManifest, CertTier, DeviceCategory,
|
|
71
|
+
SensorSpec, Urgency,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Tag vocabulary: no vendor name ("matter"), canonical "plug" not
|
|
75
|
+
# "smart-plug", no imprecise "climate"/"door". Per TAG-VOCABULARY.md.
|
|
76
|
+
base_tags = []
|
|
77
|
+
type_tags = {
|
|
78
|
+
"light": ["light"],
|
|
79
|
+
"switch": ["appliance", "plug"],
|
|
80
|
+
"cover": ["blinds"],
|
|
81
|
+
"lock": ["lock", "security", "emergency"],
|
|
82
|
+
"climate": ["thermostat"],
|
|
83
|
+
}
|
|
84
|
+
base_tags.extend(type_tags.get(device_type, ["appliance"]))
|
|
85
|
+
if tags:
|
|
86
|
+
base_tags.extend(tags)
|
|
87
|
+
if room:
|
|
88
|
+
base_tags.append(room)
|
|
89
|
+
|
|
90
|
+
actuators_map = {
|
|
91
|
+
"light": [("turn_on", "Encender"), ("turn_off", "Apagar"),
|
|
92
|
+
("set_brightness", "Brillo"), ("set_color", "Color")],
|
|
93
|
+
"switch": [("turn_on", "Encender"), ("turn_off", "Apagar")],
|
|
94
|
+
"cover": [("open", "Abrir"), ("close", "Cerrar"),
|
|
95
|
+
("set_position", "Posición 0-100%")],
|
|
96
|
+
"lock": [("lock", "Cerrar"), ("unlock", "Abrir")],
|
|
97
|
+
"climate": [("set_temperature", "Temperatura"), ("turn_on", "Encender"),
|
|
98
|
+
("turn_off", "Apagar")],
|
|
99
|
+
}
|
|
100
|
+
actuators = [
|
|
101
|
+
ActuatorSpec(id=f"{device_id}-{a[0]}", type=a[0], description=a[1])
|
|
102
|
+
for a in actuators_map.get(device_type, actuators_map["switch"])
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
return CapabilityManifest(
|
|
106
|
+
device_id=device_id,
|
|
107
|
+
device_name=device_name,
|
|
108
|
+
manufacturer="Matter",
|
|
109
|
+
model=f"Matter {device_type.capitalize()}",
|
|
110
|
+
firmware="unknown",
|
|
111
|
+
category=DeviceCategory.ACTUATOR,
|
|
112
|
+
tags=list(set(base_tags)),
|
|
113
|
+
sensors=[],
|
|
114
|
+
actuators=actuators,
|
|
115
|
+
events=[],
|
|
116
|
+
emergency_capable=emergency_capable,
|
|
117
|
+
cert_tier=CertTier.BASIC,
|
|
118
|
+
adapter="matter",
|
|
119
|
+
adapter_config={
|
|
120
|
+
"entity_id": entity_id,
|
|
121
|
+
"device_type": device_type,
|
|
122
|
+
"mode": "ha",
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Matter via HA ─────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
class _MatterViaHA:
|
|
130
|
+
"""Controla dispositivos Matter via la API REST de Home Assistant."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, ha_url: str, ha_token: str):
|
|
133
|
+
self.ha_url = ha_url.rstrip("/")
|
|
134
|
+
self.headers = {
|
|
135
|
+
"Authorization": f"Bearer {ha_token}",
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
}
|
|
138
|
+
self.timeout = 10
|
|
139
|
+
|
|
140
|
+
def call_service(self, domain: str, service: str,
|
|
141
|
+
entity_id: str, data: Optional[dict] = None) -> dict:
|
|
142
|
+
import requests
|
|
143
|
+
payload = {"entity_id": entity_id}
|
|
144
|
+
if data:
|
|
145
|
+
payload.update(data)
|
|
146
|
+
url = f"{self.ha_url}/api/services/{domain}/{service}"
|
|
147
|
+
r = requests.post(url, json=payload, headers=self.headers,
|
|
148
|
+
timeout=self.timeout, verify=False)
|
|
149
|
+
r.raise_for_status()
|
|
150
|
+
return {"status": "ok", "service": f"{domain}.{service}",
|
|
151
|
+
"entity_id": entity_id}
|
|
152
|
+
|
|
153
|
+
def get_state(self, entity_id: str) -> dict:
|
|
154
|
+
import requests
|
|
155
|
+
url = f"{self.ha_url}/api/states/{entity_id}"
|
|
156
|
+
r = requests.get(url, headers=self.headers,
|
|
157
|
+
timeout=self.timeout, verify=False)
|
|
158
|
+
r.raise_for_status()
|
|
159
|
+
return r.json()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Adapter ───────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
class MatterAdapter(DoSyncAdapter):
|
|
165
|
+
"""
|
|
166
|
+
Adapter DoSync para dispositivos Matter.
|
|
167
|
+
|
|
168
|
+
Modo actual: via Home Assistant bridge (v0.2).
|
|
169
|
+
Modo futuro: via python-matter-server standalone (v0.3).
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, ha_url: Optional[str] = None,
|
|
173
|
+
ha_token: Optional[str] = None, hub=None):
|
|
174
|
+
import os
|
|
175
|
+
self._ha_url = ha_url or os.environ.get("HA_URL", "http://localhost:8123")
|
|
176
|
+
self._ha_token = ha_token or os.environ.get("HA_TOKEN", "")
|
|
177
|
+
self._hub = hub
|
|
178
|
+
self._client: Optional[_MatterViaHA] = None
|
|
179
|
+
if self._ha_token:
|
|
180
|
+
self._client = _MatterViaHA(self._ha_url, self._ha_token)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def adapter_name(self) -> str:
|
|
184
|
+
return "matter"
|
|
185
|
+
|
|
186
|
+
async def get_state(self, device_id: str) -> dict | None:
|
|
187
|
+
"""Query current Matter device state via HA REST API."""
|
|
188
|
+
if not self._client or not self._hub:
|
|
189
|
+
return None
|
|
190
|
+
device = self._hub.registry.get(device_id)
|
|
191
|
+
if not device or not device.adapter_config:
|
|
192
|
+
return None
|
|
193
|
+
entity_id = device.adapter_config.get("entity_id")
|
|
194
|
+
if not entity_id:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
import asyncio as _asyncio
|
|
198
|
+
raw = await _asyncio.get_running_loop().run_in_executor(
|
|
199
|
+
None, lambda: self._client.get_state(entity_id)
|
|
200
|
+
)
|
|
201
|
+
state_str = raw.get("state", "")
|
|
202
|
+
attrs = raw.get("attributes", {})
|
|
203
|
+
on = state_str not in ("off", "unavailable", "unknown", "0")
|
|
204
|
+
result = {"on": on, "state": state_str}
|
|
205
|
+
if "brightness" in attrs:
|
|
206
|
+
result["brightness"] = round(attrs["brightness"] / 255 * 100)
|
|
207
|
+
if "current_temperature" in attrs:
|
|
208
|
+
result["current_temperature"] = attrs["current_temperature"]
|
|
209
|
+
return result
|
|
210
|
+
except Exception as e:
|
|
211
|
+
log.debug("MatterAdapter get_state %s: %s", device_id, e)
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
|
|
215
|
+
config = action.params.get("_adapter_config", {})
|
|
216
|
+
entity_id = config.get("entity_id")
|
|
217
|
+
device_type = config.get("device_type", "light")
|
|
218
|
+
|
|
219
|
+
if not entity_id:
|
|
220
|
+
return ActionResult(
|
|
221
|
+
device_id=action.device_id,
|
|
222
|
+
action=action.action,
|
|
223
|
+
success=False,
|
|
224
|
+
response=None,
|
|
225
|
+
error="Missing entity_id in adapter_config",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if not self._client:
|
|
229
|
+
log.warning("Matter adapter: no HA client — simulating %s on %s",
|
|
230
|
+
action.action, action.device_id)
|
|
231
|
+
return ActionResult(
|
|
232
|
+
device_id=action.device_id,
|
|
233
|
+
action=action.action,
|
|
234
|
+
success=True,
|
|
235
|
+
response={"simulated": True, "entity_id": entity_id},
|
|
236
|
+
error=None,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
import asyncio
|
|
241
|
+
response = await asyncio.get_running_loop().run_in_executor(
|
|
242
|
+
None,
|
|
243
|
+
lambda: self._call_ha(device_type, action, entity_id)
|
|
244
|
+
)
|
|
245
|
+
log.info("Matter %s — %s OK", action.device_id, action.action)
|
|
246
|
+
return ActionResult(
|
|
247
|
+
device_id=action.device_id,
|
|
248
|
+
action=action.action,
|
|
249
|
+
success=True,
|
|
250
|
+
response=response,
|
|
251
|
+
error=None,
|
|
252
|
+
)
|
|
253
|
+
except Exception as e:
|
|
254
|
+
log.error("Matter error %s: %s", action.device_id, e)
|
|
255
|
+
return ActionResult(
|
|
256
|
+
device_id=action.device_id,
|
|
257
|
+
action=action.action,
|
|
258
|
+
success=False,
|
|
259
|
+
response=None,
|
|
260
|
+
error=str(e),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _call_ha(self, device_type: str,
|
|
264
|
+
action: DeviceAction, entity_id: str) -> dict:
|
|
265
|
+
"""Traduce acción DoSync a servicio HA."""
|
|
266
|
+
a = action.action
|
|
267
|
+
p = action.params
|
|
268
|
+
|
|
269
|
+
if device_type == "light":
|
|
270
|
+
if a == "turn_on":
|
|
271
|
+
data = {}
|
|
272
|
+
if "brightness" in p:
|
|
273
|
+
data["brightness_pct"] = int(p["brightness"])
|
|
274
|
+
if "red" in p:
|
|
275
|
+
data["rgb_color"] = [p["red"], p["green"], p["blue"]]
|
|
276
|
+
return self._client.call_service("light", "turn_on",
|
|
277
|
+
entity_id, data)
|
|
278
|
+
elif a == "turn_off":
|
|
279
|
+
return self._client.call_service("light", "turn_off", entity_id)
|
|
280
|
+
elif a == "set_brightness":
|
|
281
|
+
return self._client.call_service(
|
|
282
|
+
"light", "turn_on", entity_id,
|
|
283
|
+
{"brightness_pct": int(p.get("brightness", 100))}
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
elif device_type == "switch":
|
|
287
|
+
if a == "turn_on":
|
|
288
|
+
return self._client.call_service("switch", "turn_on", entity_id)
|
|
289
|
+
elif a == "turn_off":
|
|
290
|
+
return self._client.call_service("switch", "turn_off", entity_id)
|
|
291
|
+
|
|
292
|
+
elif device_type == "lock":
|
|
293
|
+
if a == "unlock":
|
|
294
|
+
return self._client.call_service("lock", "unlock", entity_id)
|
|
295
|
+
elif a == "lock":
|
|
296
|
+
return self._client.call_service("lock", "lock", entity_id)
|
|
297
|
+
|
|
298
|
+
elif device_type == "cover":
|
|
299
|
+
if a == "open":
|
|
300
|
+
return self._client.call_service("cover", "open_cover", entity_id)
|
|
301
|
+
elif a == "close":
|
|
302
|
+
return self._client.call_service("cover", "close_cover", entity_id)
|
|
303
|
+
elif a == "set_position":
|
|
304
|
+
return self._client.call_service(
|
|
305
|
+
"cover", "set_cover_position", entity_id,
|
|
306
|
+
{"position": int(p.get("position", 50))}
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
elif device_type == "climate":
|
|
310
|
+
if a == "set_temperature":
|
|
311
|
+
return self._client.call_service(
|
|
312
|
+
"climate", "set_temperature", entity_id,
|
|
313
|
+
{"temperature": float(p.get("temperature", 21))}
|
|
314
|
+
)
|
|
315
|
+
elif a == "turn_on":
|
|
316
|
+
return self._client.call_service("climate", "turn_on", entity_id)
|
|
317
|
+
elif a == "turn_off":
|
|
318
|
+
return self._client.call_service("climate", "turn_off", entity_id)
|
|
319
|
+
|
|
320
|
+
return {"status": "unknown_action", "action": a}
|