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/adapters/wiz.py ADDED
@@ -0,0 +1,357 @@
1
+ """
2
+ DoSync — WiZ Adapter
3
+ ====================
4
+ Adapter para lamparitas Philips WiZ via protocolo UDP local.
5
+
6
+ Características:
7
+ - Comunicación 100% local — sin nube, sin internet requerido
8
+ - Compatible con todas las lamparitas WiZ con WiFi
9
+ - Soporta: encender, apagar, brillo, color RGB, temperatura de color
10
+ - Discovery automático de IPs via broadcast (opcional)
11
+
12
+ Instalación:
13
+ pip install pywizlight
14
+
15
+ Registro de un dispositivo WiZ en DoSync:
16
+
17
+ from dosync.adapters.wiz import WiZAdapter, wiz_manifest
18
+
19
+ # Registrar el adapter en el executor
20
+ executor = AdapterExecutor(hub)
21
+ executor.register(WiZAdapter())
22
+
23
+ # Registrar la lamparita en el hub
24
+ hub.register_device(wiz_manifest(
25
+ device_id="wiz-living-01",
26
+ device_name="Lámpara sala",
27
+ ip="192.168.1.45",
28
+ tags=["light", "living-room", "climate"],
29
+ ))
30
+ """
31
+
32
+ from __future__ import annotations
33
+ import logging
34
+ from typing import Optional
35
+
36
+ from ..adapters import DoSyncAdapter
37
+ from ..models import ActionResult, DeviceAction, Urgency
38
+
39
+ log = logging.getLogger("dosync.adapters.wiz")
40
+
41
+ # Optional import — if pywizlight is not installed, the adapter
42
+ # operates in simulated mode with a warning
43
+ try:
44
+ from pywizlight import wizlight, PilotBuilder
45
+ WIZ_AVAILABLE = True
46
+ except ImportError:
47
+ WIZ_AVAILABLE = False
48
+ log.warning(
49
+ "pywizlight not installed — WiZAdapter running in simulated mode. "
50
+ "Install with: pip install pywizlight"
51
+ )
52
+
53
+
54
+ # ── Manifest helper ───────────────────────────────────────────────────────────
55
+
56
+ def wiz_manifest(
57
+ device_id: str,
58
+ device_name: str,
59
+ ip: str,
60
+ tags: Optional[list[str]] = None,
61
+ room: str = "",
62
+ ):
63
+ """
64
+ Genera un CapabilityManifest listo para registrar una lamparita WiZ.
65
+
66
+ Args:
67
+ device_id: identificador único (ej: "wiz-living-01")
68
+ device_name: nombre visible (ej: "Lámpara sala")
69
+ ip: IP de la lamparita en la red local (ej: "192.168.1.45")
70
+ tags: tags adicionales (se agregan a ["light", "wiz"])
71
+ room: habitación (se agrega como tag si se provee)
72
+ """
73
+ from ..models import (
74
+ ActuatorSpec, CapabilityManifest, CertTier, DeviceCategory,
75
+ EventSpec, SensorSpec, Urgency,
76
+ )
77
+
78
+ # Tag vocabulary: only canonical role tags here. Vendor names ("wiz") and
79
+ # imprecise tags ("climate") are non-portable and were removed per
80
+ # TAG-VOCABULARY.md. The caller adds emergency/energy/location via `tags`.
81
+ base_tags = ["light"]
82
+ if tags:
83
+ base_tags.extend(tags)
84
+ if room:
85
+ base_tags.append(room)
86
+
87
+ # Guardamos la IP en adapter_config para que el AdapterExecutor la encuentre
88
+ manifest = CapabilityManifest(
89
+ device_id=device_id,
90
+ device_name=device_name,
91
+ manufacturer="Philips WiZ",
92
+ model="WiZ WiFi Bulb",
93
+ firmware="auto",
94
+ category=DeviceCategory.ACTUATOR,
95
+ tags=list(set(base_tags)),
96
+ sensors=[
97
+ # kind="device_state": these describe the LAMP, not the room. Real
98
+ # telemetry, truthfully declared — but "read the environment" should
99
+ # not sweep them (SENSOR-KIND, 2026-07-17).
100
+ SensorSpec("brightness", "integer", "Brillo actual", unit="%",
101
+ kind="device_state"),
102
+ SensorSpec("state", "boolean", "Encendida/apagada",
103
+ kind="device_state"),
104
+ ],
105
+ actuators=[
106
+ ActuatorSpec("turn_on", "turn_on", "Encender"),
107
+ ActuatorSpec("turn_off", "turn_off", "Apagar"),
108
+ ActuatorSpec("set_brightness", "set_brightness", "Brillo 0-100%",
109
+ {"type": "object",
110
+ "properties": {"brightness": {"type": "integer", "minimum": 0, "maximum": 100}},
111
+ "required": ["brightness"]}),
112
+ ActuatorSpec("set_color", "set_color", "Color RGB",
113
+ {"type": "object",
114
+ "properties": {"r": {"type": "integer", "minimum": 0, "maximum": 255},
115
+ "g": {"type": "integer", "minimum": 0, "maximum": 255},
116
+ "b": {"type": "integer", "minimum": 0, "maximum": 255}},
117
+ "required": ["r", "g", "b"]}),
118
+ ActuatorSpec("set_color_temp", "set_color_temp", "Temperatura de color",
119
+ {"type": "object",
120
+ "properties": {"kelvin": {"type": "integer", "minimum": 2200, "maximum": 6500}},
121
+ "required": ["kelvin"]}),
122
+ ActuatorSpec("set_scene", "set_scene", "Escena WiZ predefinida",
123
+ {"type": "object",
124
+ "properties": {"scene_id": {"type": "integer", "minimum": 1, "maximum": 32}},
125
+ "required": ["scene_id"]}),
126
+ ],
127
+ events=[],
128
+ emergency_capable=True, # puede usarse en emergencias (luces al max)
129
+ cert_tier=CertTier.STANDARD,
130
+ )
131
+
132
+ # Adjuntar config del adapter directamente al manifest
133
+ manifest.adapter = "wiz"
134
+ manifest.adapter_config = {"ip": ip, "port": 38899}
135
+
136
+ return manifest
137
+
138
+
139
+ # ── WiZ Adapter ───────────────────────────────────────────────────────────────
140
+
141
+ class WiZAdapter(DoSyncAdapter):
142
+ """
143
+ DoSync adapter for Philips WiZ smart bulbs.
144
+
145
+ All communication is direct UDP on the local network.
146
+ No WiZ account or internet connection required.
147
+ """
148
+
149
+ @property
150
+ def adapter_name(self) -> str:
151
+ return "wiz"
152
+
153
+ def _get_ip(self, action: DeviceAction) -> Optional[str]:
154
+ """Obtiene la IP del dispositivo desde el registry del hub."""
155
+ # The device IP is stored in adapter_config of the manifest
156
+ from .. import models # avoid circular import
157
+ return None # se resuelve en execute via action.params o manifest
158
+
159
+ def __init__(self, hub=None):
160
+ """
161
+ Args:
162
+ hub: referencia al DoSyncHub para leer adapter_config del manifest.
163
+ Opcional — si no se pasa, la IP debe venir en action.params.
164
+ """
165
+ self._hub = hub
166
+
167
+ async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
168
+ """Translate a DoSync action into a WiZ UDP command."""
169
+
170
+ # Priority: action params override adapter_config from the manifest
171
+ ip = action.params.get("ip")
172
+
173
+ if not ip and self._hub:
174
+ device = self._hub.registry.get(action.device_id)
175
+ if device and device.adapter_config:
176
+ ip = device.adapter_config.get("ip")
177
+
178
+ if not ip:
179
+ return ActionResult(
180
+ device_id=action.device_id,
181
+ action=action.action,
182
+ success=False,
183
+ error="WiZ IP not found. Use wiz_manifest(ip=...) or pass ip in params.",
184
+ )
185
+
186
+ if not WIZ_AVAILABLE:
187
+ # Modo simulado — pywizlight no instalado
188
+ log.info(
189
+ "[SIMULATED] WiZ %s @ %s: %s %s",
190
+ action.device_id, ip, action.action, action.params,
191
+ )
192
+ return ActionResult(
193
+ device_id=action.device_id,
194
+ action=action.action,
195
+ success=True,
196
+ response={"status": "simulated", "ip": ip, "action": action.action},
197
+ )
198
+
199
+ try:
200
+ bulb = wizlight(ip)
201
+ pilot = await self._build_pilot(action, urgency)
202
+
203
+ if action.action == "turn_off" or pilot is None:
204
+ await bulb.turn_off()
205
+ response = {"status": "off", "ip": ip}
206
+ else:
207
+ await bulb.turn_on(pilot)
208
+ response = {
209
+ "status": "on",
210
+ "ip": ip,
211
+ "action": action.action,
212
+ "params": action.params,
213
+ }
214
+
215
+ await bulb.async_close()
216
+
217
+ log.info(
218
+ "WiZ %s @ %s: %s → OK",
219
+ action.device_id, ip, action.action,
220
+ )
221
+ return ActionResult(
222
+ device_id=action.device_id,
223
+ action=action.action,
224
+ success=True,
225
+ response=response,
226
+ )
227
+
228
+ except Exception as e:
229
+ import asyncio as _asyncio
230
+ if isinstance(e, _asyncio.TimeoutError):
231
+ log.warning("WiZ timeout %s @ %s — marking unreachable",
232
+ action.device_id, ip)
233
+ if self._hub and hasattr(self._hub, 'resolver'):
234
+ if hasattr(self._hub.resolver, 'mark_unreachable'):
235
+ self._hub.resolver.mark_unreachable(action.device_id)
236
+ return ActionResult(
237
+ device_id=action.device_id,
238
+ action=action.action,
239
+ success=False,
240
+ error="WiZ timeout — device unreachable",
241
+ )
242
+ log.error("WiZ error %s @ %s: %s", action.device_id, ip, e)
243
+ return ActionResult(
244
+ device_id=action.device_id,
245
+ action=action.action,
246
+ success=False,
247
+ error=str(e),
248
+ )
249
+
250
+ async def get_state(self, device_id: str) -> dict | None:
251
+ """
252
+ Query current WiZ bulb state via UDP updateState.
253
+ Returns {"on": bool, "brightness": int} or None on failure.
254
+ Timeout: 3 seconds.
255
+ """
256
+ ip = None
257
+ if self._hub:
258
+ device = self._hub.registry.get(device_id)
259
+ if device and device.adapter_config:
260
+ ip = device.adapter_config.get("ip")
261
+ if not ip:
262
+ return None
263
+ if not WIZ_AVAILABLE:
264
+ return None
265
+ try:
266
+ import asyncio as _asyncio
267
+ bulb = wizlight(ip)
268
+ pilot = await _asyncio.wait_for(bulb.updateState(), timeout=3.0)
269
+ await bulb.async_close()
270
+ if pilot:
271
+ pr = pilot.pilotResult
272
+ return {
273
+ "on": pr.get("state", False),
274
+ "brightness": pr.get("dimming", 0),
275
+ "r": pr.get("r"),
276
+ "g": pr.get("g"),
277
+ "b": pr.get("b"),
278
+ "temp": pr.get("temp"),
279
+ }
280
+ return None
281
+ except Exception as e:
282
+ log.debug("WiZ get_state %s @ %s: %s", device_id, ip, e)
283
+ return None
284
+
285
+ async def _build_pilot(self, action: DeviceAction, urgency: Urgency) -> "PilotBuilder":
286
+ """Build a pywizlight PilotBuilder for the given DoSync action."""
287
+ params = action.params
288
+
289
+ # Emergency: always maximum brightness, cool white
290
+ if urgency == Urgency.EMERGENCY:
291
+ return PilotBuilder(brightness=255, colortemp=6500)
292
+
293
+ if action.action == "turn_on":
294
+ brightness = params.get("brightness", 100)
295
+ return PilotBuilder(brightness=self._pct_to_wiz(brightness))
296
+
297
+ if action.action == "set_brightness":
298
+ pct = params.get("brightness", 100)
299
+ # Si viene 0 es apagar
300
+ if pct == 0:
301
+ return None # se maneja como turn_off en execute()
302
+ return PilotBuilder(brightness=self._pct_to_wiz(pct))
303
+
304
+ if action.action == "set_color":
305
+ r = params.get("r", 255)
306
+ g = params.get("g", 255)
307
+ b = params.get("b", 255)
308
+ return PilotBuilder(rgb=(r, g, b))
309
+
310
+ if action.action == "set_color_temp":
311
+ kelvin = params.get("kelvin", 4000)
312
+ return PilotBuilder(colortemp=kelvin)
313
+
314
+ if action.action == "set_scene":
315
+ scene_id = params.get("scene_id", 1)
316
+ return PilotBuilder(scene=scene_id)
317
+
318
+ # Default: encender con brillo al 80%
319
+ return PilotBuilder(brightness=204)
320
+
321
+ @staticmethod
322
+ def _pct_to_wiz(pct: int) -> int:
323
+ """Convierte porcentaje DoSync (0-100) a valor WiZ (0-255)."""
324
+ return max(0, min(255, round(pct * 255 / 100)))
325
+
326
+
327
+ # ── Escenas WiZ predefinidas (referencia) ────────────────────────────────────
328
+
329
+ WIZ_SCENES = {
330
+ 1: "Ocean",
331
+ 2: "Romance",
332
+ 3: "Sunset",
333
+ 4: "Party",
334
+ 5: "Fireplace",
335
+ 6: "Cozy",
336
+ 9: "Cool white",
337
+ 10: "Night light",
338
+ 11: "Focus",
339
+ 12: "Relax",
340
+ 13: "True colors",
341
+ 14: "TV time",
342
+ 15: "Plantgrowth",
343
+ 16: "Spring",
344
+ 17: "Summer",
345
+ 18: "Fall",
346
+ 19: "Deepdive",
347
+ 20: "Jungle",
348
+ 21: "Mojito",
349
+ 22: "Club",
350
+ 23: "Christmas",
351
+ 24: "Halloween",
352
+ 25: "Candlelight",
353
+ 26: "Golden white",
354
+ 27: "Pulse",
355
+ 28: "Steampunk",
356
+ 29: "Rhythm", # sincroniza con música
357
+ }
dosync/audit_backup.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ DoSync — audit log backup / restore / verify (REL-2).
3
+ =====================================================
4
+
5
+ The audit log is the system's most valuable asset: the tamper-evident record
6
+ that backs every "who decided what, and can you prove it" claim. Before REL-2
7
+ the only durability was the live SQLite file plus the ad-hoc export that
8
+ `db audit-reset` writes on its way to wiping a broken chain. This module gives
9
+ the audit log a first-class, standalone backup/restore/verify path.
10
+
11
+ Design:
12
+ * A backup is a self-describing JSON file: the full ordered entry list plus a
13
+ manifest (count, first/last timestamps, a SHA-256 over the canonical entry
14
+ payload, and whether the chain verified AT BACKUP TIME). The manifest lets a
15
+ restore — or an external auditor — detect tampering of the backup file
16
+ itself, independently of the internal per-entry hash chain.
17
+ * verify() re-runs the exact same SHA-256 chain check the live AuditLog uses
18
+ (imported, not reimplemented — one source of truth for what "valid" means).
19
+ * restore() refuses to overwrite a non-empty audit log unless forced, and
20
+ re-verifies the chain of what it loaded. A restore that produced a broken
21
+ chain would be worse than no restore.
22
+
23
+ Nothing here bypasses the chain: restored entries keep their original hashes and
24
+ prev_hash links, so a restored log verifies exactly as the original did.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ import time
32
+ from typing import Any
33
+
34
+
35
+ BACKUP_FORMAT_VERSION = 1
36
+
37
+
38
+ def _canonical(entries: list[dict]) -> str:
39
+ """Canonical JSON of the entry list, for a stable file-level checksum."""
40
+ return json.dumps(entries, sort_keys=True, separators=(",", ":"))
41
+
42
+
43
+ GENESIS = "0" * 64
44
+
45
+
46
+ def verify_entries(entries: list[dict], anchor_prev_hash: str = GENESIS) -> bool:
47
+ """Re-run the live AuditLog SHA-256 chain check over a list of entries.
48
+
49
+ Imported from hub to guarantee identical semantics; falls back to an inline
50
+ equivalent only if the import shape ever changes (kept byte-for-byte the
51
+ same as AuditLog.verify).
52
+
53
+ AUDIT-ARCHIVE (2026-07-19): a chain no longer necessarily starts at the
54
+ genesis hash. When older entries have been archived to a segment file, the
55
+ live chain's first entry links to the LAST ARCHIVED entry — that hash is
56
+ the anchor, and verification starts from it. Genesis is just the anchor of
57
+ a chain that has never been archived.
58
+ """
59
+ prev = anchor_prev_hash
60
+ for entry in entries:
61
+ entry = dict(entry)
62
+ stored_hash = entry.pop("hash", None)
63
+ if stored_hash is None:
64
+ return False
65
+ raw = json.dumps(entry, sort_keys=True)
66
+ calc = hashlib.sha256(raw.encode()).hexdigest()
67
+ if calc != stored_hash or entry.get("prev_hash") != prev:
68
+ return False
69
+ prev = stored_hash
70
+ return True
71
+
72
+
73
+ def build_backup(entries: list[dict],
74
+ anchor_prev_hash: str = GENESIS) -> dict[str, Any]:
75
+ """Build the backup document (manifest + entries) for a list of entries.
76
+
77
+ An archived chain does not start at genesis; the backup records the anchor
78
+ it verifies from, so the file stays self-contained: `verify --file` needs
79
+ nothing but the file."""
80
+ canonical = _canonical(entries)
81
+ return {
82
+ "format_version": BACKUP_FORMAT_VERSION,
83
+ "anchor_prev_hash": anchor_prev_hash,
84
+ "manifest": {
85
+ "count": len(entries),
86
+ "first_timestamp": entries[0].get("timestamp") if entries else None,
87
+ "last_timestamp": entries[-1].get("timestamp") if entries else None,
88
+ "payload_sha256": hashlib.sha256(canonical.encode()).hexdigest(),
89
+ "chain_valid_at_backup": verify_entries(entries, anchor_prev_hash),
90
+ "backed_up_at": time.time(),
91
+ },
92
+ "entries": entries,
93
+ }
94
+
95
+
96
+ def write_backup(entries: list[dict], path: str,
97
+ anchor_prev_hash: str = GENESIS) -> dict[str, Any]:
98
+ """Serialize a backup to disk. Returns the manifest."""
99
+ doc = build_backup(entries, anchor_prev_hash)
100
+ with open(path, "w") as f:
101
+ json.dump(doc, f, indent=2, sort_keys=True)
102
+ return doc["manifest"]
103
+
104
+
105
+ SEGMENT_FORMAT_VERSION = "dosync-audit-segment/v1"
106
+
107
+
108
+ def write_segment(entries: list[dict], path: str, anchor_prev_hash: str,
109
+ generation: int) -> dict[str, Any]:
110
+ """Write an ARCHIVE SEGMENT: a slice of the chain moved out of the live DB.
111
+
112
+ The segment is self-describing and independently verifiable: it records the
113
+ anchor it chains FROM (the previous segment's last hash, or genesis for the
114
+ first generation), so `verify_entries(seg["entries"], seg["anchor_prev_hash"])`
115
+ proves its integrity standalone — and consecutive generations interlock:
116
+ segment N+1's anchor MUST equal segment N's last_hash. The full history is
117
+ verifiable end to end by walking the segments in order, then the live DB.
118
+ """
119
+ if not entries:
120
+ raise ValueError("refusing to write an empty segment")
121
+ canonical = _canonical(entries)
122
+ doc = {
123
+ "format_version": SEGMENT_FORMAT_VERSION,
124
+ "manifest": {
125
+ "generation": generation,
126
+ "anchor_prev_hash": anchor_prev_hash,
127
+ "first_hash": entries[0].get("hash"),
128
+ "last_hash": entries[-1].get("hash"),
129
+ "count": len(entries),
130
+ "first_timestamp": entries[0].get("timestamp"),
131
+ "last_timestamp": entries[-1].get("timestamp"),
132
+ "payload_sha256": hashlib.sha256(canonical.encode()).hexdigest(),
133
+ "archived_at": time.time(),
134
+ },
135
+ "entries": entries,
136
+ }
137
+ with open(path, "w") as f:
138
+ json.dump(doc, f, indent=2, sort_keys=True)
139
+ return doc["manifest"]
140
+
141
+
142
+ def read_segment(path: str) -> dict[str, Any]:
143
+ """Load and integrity-check an archive segment file."""
144
+ with open(path) as f:
145
+ doc = json.load(f)
146
+ if doc.get("format_version") != SEGMENT_FORMAT_VERSION:
147
+ raise ValueError(f"Not an audit segment file: {doc.get('format_version')}")
148
+ entries = doc.get("entries", [])
149
+ expected = doc["manifest"]["payload_sha256"]
150
+ actual = hashlib.sha256(_canonical(entries).encode()).hexdigest()
151
+ if actual != expected:
152
+ raise ValueError("Segment payload checksum mismatch — the file was altered after writing")
153
+ return doc
154
+
155
+
156
+ def file_sha256(path: str) -> str:
157
+ """SHA-256 of the file bytes as written — the fingerprint the live chain's
158
+ `audit_archived` entry binds, so the segment cannot be silently swapped."""
159
+ h = hashlib.sha256()
160
+ with open(path, "rb") as f:
161
+ for chunk in iter(lambda: f.read(65536), b""):
162
+ h.update(chunk)
163
+ return h.hexdigest()
164
+
165
+
166
+ def read_backup(path: str) -> dict[str, Any]:
167
+ """Load and integrity-check a backup file.
168
+
169
+ Raises ValueError if the file-level checksum does not match its entries
170
+ (i.e. the backup file was altered after it was written).
171
+ """
172
+ with open(path) as f:
173
+ doc = json.load(f)
174
+ if doc.get("format_version") != BACKUP_FORMAT_VERSION:
175
+ raise ValueError(f"Unsupported backup format_version: {doc.get('format_version')}")
176
+ entries = doc.get("entries", [])
177
+ expected = doc["manifest"]["payload_sha256"]
178
+ actual = hashlib.sha256(_canonical(entries).encode()).hexdigest()
179
+ if actual != expected:
180
+ raise ValueError(
181
+ "Backup file checksum mismatch — the backup has been altered since it "
182
+ f"was written (manifest={expected[:16]}…, actual={actual[:16]}…)."
183
+ )
184
+ return doc