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