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.
@@ -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}