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