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,655 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Home Assistant Bridge
|
|
3
|
+
===============================
|
|
4
|
+
Conecta DoSync con Home Assistant via su API REST local.
|
|
5
|
+
Expone todos los dispositivos de HA como gadgets DoSync certificables.
|
|
6
|
+
|
|
7
|
+
Requiere:
|
|
8
|
+
pip install aiohttp
|
|
9
|
+
|
|
10
|
+
Configuración:
|
|
11
|
+
HA_URL = "http://homeassistant.local:8123"
|
|
12
|
+
HA_TOKEN = "<long-lived access token>"
|
|
13
|
+
|
|
14
|
+
Cómo obtener el token en HA:
|
|
15
|
+
1. Perfil (ícono abajo izquierda) → Long-Lived Access Tokens
|
|
16
|
+
2. "Create Token" → copiar el token
|
|
17
|
+
|
|
18
|
+
Uso:
|
|
19
|
+
from dosync.adapters.homeassistant import HABridge
|
|
20
|
+
|
|
21
|
+
bridge = HABridge(
|
|
22
|
+
ha_url="http://homeassistant.local:8123",
|
|
23
|
+
ha_token="<token>",
|
|
24
|
+
hub=hub,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Importar todos los dispositivos de HA al hub
|
|
28
|
+
count = await bridge.import_devices()
|
|
29
|
+
print(f"Imported {count} devices")
|
|
30
|
+
|
|
31
|
+
# Registrar el adapter en el executor
|
|
32
|
+
executor.register(bridge)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
import logging
|
|
37
|
+
from typing import Optional, TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from ..hub import DoSyncHub
|
|
41
|
+
|
|
42
|
+
from ..adapters import DoSyncAdapter
|
|
43
|
+
from ..models import (
|
|
44
|
+
ActionResult, ActuatorSpec, CapabilityManifest,
|
|
45
|
+
CertTier, DeviceAction, DeviceCategory, EventSpec,
|
|
46
|
+
SensorSpec, Urgency,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger("dosync.adapters.ha")
|
|
50
|
+
|
|
51
|
+
# ── Domain → DoSync mapping ───────────────────────────────────────────────────
|
|
52
|
+
# Cada dominio de HA se mapea a tags, actuadores y sensores DoSync
|
|
53
|
+
|
|
54
|
+
HA_DOMAIN_MAP = {
|
|
55
|
+
"light": {
|
|
56
|
+
"category": DeviceCategory.HYBRID,
|
|
57
|
+
"tags": ["light"],
|
|
58
|
+
"actuators": [
|
|
59
|
+
ActuatorSpec("turn_on", "turn_on", "Encender"),
|
|
60
|
+
ActuatorSpec("turn_off", "turn_off", "Apagar"),
|
|
61
|
+
ActuatorSpec("set_brightness", "set_brightness", "Brillo 0-100%",
|
|
62
|
+
{"type": "object",
|
|
63
|
+
"properties": {"brightness": {"type": "integer", "minimum": 0, "maximum": 100}},
|
|
64
|
+
"required": ["brightness"]}),
|
|
65
|
+
ActuatorSpec("set_color", "set_color", "Color RGB",
|
|
66
|
+
{"type": "object",
|
|
67
|
+
"properties": {"r": {"type": "integer", "minimum": 0, "maximum": 255},
|
|
68
|
+
"g": {"type": "integer", "minimum": 0, "maximum": 255},
|
|
69
|
+
"b": {"type": "integer", "minimum": 0, "maximum": 255}},
|
|
70
|
+
"required": ["r", "g", "b"]}),
|
|
71
|
+
ActuatorSpec("set_effect", "set_effect", "Efecto Ambilight",
|
|
72
|
+
{"type": "object",
|
|
73
|
+
"properties": {"effect": {"type": "string"}},
|
|
74
|
+
"required": ["effect"]}),
|
|
75
|
+
ActuatorSpec("set_color_temp", "set_color_temp", "Temperatura de color",
|
|
76
|
+
{"type": "object",
|
|
77
|
+
"properties": {"kelvin": {"type": "integer", "minimum": 2200, "maximum": 6500}},
|
|
78
|
+
"required": ["kelvin"]}),
|
|
79
|
+
],
|
|
80
|
+
"sensors": [
|
|
81
|
+
SensorSpec("state", "boolean", "Encendida/apagada", kind="device_state"),
|
|
82
|
+
SensorSpec("brightness", "integer", "Brillo actual", unit="%", kind="device_state"),
|
|
83
|
+
],
|
|
84
|
+
# Bridged devices reach DoSync through the HA hop: an emergency response
|
|
85
|
+
# should rely on natively-integrated devices, not on a bridge dependency.
|
|
86
|
+
# Deployments that accept the dependency can override per device after
|
|
87
|
+
# registration. (2026-07-12 — operator benchmark finding: the Ambilight
|
|
88
|
+
# was force-included in emergencies because of this default.)
|
|
89
|
+
"emergency_capable": False,
|
|
90
|
+
},
|
|
91
|
+
"switch": {
|
|
92
|
+
"category": DeviceCategory.ACTUATOR,
|
|
93
|
+
# "smart-plug" is a deprecated vendor-ish tag (TAG-VOCABULARY); the
|
|
94
|
+
# native adapters stopped emitting it — the HA map lagged behind.
|
|
95
|
+
"tags": ["appliance"],
|
|
96
|
+
"actuators": [
|
|
97
|
+
ActuatorSpec("turn_on", "turn_on", "Encender"),
|
|
98
|
+
ActuatorSpec("turn_off", "turn_off", "Apagar"),
|
|
99
|
+
],
|
|
100
|
+
"sensors": [SensorSpec("state", "boolean", "Estado", kind="device_state")],
|
|
101
|
+
"emergency_capable": False,
|
|
102
|
+
},
|
|
103
|
+
"climate": {
|
|
104
|
+
"category": DeviceCategory.HYBRID,
|
|
105
|
+
"tags": ["thermostat", "climate"],
|
|
106
|
+
"actuators": [
|
|
107
|
+
ActuatorSpec("set_temperature", "set_temperature", "Temperatura",
|
|
108
|
+
{"type": "object",
|
|
109
|
+
"properties": {"celsius": {"type": "number"}},
|
|
110
|
+
"required": ["celsius"]}),
|
|
111
|
+
ActuatorSpec("turn_off", "turn_off", "Apagar clima"),
|
|
112
|
+
],
|
|
113
|
+
"sensors": [
|
|
114
|
+
# The per-sensor grain earning its keep: current_temp MEASURES THE
|
|
115
|
+
# ROOM (environment — a thermostat is also a thermometer); target_temp
|
|
116
|
+
# is the setpoint, the device's own configuration (device_state).
|
|
117
|
+
SensorSpec("current_temp", "temperature", "Temperatura actual", unit="celsius"),
|
|
118
|
+
SensorSpec("target_temp", "temperature", "Temperatura objetivo", unit="celsius",
|
|
119
|
+
kind="device_state"),
|
|
120
|
+
],
|
|
121
|
+
"emergency_capable": False,
|
|
122
|
+
},
|
|
123
|
+
"cover": {
|
|
124
|
+
"category": DeviceCategory.ACTUATOR,
|
|
125
|
+
"tags": ["blinds", "climate"],
|
|
126
|
+
"actuators": [
|
|
127
|
+
ActuatorSpec("set_position", "set_position", "Posición 0-100%",
|
|
128
|
+
{"type": "object",
|
|
129
|
+
"properties": {"position": {"type": "integer", "minimum": 0, "maximum": 100}},
|
|
130
|
+
"required": ["position"]}),
|
|
131
|
+
ActuatorSpec("turn_on", "turn_on", "Abrir"),
|
|
132
|
+
ActuatorSpec("turn_off", "turn_off", "Cerrar"),
|
|
133
|
+
],
|
|
134
|
+
"sensors": [SensorSpec("position", "integer", "Posición actual", unit="%",
|
|
135
|
+
kind="device_state")],
|
|
136
|
+
"emergency_capable": False,
|
|
137
|
+
},
|
|
138
|
+
"lock": {
|
|
139
|
+
"category": DeviceCategory.HYBRID,
|
|
140
|
+
"tags": ["door-lock", "access", "emergency"],
|
|
141
|
+
"actuators": [
|
|
142
|
+
ActuatorSpec("unlock", "unlock", "Desbloquear"),
|
|
143
|
+
ActuatorSpec("lock", "lock", "Bloquear"),
|
|
144
|
+
],
|
|
145
|
+
"sensors": [SensorSpec("state", "boolean", "Bloqueado/desbloqueado",
|
|
146
|
+
kind="device_state")],
|
|
147
|
+
"emergency_capable": True,
|
|
148
|
+
},
|
|
149
|
+
"binary_sensor": {
|
|
150
|
+
"category": DeviceCategory.SENSOR,
|
|
151
|
+
"tags": ["sensor"],
|
|
152
|
+
"actuators": [],
|
|
153
|
+
"sensors": [SensorSpec("state", "boolean", "Estado")],
|
|
154
|
+
"emergency_capable": False,
|
|
155
|
+
},
|
|
156
|
+
"sensor": {
|
|
157
|
+
"category": DeviceCategory.SENSOR,
|
|
158
|
+
"tags": ["sensor"],
|
|
159
|
+
"actuators": [],
|
|
160
|
+
"sensors": [SensorSpec("value", "float", "Valor del sensor")],
|
|
161
|
+
"emergency_capable": False,
|
|
162
|
+
},
|
|
163
|
+
"camera": {
|
|
164
|
+
"category": DeviceCategory.HYBRID,
|
|
165
|
+
"tags": ["camera", "emergency"],
|
|
166
|
+
"actuators": [
|
|
167
|
+
ActuatorSpec("record", "record", "Iniciar grabación"),
|
|
168
|
+
ActuatorSpec("stream", "stream", "Activar streaming"),
|
|
169
|
+
],
|
|
170
|
+
"sensors": [SensorSpec("state", "boolean", "Activa", kind="device_state")],
|
|
171
|
+
"emergency_capable": True,
|
|
172
|
+
},
|
|
173
|
+
"alarm_control_panel": {
|
|
174
|
+
"category": DeviceCategory.HYBRID,
|
|
175
|
+
"tags": ["alarm", "emergency", "security"],
|
|
176
|
+
"actuators": [
|
|
177
|
+
ActuatorSpec("arm", "arm", "Armar"),
|
|
178
|
+
ActuatorSpec("alarm", "alarm", "Activar alarma"),
|
|
179
|
+
],
|
|
180
|
+
"sensors": [SensorSpec("state", "string", "Estado de alarma",
|
|
181
|
+
kind="device_state")],
|
|
182
|
+
"emergency_capable": True,
|
|
183
|
+
},
|
|
184
|
+
"media_player": {
|
|
185
|
+
"category": DeviceCategory.HYBRID,
|
|
186
|
+
"tags": ["display", "communication"],
|
|
187
|
+
"actuators": [
|
|
188
|
+
ActuatorSpec("turn_on", "turn_on", "Encender"),
|
|
189
|
+
ActuatorSpec("turn_off", "turn_off", "Apagar"),
|
|
190
|
+
ActuatorSpec("display", "display", "Mostrar mensaje"),
|
|
191
|
+
],
|
|
192
|
+
"sensors": [SensorSpec("state", "string", "Estado", kind="device_state")],
|
|
193
|
+
"emergency_capable": False,
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Dominios que ignoramos (no tienen sentido como gadgets DoSync)
|
|
198
|
+
# ── HA housekeeping (HA-BRIDGE-HYGIENE, 2026-07-19) ──────────────────────────
|
|
199
|
+
# Home Assistant's own internals — sunrise/sunset times from the `sun`
|
|
200
|
+
# integration, backup status from `backup` — surface as sensor.* entities and
|
|
201
|
+
# used to be imported as DoSync "devices". They are not devices in any
|
|
202
|
+
# meaningful sense: nothing about a building is being sensed, and every HA
|
|
203
|
+
# deployment has them (this is bridge-standard behavior, not one deployment's
|
|
204
|
+
# quirk — benchmark cause #3, the largest remaining precision gap). Skipped by
|
|
205
|
+
# DEFAULT; a deployment that genuinely wants them opts in with
|
|
206
|
+
# DOSYNC_HA_IMPORT_HOUSEKEEPING=true. The trailing underscore matters:
|
|
207
|
+
# "sun_" must not match a real sensor named "sunroom_temperature".
|
|
208
|
+
HA_HOUSEKEEPING_PREFIXES = ("sun_", "backup_")
|
|
209
|
+
|
|
210
|
+
HA_IGNORED_DOMAINS = {
|
|
211
|
+
"automation", "script", "scene", "group",
|
|
212
|
+
"input_boolean", "input_number", "input_select",
|
|
213
|
+
"persistent_notification", "person", "zone",
|
|
214
|
+
"sun", "weather", "update", "device_tracker",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── HA Service mapping ────────────────────────────────────────────────────────
|
|
219
|
+
# Traduce acciones DoSync a llamadas de servicio HA
|
|
220
|
+
|
|
221
|
+
def dosync_to_ha_service(domain: str, action: str, params: dict) -> tuple[str, str, dict]:
|
|
222
|
+
"""
|
|
223
|
+
Retorna (domain, service, service_data) para llamar a HA.
|
|
224
|
+
"""
|
|
225
|
+
if action == "turn_on":
|
|
226
|
+
svc_data = {}
|
|
227
|
+
if "brightness" in params:
|
|
228
|
+
svc_data["brightness_pct"] = params["brightness"]
|
|
229
|
+
if "r" in params:
|
|
230
|
+
svc_data["rgb_color"] = [params["r"], params["g"], params["b"]]
|
|
231
|
+
if "kelvin" in params:
|
|
232
|
+
svc_data["color_temp_kelvin"] = params["kelvin"]
|
|
233
|
+
return domain, "turn_on", svc_data
|
|
234
|
+
|
|
235
|
+
if action == "turn_off":
|
|
236
|
+
return domain, "turn_off", {}
|
|
237
|
+
|
|
238
|
+
if action == "set_brightness":
|
|
239
|
+
return domain, "turn_on", {"brightness_pct": params.get("brightness", 100)}
|
|
240
|
+
|
|
241
|
+
if action == "set_color":
|
|
242
|
+
return domain, "turn_on", {
|
|
243
|
+
"rgb_color": [params.get("r",255), params.get("g",255), params.get("b",255)]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if action == "set_effect":
|
|
247
|
+
return domain, "turn_on", {"effect": params.get("effect", "FOLLOW_VIDEO: STANDARD")}
|
|
248
|
+
if action == "set_color_temp":
|
|
249
|
+
return domain, "turn_on", {"color_temp_kelvin": params.get("kelvin", 4000)}
|
|
250
|
+
|
|
251
|
+
if action == "set_temperature":
|
|
252
|
+
return "climate", "set_temperature", {"temperature": params.get("celsius", 21)}
|
|
253
|
+
|
|
254
|
+
if action == "set_position":
|
|
255
|
+
return "cover", "set_cover_position", {"position": params.get("position", 50)}
|
|
256
|
+
|
|
257
|
+
if action == "unlock":
|
|
258
|
+
return "lock", "unlock", {}
|
|
259
|
+
|
|
260
|
+
if action == "lock":
|
|
261
|
+
return "lock", "lock", {}
|
|
262
|
+
|
|
263
|
+
if action == "arm":
|
|
264
|
+
return "alarm_control_panel", "alarm_arm_away", {}
|
|
265
|
+
|
|
266
|
+
if action == "alarm":
|
|
267
|
+
return "alarm_control_panel", "alarm_trigger", {}
|
|
268
|
+
|
|
269
|
+
# Default: intentar turn_on
|
|
270
|
+
return domain, "turn_on", {}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── HA Bridge ─────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
class HABridge(DoSyncAdapter):
|
|
276
|
+
"""
|
|
277
|
+
Bridge entre DoSync y Home Assistant.
|
|
278
|
+
|
|
279
|
+
Actúa como:
|
|
280
|
+
1. Scanner — importa dispositivos de HA como manifests DoSync
|
|
281
|
+
2. Adapter — ejecuta acciones DoSync traducidas a servicios HA
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
def __init__(
|
|
285
|
+
self,
|
|
286
|
+
ha_url: str,
|
|
287
|
+
ha_token: str,
|
|
288
|
+
hub: "DoSyncHub",
|
|
289
|
+
simulated: bool = False,
|
|
290
|
+
import_housekeeping: bool | None = None,
|
|
291
|
+
exclude_prefixes: list[str] | None = None,
|
|
292
|
+
):
|
|
293
|
+
"""
|
|
294
|
+
Args:
|
|
295
|
+
ha_url: URL de HA (ej: http://homeassistant.local:8123)
|
|
296
|
+
ha_token: Long-lived access token de HA
|
|
297
|
+
hub: instancia del DoSyncHub
|
|
298
|
+
simulated: si True, usa datos de ejemplo sin conectar a HA real
|
|
299
|
+
"""
|
|
300
|
+
self._url = ha_url.rstrip("/")
|
|
301
|
+
self._token = ha_token
|
|
302
|
+
self._hub = hub
|
|
303
|
+
self._simulated = simulated
|
|
304
|
+
self._session = None
|
|
305
|
+
# HA-BRIDGE-HYGIENE: both are deployment configuration (env), exposed as
|
|
306
|
+
# constructor params so tests need no environment.
|
|
307
|
+
import os as _os
|
|
308
|
+
self._import_housekeeping = (
|
|
309
|
+
import_housekeeping if import_housekeeping is not None
|
|
310
|
+
else _os.environ.get("DOSYNC_HA_IMPORT_HOUSEKEEPING", "").lower()
|
|
311
|
+
in ("1", "true", "yes"))
|
|
312
|
+
raw_excludes = (exclude_prefixes if exclude_prefixes is not None
|
|
313
|
+
else [p.strip() for p in
|
|
314
|
+
_os.environ.get("DOSYNC_HA_EXCLUDE_ENTITIES", "").split(",")
|
|
315
|
+
if p.strip()])
|
|
316
|
+
self._exclude_prefixes = tuple(raw_excludes)
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def adapter_name(self) -> str:
|
|
320
|
+
return "homeassistant"
|
|
321
|
+
|
|
322
|
+
def _headers(self) -> dict:
|
|
323
|
+
return {
|
|
324
|
+
"Authorization": f"Bearer {self._token}",
|
|
325
|
+
"Content-Type": "application/json",
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async def _get_session(self):
|
|
329
|
+
if self._session is None:
|
|
330
|
+
try:
|
|
331
|
+
import aiohttp
|
|
332
|
+
self._session = aiohttp.ClientSession(headers=self._headers())
|
|
333
|
+
except ImportError:
|
|
334
|
+
raise ImportError(
|
|
335
|
+
"aiohttp not installed. Run: pip install aiohttp"
|
|
336
|
+
)
|
|
337
|
+
return self._session
|
|
338
|
+
|
|
339
|
+
# ── Import devices from HA ────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
async def import_devices(self) -> dict:
|
|
342
|
+
"""
|
|
343
|
+
Reads all HA states and registers devices in the hub.
|
|
344
|
+
|
|
345
|
+
Returns {"new": N, "updated": M, "skipped": K, "total": N+M+K}
|
|
346
|
+
- new: device_id not previously in registry
|
|
347
|
+
- updated: device_id existed but manifest changed
|
|
348
|
+
- skipped: device_id existed with identical manifest (no-op)
|
|
349
|
+
|
|
350
|
+
Idempotent: safe to run multiple times. Re-runs update changed
|
|
351
|
+
devices and skip unchanged ones. Never creates duplicates.
|
|
352
|
+
"""
|
|
353
|
+
if self._simulated:
|
|
354
|
+
count = self._import_simulated()
|
|
355
|
+
return {"new": count, "updated": 0, "skipped": 0, "total": count}
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
session = await self._get_session()
|
|
359
|
+
async with session.get(f"{self._url}/api/states") as resp:
|
|
360
|
+
if resp.status == 401:
|
|
361
|
+
raise ValueError("Invalid HA token — check your Long-Lived Access Token")
|
|
362
|
+
if resp.status != 200:
|
|
363
|
+
raise ConnectionError(f"HA returned status {resp.status}")
|
|
364
|
+
states = await resp.json()
|
|
365
|
+
except Exception as e:
|
|
366
|
+
log.error("Failed to connect to HA at %s: %s", self._url, e)
|
|
367
|
+
raise
|
|
368
|
+
|
|
369
|
+
new_count = 0
|
|
370
|
+
updated_count = 0
|
|
371
|
+
skipped_count = 0
|
|
372
|
+
|
|
373
|
+
for state in states:
|
|
374
|
+
manifest = self._state_to_manifest(state)
|
|
375
|
+
if not manifest:
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
existing = self._hub.registry.get(manifest.device_id)
|
|
379
|
+
if existing is None:
|
|
380
|
+
self._hub.register_device(manifest)
|
|
381
|
+
new_count += 1
|
|
382
|
+
log.debug("HA bridge: new device %s", manifest.device_id)
|
|
383
|
+
else:
|
|
384
|
+
# Compare relevant fields — skip if nothing changed
|
|
385
|
+
e, n = existing.to_dict(), manifest.to_dict()
|
|
386
|
+
changed = (
|
|
387
|
+
e.get("device_name") != n.get("device_name")
|
|
388
|
+
or e.get("tags") != n.get("tags")
|
|
389
|
+
or e.get("actuators") != n.get("actuators")
|
|
390
|
+
or e.get("sensors") != n.get("sensors")
|
|
391
|
+
or e.get("emergency_capable") != n.get("emergency_capable")
|
|
392
|
+
)
|
|
393
|
+
if changed:
|
|
394
|
+
self._hub.register_device(manifest)
|
|
395
|
+
updated_count += 1
|
|
396
|
+
log.debug("HA bridge: updated device %s", manifest.device_id)
|
|
397
|
+
else:
|
|
398
|
+
skipped_count += 1
|
|
399
|
+
log.debug("HA bridge: unchanged %s (skipped)", manifest.device_id)
|
|
400
|
+
|
|
401
|
+
total = new_count + updated_count + skipped_count
|
|
402
|
+
log.info(
|
|
403
|
+
"HA bridge: %d new, %d updated, %d unchanged — %d device(s) from %s",
|
|
404
|
+
new_count, updated_count, skipped_count, total, self._url,
|
|
405
|
+
)
|
|
406
|
+
return {"new": new_count, "updated": updated_count, "skipped": skipped_count, "total": total}
|
|
407
|
+
|
|
408
|
+
def _state_to_manifest(self, state: dict) -> Optional[CapabilityManifest]:
|
|
409
|
+
"""Convierte un estado de HA en un CapabilityManifest DoSync."""
|
|
410
|
+
entity_id = state.get("entity_id", "")
|
|
411
|
+
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
412
|
+
attributes = state.get("attributes", {})
|
|
413
|
+
friendly = attributes.get("friendly_name", entity_id)
|
|
414
|
+
|
|
415
|
+
if domain in HA_IGNORED_DOMAINS:
|
|
416
|
+
return None
|
|
417
|
+
if domain not in HA_DOMAIN_MAP:
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
# Skip ALL WiZ entities — DoSync registers WiZ bulbs directly via
|
|
421
|
+
# WiZAdapter (device_id "wiz-*"), so importing their HA mirror creates
|
|
422
|
+
# logical duplicates. This covers BOTH the primary light.wiz_* entity
|
|
423
|
+
# (which duplicated all 10 bulbs on 2026-07-12) AND the read-only power
|
|
424
|
+
# sub-sensors (sensor.wiz_*_power/energy/...) HA auto-creates. Devices
|
|
425
|
+
# with a native DoSync adapter must not be re-imported through the bridge.
|
|
426
|
+
if "wiz" in entity_id.lower():
|
|
427
|
+
log.debug("Skipping WiZ entity (already registered via WiZAdapter): %s", entity_id)
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
# HA housekeeping (sun times, backup status) is not a device. Skipped by
|
|
431
|
+
# default; DOSYNC_HA_IMPORT_HOUSEKEEPING=true opts back in. See the
|
|
432
|
+
# module-level note for the reasoning.
|
|
433
|
+
name_part = entity_id.split(".", 1)[1] if "." in entity_id else entity_id
|
|
434
|
+
if (not self._import_housekeeping
|
|
435
|
+
and name_part.startswith(HA_HOUSEKEEPING_PREFIXES)):
|
|
436
|
+
log.debug("Skipping HA housekeeping entity: %s", entity_id)
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
# Deployment-declared exclusions (DOSYNC_HA_EXCLUDE_ENTITIES): matched as
|
|
440
|
+
# prefixes of the entity name, same rule as housekeeping.
|
|
441
|
+
if self._exclude_prefixes and name_part.startswith(self._exclude_prefixes):
|
|
442
|
+
log.debug("Skipping HA entity excluded by deployment config: %s", entity_id)
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
mapping = HA_DOMAIN_MAP[domain]
|
|
446
|
+
|
|
447
|
+
# Inferir tags adicionales del nombre
|
|
448
|
+
extra_tags = self._infer_tags(friendly, entity_id)
|
|
449
|
+
|
|
450
|
+
manifest = CapabilityManifest(
|
|
451
|
+
device_id=f"ha-{entity_id.replace('.', '-')}",
|
|
452
|
+
device_name=f"{friendly}",
|
|
453
|
+
manufacturer="Home Assistant",
|
|
454
|
+
model=f"HA {domain}",
|
|
455
|
+
firmware="auto",
|
|
456
|
+
category=mapping["category"],
|
|
457
|
+
tags=list(set(mapping["tags"] + extra_tags)),
|
|
458
|
+
sensors=mapping.get("sensors", []),
|
|
459
|
+
actuators=mapping.get("actuators", []),
|
|
460
|
+
events=[],
|
|
461
|
+
emergency_capable=mapping.get("emergency_capable", False),
|
|
462
|
+
cert_tier=CertTier.STANDARD,
|
|
463
|
+
)
|
|
464
|
+
manifest.adapter = "homeassistant"
|
|
465
|
+
manifest.adapter_config = {
|
|
466
|
+
"entity_id": entity_id,
|
|
467
|
+
"domain": domain,
|
|
468
|
+
"ha_url": self._url,
|
|
469
|
+
}
|
|
470
|
+
return manifest
|
|
471
|
+
|
|
472
|
+
def _infer_tags(self, friendly_name: str, entity_id: str) -> list[str]:
|
|
473
|
+
"""Infer location and type tags from the device name."""
|
|
474
|
+
tags = []
|
|
475
|
+
name = (friendly_name + " " + entity_id).lower()
|
|
476
|
+
rooms = [
|
|
477
|
+
"living", "sala", "bedroom", "dormitorio", "kitchen", "cocina",
|
|
478
|
+
"bathroom", "baño", "garage", "garden", "outdoor", "exterior",
|
|
479
|
+
"office", "oficina", "hallway", "entrance", "entrada",
|
|
480
|
+
]
|
|
481
|
+
for room in rooms:
|
|
482
|
+
if room in name:
|
|
483
|
+
tags.append(room)
|
|
484
|
+
return tags
|
|
485
|
+
|
|
486
|
+
# ── Simulated mode ────────────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
def _import_simulated(self) -> int:
|
|
489
|
+
"""Importa dispositivos de ejemplo para testing sin HA real."""
|
|
490
|
+
simulated_states = [
|
|
491
|
+
{"entity_id": "light.living_room_main",
|
|
492
|
+
"state": "on",
|
|
493
|
+
"attributes": {"friendly_name": "Sala — Luces principales",
|
|
494
|
+
"brightness": 200}},
|
|
495
|
+
{"entity_id": "light.bedroom_lamp",
|
|
496
|
+
"state": "off",
|
|
497
|
+
"attributes": {"friendly_name": "Dormitorio — Lámpara"}},
|
|
498
|
+
{"entity_id": "climate.main_thermostat",
|
|
499
|
+
"state": "heat",
|
|
500
|
+
"attributes": {"friendly_name": "Termostato principal",
|
|
501
|
+
"current_temperature": 20.5,
|
|
502
|
+
"temperature": 22}},
|
|
503
|
+
{"entity_id": "lock.front_door",
|
|
504
|
+
"state": "locked",
|
|
505
|
+
"attributes": {"friendly_name": "Cerradura puerta principal"}},
|
|
506
|
+
{"entity_id": "cover.living_room_blinds",
|
|
507
|
+
"state": "closed",
|
|
508
|
+
"attributes": {"friendly_name": "Persiana sala", "position": 0}},
|
|
509
|
+
{"entity_id": "alarm_control_panel.main",
|
|
510
|
+
"state": "disarmed",
|
|
511
|
+
"attributes": {"friendly_name": "Alarma principal"}},
|
|
512
|
+
{"entity_id": "binary_sensor.motion_living",
|
|
513
|
+
"state": "off",
|
|
514
|
+
"attributes": {"friendly_name": "Sensor movimiento sala"}},
|
|
515
|
+
{"entity_id": "switch.coffee_maker",
|
|
516
|
+
"state": "off",
|
|
517
|
+
"attributes": {"friendly_name": "Cafetera"}},
|
|
518
|
+
{"entity_id": "media_player.living_tv",
|
|
519
|
+
"state": "standby",
|
|
520
|
+
"attributes": {"friendly_name": "TV sala"}},
|
|
521
|
+
{"entity_id": "camera.front_door",
|
|
522
|
+
"state": "idle",
|
|
523
|
+
"attributes": {"friendly_name": "Cámara puerta principal"}},
|
|
524
|
+
]
|
|
525
|
+
count = 0
|
|
526
|
+
for state in simulated_states:
|
|
527
|
+
manifest = self._state_to_manifest(state)
|
|
528
|
+
if manifest:
|
|
529
|
+
self._hub.register_device(manifest)
|
|
530
|
+
count += 1
|
|
531
|
+
log.info("HA bridge (simulated): imported %d device(s)", count)
|
|
532
|
+
return count
|
|
533
|
+
|
|
534
|
+
# ── Execute action ────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
|
|
537
|
+
"""Translate a DoSync action into a Home Assistant service call and execute it."""
|
|
538
|
+
device = self._hub.registry.get(action.device_id)
|
|
539
|
+
if not device or not device.adapter_config:
|
|
540
|
+
return ActionResult(
|
|
541
|
+
device_id=action.device_id,
|
|
542
|
+
action=action.action,
|
|
543
|
+
success=False,
|
|
544
|
+
error="Device not found or missing adapter_config",
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
entity_id = device.adapter_config.get("entity_id")
|
|
548
|
+
domain = device.adapter_config.get("domain", entity_id.split(".")[0])
|
|
549
|
+
|
|
550
|
+
svc_domain, service, svc_data = dosync_to_ha_service(
|
|
551
|
+
domain, action.action, action.params
|
|
552
|
+
)
|
|
553
|
+
svc_data["entity_id"] = entity_id
|
|
554
|
+
|
|
555
|
+
# Modo simulado
|
|
556
|
+
if self._simulated:
|
|
557
|
+
log.info(
|
|
558
|
+
"[HA SIMULATED] %s.%s(%s) → %s/%s %s",
|
|
559
|
+
action.device_id, action.action, action.params,
|
|
560
|
+
svc_domain, service, svc_data,
|
|
561
|
+
)
|
|
562
|
+
return ActionResult(
|
|
563
|
+
device_id=action.device_id,
|
|
564
|
+
action=action.action,
|
|
565
|
+
success=True,
|
|
566
|
+
response={
|
|
567
|
+
"status": "simulated",
|
|
568
|
+
"ha_service": f"{svc_domain}.{service}",
|
|
569
|
+
"entity_id": entity_id,
|
|
570
|
+
"data": svc_data,
|
|
571
|
+
},
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
# Llamada real a HA
|
|
575
|
+
try:
|
|
576
|
+
session = await self._get_session()
|
|
577
|
+
url = f"{self._url}/api/services/{svc_domain}/{service}"
|
|
578
|
+
async with session.post(url, json=svc_data) as resp:
|
|
579
|
+
if resp.status in (200, 201):
|
|
580
|
+
log.info(
|
|
581
|
+
"HA %s.%s → %s.%s OK",
|
|
582
|
+
action.device_id, action.action, svc_domain, service,
|
|
583
|
+
)
|
|
584
|
+
return ActionResult(
|
|
585
|
+
device_id=action.device_id,
|
|
586
|
+
action=action.action,
|
|
587
|
+
success=True,
|
|
588
|
+
response={
|
|
589
|
+
"status": "ok",
|
|
590
|
+
"ha_service": f"{svc_domain}.{service}",
|
|
591
|
+
"entity_id": entity_id,
|
|
592
|
+
},
|
|
593
|
+
)
|
|
594
|
+
else:
|
|
595
|
+
body = await resp.text()
|
|
596
|
+
return ActionResult(
|
|
597
|
+
device_id=action.device_id,
|
|
598
|
+
action=action.action,
|
|
599
|
+
success=False,
|
|
600
|
+
error=f"HA returned {resp.status}: {body[:200]}",
|
|
601
|
+
)
|
|
602
|
+
except Exception as e:
|
|
603
|
+
return ActionResult(
|
|
604
|
+
device_id=action.device_id,
|
|
605
|
+
action=action.action,
|
|
606
|
+
success=False,
|
|
607
|
+
error=str(e),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
async def get_state(self, device_id: str) -> dict | None:
|
|
611
|
+
"""
|
|
612
|
+
Query current HA entity state via REST API.
|
|
613
|
+
Returns normalized state dict or None on failure.
|
|
614
|
+
Timeout: 3 seconds.
|
|
615
|
+
"""
|
|
616
|
+
if self._simulated:
|
|
617
|
+
return None
|
|
618
|
+
if not self._hub:
|
|
619
|
+
return None
|
|
620
|
+
device = self._hub.registry.get(device_id)
|
|
621
|
+
if not device or not device.adapter_config:
|
|
622
|
+
return None
|
|
623
|
+
entity_id = device.adapter_config.get("entity_id")
|
|
624
|
+
if not entity_id:
|
|
625
|
+
return None
|
|
626
|
+
try:
|
|
627
|
+
session = await self._get_session()
|
|
628
|
+
async with session.get(
|
|
629
|
+
f"{self._url}/api/states/{entity_id}",
|
|
630
|
+
timeout=3.0,
|
|
631
|
+
) as resp:
|
|
632
|
+
if resp.status != 200:
|
|
633
|
+
return None
|
|
634
|
+
data = await resp.json()
|
|
635
|
+
state_str = data.get("state", "")
|
|
636
|
+
attrs = data.get("attributes", {})
|
|
637
|
+
# Normalize to a common state format
|
|
638
|
+
on = state_str not in ("off", "unavailable", "unknown", "0")
|
|
639
|
+
result = {"on": on, "state": state_str}
|
|
640
|
+
if "brightness" in attrs:
|
|
641
|
+
result["brightness"] = round(attrs["brightness"] / 255 * 100)
|
|
642
|
+
if "temperature" in attrs:
|
|
643
|
+
result["temperature"] = attrs["temperature"]
|
|
644
|
+
if "current_temperature" in attrs:
|
|
645
|
+
result["current_temperature"] = attrs["current_temperature"]
|
|
646
|
+
return result
|
|
647
|
+
except Exception as e:
|
|
648
|
+
log.debug("HABridge get_state %s (%s): %s", device_id, entity_id, e)
|
|
649
|
+
return None
|
|
650
|
+
|
|
651
|
+
async def close(self) -> None:
|
|
652
|
+
"""Close the HTTP session."""
|
|
653
|
+
if self._session:
|
|
654
|
+
await self._session.close()
|
|
655
|
+
self._session = None
|