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,348 @@
1
+ """
2
+ DoSync — Shelly Adapter
3
+ =======================
4
+ Adapter para dispositivos Shelly via HTTP local (Gen1 y Gen2).
5
+
6
+ Características:
7
+ - Comunicación 100% local — sin nube, sin internet requerido
8
+ - Compatible con Shelly 1, 1PM, 2.5, Plug S, Dimmer, RGBW2, Pro series
9
+ - Gen1: API /relay/0, /light/0 via GET requests
10
+ - Gen2: API RPC via POST /rpc/Switch.Set, /rpc/Light.Set
11
+ - No requiere dependencias externas — usa requests (ya en requirements.txt)
12
+
13
+ Instalación:
14
+ No requiere instalación adicional.
15
+
16
+ Registro de un dispositivo Shelly en DoSync:
17
+ from dosync.adapters.shelly import ShellyAdapter, shelly_manifest
18
+
19
+ executor.register(ShellyAdapter())
20
+
21
+ hub.register_device(shelly_manifest(
22
+ device_id="shelly-living-01",
23
+ device_name="Shelly Living Room",
24
+ ip="192.168.1.50",
25
+ device_type="relay", # relay | dimmer | plug | rgbw
26
+ gen=1, # 1 o 2
27
+ tags=["light", "living-room"],
28
+ ))
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from typing import Optional
34
+
35
+ from ..adapters import DoSyncAdapter
36
+ from ..models import ActionResult, DeviceAction, Urgency
37
+
38
+ log = logging.getLogger("dosync.adapters.shelly")
39
+
40
+ try:
41
+ import requests as _requests
42
+ REQUESTS_AVAILABLE = True
43
+ except ImportError:
44
+ REQUESTS_AVAILABLE = False
45
+ log.warning("requests not installed — ShellyAdapter running in simulated mode.")
46
+
47
+
48
+ # ── Manifest helper ───────────────────────────────────────────────────────────
49
+
50
+ def shelly_manifest(
51
+ device_id: str,
52
+ device_name: str,
53
+ ip: str,
54
+ device_type: str = "relay",
55
+ gen: int = 1,
56
+ tags: Optional[list[str]] = None,
57
+ room: str = "",
58
+ emergency_capable: bool = False,
59
+ ):
60
+ """
61
+ Genera un CapabilityManifest listo para registrar un dispositivo Shelly.
62
+
63
+ Args:
64
+ device_id: identificador único (ej: "shelly-living-01")
65
+ device_name: nombre visible (ej: "Shelly Living Room")
66
+ ip: IP del dispositivo en la red local
67
+ device_type: tipo: "relay" | "dimmer" | "plug" | "rgbw"
68
+ gen: generación de la API: 1 o 2
69
+ tags: tags adicionales
70
+ room: habitación (se agrega como tag)
71
+ emergency_capable: si puede actuar en emergencias
72
+ """
73
+ from ..models import (
74
+ ActuatorSpec, CapabilityManifest, CertTier, DeviceCategory,
75
+ EventSpec, SensorSpec, Urgency,
76
+ )
77
+
78
+ # Tag vocabulary: no vendor name ("shelly"), canonical "plug" not
79
+ # "smart-plug", no imprecise "climate". Per TAG-VOCABULARY.md.
80
+ base_tags = []
81
+
82
+ # Tags según tipo de dispositivo
83
+ type_tags = {
84
+ "relay": ["light", "appliance"],
85
+ "dimmer": ["light"],
86
+ "plug": ["plug", "appliance"],
87
+ "rgbw": ["light"],
88
+ }
89
+ base_tags.extend(type_tags.get(device_type, []))
90
+ if tags:
91
+ base_tags.extend(tags)
92
+ if room:
93
+ base_tags.append(room)
94
+
95
+ # Actuadores según tipo
96
+ actuators_map = {
97
+ "relay": [("turn_on", "Encender relay"), ("turn_off", "Apagar relay")],
98
+ "dimmer": [("turn_on", "Encender dimmer"), ("turn_off", "Apagar dimmer"),
99
+ ("set_brightness", "Ajustar brillo 0-100%")],
100
+ "plug": [("turn_on", "Encender enchufe"), ("turn_off", "Apagar enchufe")],
101
+ "rgbw": [("turn_on", "Encender"), ("turn_off", "Apagar"),
102
+ ("set_brightness", "Brillo"), ("set_color", "Color RGB")],
103
+ }
104
+ actuators = [
105
+ ActuatorSpec(id=f"{device_id}-{a[0]}", type=a[0], description=a[1])
106
+ for a in actuators_map.get(device_type, actuators_map["relay"])
107
+ ]
108
+
109
+ return CapabilityManifest(
110
+ device_id=device_id,
111
+ device_name=device_name,
112
+ manufacturer="Allterco Robotics",
113
+ model=f"Shelly {device_type.capitalize()} Gen{gen}",
114
+ firmware="unknown",
115
+ category=DeviceCategory.ACTUATOR,
116
+ tags=list(set(base_tags)),
117
+ sensors=[],
118
+ actuators=actuators,
119
+ events=[],
120
+ emergency_capable=emergency_capable,
121
+ cert_tier=CertTier.BASIC,
122
+ adapter="shelly",
123
+ adapter_config={"ip": ip, "device_type": device_type, "gen": gen},
124
+ )
125
+
126
+
127
+ # ── Shelly HTTP client ────────────────────────────────────────────────────────
128
+
129
+ class _ShellyGen1:
130
+ """Cliente HTTP para Shelly Gen1 (API REST simple)."""
131
+
132
+ def __init__(self, ip: str):
133
+ self.base = f"http://{ip}"
134
+ self.timeout = 5
135
+
136
+ def relay_set(self, channel: int, on: bool) -> dict:
137
+ url = f"{self.base}/relay/{channel}"
138
+ r = _requests.get(url, params={"turn": "on" if on else "off"},
139
+ timeout=self.timeout)
140
+ r.raise_for_status()
141
+ return r.json()
142
+
143
+ def light_set(self, channel: int, on: bool,
144
+ brightness: Optional[int] = None,
145
+ red: int = 255, green: int = 255, blue: int = 255) -> dict:
146
+ url = f"{self.base}/light/{channel}"
147
+ params: dict = {"turn": "on" if on else "off"}
148
+ if brightness is not None:
149
+ params["brightness"] = max(0, min(100, brightness))
150
+ if red != 255 or green != 255 or blue != 255:
151
+ params["red"] = red
152
+ params["green"] = green
153
+ params["blue"] = blue
154
+ r = _requests.get(url, params=params, timeout=self.timeout)
155
+ r.raise_for_status()
156
+ return r.json()
157
+
158
+ def get_status(self) -> dict:
159
+ r = _requests.get(f"{self.base}/status", timeout=self.timeout)
160
+ r.raise_for_status()
161
+ return r.json()
162
+
163
+
164
+ class _ShellyGen2:
165
+ """Cliente HTTP para Shelly Gen2 (API RPC JSON)."""
166
+
167
+ def __init__(self, ip: str):
168
+ self.base = f"http://{ip}/rpc"
169
+ self.timeout = 5
170
+
171
+ def _rpc(self, method: str, params: dict) -> dict:
172
+ r = _requests.post(self.base, json={"id": 1, "method": method,
173
+ "params": params},
174
+ timeout=self.timeout)
175
+ r.raise_for_status()
176
+ return r.json()
177
+
178
+ def switch_set(self, channel: int, on: bool) -> dict:
179
+ return self._rpc("Switch.Set", {"id": channel, "on": on})
180
+
181
+ def light_set(self, channel: int, on: bool,
182
+ brightness: Optional[int] = None) -> dict:
183
+ params: dict = {"id": channel, "on": on}
184
+ if brightness is not None:
185
+ params["brightness"] = max(0, min(100, brightness))
186
+ return self._rpc("Light.Set", params)
187
+
188
+ def get_status(self) -> dict:
189
+ return self._rpc("Shelly.GetStatus", {})
190
+
191
+
192
+ # ── Adapter ───────────────────────────────────────────────────────────────────
193
+
194
+ class ShellyAdapter(DoSyncAdapter):
195
+ """
196
+ Adapter DoSync para dispositivos Shelly.
197
+
198
+ Soporta Gen1 (API REST) y Gen2 (API RPC).
199
+ Sin dependencias externas — usa requests.
200
+ """
201
+
202
+ @property
203
+ def adapter_name(self) -> str:
204
+ return "shelly"
205
+
206
+ async def get_state(self, device_id: str) -> dict | None:
207
+ """
208
+ Query current Shelly device state via HTTP.
209
+ Supports Gen1 (/status) and Gen2 (Shelly.GetStatus).
210
+ Timeout: 3 seconds.
211
+ """
212
+ if not self._hub:
213
+ return None
214
+ device = self._hub.registry.get(device_id)
215
+ if not device or not device.adapter_config:
216
+ return None
217
+ config = device.adapter_config
218
+ ip = config.get("ip")
219
+ gen = int(config.get("gen", 1))
220
+ if not ip:
221
+ return None
222
+ try:
223
+ import asyncio as _asyncio
224
+ if gen == 1:
225
+ client = _ShellyGen1(ip)
226
+ raw = await _asyncio.wait_for(
227
+ _asyncio.get_running_loop().run_in_executor(None, client.get_status),
228
+ timeout=3.0,
229
+ )
230
+ relays = raw.get("relays", [])
231
+ lights = raw.get("lights", [])
232
+ if relays:
233
+ return {"on": relays[0].get("ison", False)}
234
+ if lights:
235
+ return {"on": lights[0].get("ison", False),
236
+ "brightness": lights[0].get("brightness", 0)}
237
+ return None
238
+ else:
239
+ client = _ShellyGen2(ip)
240
+ raw = await _asyncio.wait_for(
241
+ _asyncio.get_running_loop().run_in_executor(None, client.get_status),
242
+ timeout=3.0,
243
+ )
244
+ switches = raw.get("result", {}).get("switch:0", {})
245
+ if switches:
246
+ return {"on": switches.get("output", False)}
247
+ lights = raw.get("result", {}).get("light:0", {})
248
+ if lights:
249
+ return {"on": lights.get("output", False),
250
+ "brightness": lights.get("brightness", 0)}
251
+ return None
252
+ except Exception as e:
253
+ import logging as _log
254
+ _log.getLogger("dosync.adapters.shelly").debug(
255
+ "get_state %s @ %s: %s", device_id, ip, e)
256
+ return None
257
+
258
+ async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
259
+ config = action.params.get("_adapter_config", {})
260
+ ip = config.get("ip")
261
+ device_type = config.get("device_type", "relay")
262
+ gen = int(config.get("gen", 1))
263
+
264
+ if not ip:
265
+ return ActionResult(
266
+ device_id=action.device_id,
267
+ action=action.action,
268
+ success=False,
269
+ response=None,
270
+ error="Missing IP in adapter_config",
271
+ )
272
+
273
+ if not REQUESTS_AVAILABLE:
274
+ log.warning("requests not available — simulating Shelly action %s on %s",
275
+ action.action, action.device_id)
276
+ return ActionResult(
277
+ device_id=action.device_id,
278
+ action=action.action,
279
+ success=True,
280
+ response={"simulated": True, "ip": ip},
281
+ error=None,
282
+ )
283
+
284
+ try:
285
+ response = await self._execute_http(
286
+ ip, gen, device_type, action
287
+ )
288
+ log.info("Shelly %s @ %s — %s OK", action.device_id, ip, action.action)
289
+ return ActionResult(
290
+ device_id=action.device_id,
291
+ action=action.action,
292
+ success=True,
293
+ response=response,
294
+ error=None,
295
+ )
296
+ except Exception as e:
297
+ log.error("Shelly error %s @ %s: %s", action.device_id, ip, e)
298
+ return ActionResult(
299
+ device_id=action.device_id,
300
+ action=action.action,
301
+ success=False,
302
+ response=None,
303
+ error=str(e),
304
+ )
305
+
306
+ async def _execute_http(self, ip: str, gen: int,
307
+ device_type: str, action: DeviceAction) -> dict:
308
+ """Ejecuta la acción via HTTP según la generación del dispositivo."""
309
+ import asyncio
310
+
311
+ def _sync_call():
312
+ channel = int(action.params.get("channel", 0))
313
+ brightness = action.params.get("brightness")
314
+
315
+ if gen == 1:
316
+ client = _ShellyGen1(ip)
317
+ if action.action == "turn_on":
318
+ if device_type in ("dimmer", "rgbw"):
319
+ return client.light_set(channel, True, brightness)
320
+ return client.relay_set(channel, True)
321
+ elif action.action == "turn_off":
322
+ if device_type in ("dimmer", "rgbw"):
323
+ return client.light_set(channel, False)
324
+ return client.relay_set(channel, False)
325
+ elif action.action == "set_brightness" and brightness is not None:
326
+ return client.light_set(channel, True, int(brightness))
327
+ elif action.action == "set_color":
328
+ r = action.params.get("red", 255)
329
+ g = action.params.get("green", 255)
330
+ b = action.params.get("blue", 255)
331
+ return client.light_set(channel, True, brightness, r, g, b)
332
+ else:
333
+ client = _ShellyGen2(ip)
334
+ if action.action == "turn_on":
335
+ if device_type == "dimmer":
336
+ return client.light_set(channel, True, brightness)
337
+ return client.switch_set(channel, True)
338
+ elif action.action == "turn_off":
339
+ if device_type == "dimmer":
340
+ return client.light_set(channel, False)
341
+ return client.switch_set(channel, False)
342
+ elif action.action == "set_brightness" and brightness is not None:
343
+ return client.light_set(channel, True, int(brightness))
344
+
345
+ return {"status": "unknown_action", "action": action.action}
346
+
347
+ loop = asyncio.get_running_loop()
348
+ return await loop.run_in_executor(None, _sync_call)