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 ADDED
@@ -0,0 +1,17 @@
1
+ """DoSync Protocol — the semantic layer between AI agents and physical devices.
2
+
3
+ Apache 2.0 — github.com/giulianireg-spec/dosync-protocol
4
+
5
+ This module is the SINGLE SOURCE for both version numbers. Until 2026-07-22
6
+ there were three: this file said 0.1.0/0.1, server.py hardcoded 0.4.0 in four
7
+ places, and pyproject.toml carried its own copy — three declarations nobody
8
+ checked against each other, so `import dosync; dosync.__version__` reported a
9
+ version three releases old. pyproject reads the value from here and the server
10
+ imports it, so they cannot drift again (and a test asserts it).
11
+
12
+ The two numbers move independently on purpose:
13
+ __version__ this implementation of the hub (semver)
14
+ __protocol_version__ the wire contract other implementations must match
15
+ """
16
+ __version__ = "0.4.1"
17
+ __protocol_version__ = "0.4"
@@ -0,0 +1,258 @@
1
+ """
2
+ DoSync — Adapter Layer
3
+ ======================
4
+ Capa de traducción entre el protocolo DoSync y dispositivos físicos reales.
5
+
6
+ Modelo:
7
+ DoSync Hub → AdapterExecutor → [WiZAdapter | GPIOAdapter | ShellyAdapter | ...]
8
+
9
+ Para agregar un nuevo dispositivo:
10
+ 1. Crear adapters/mi_marca.py implementando DoSyncAdapter
11
+ 2. Registrar el dispositivo con adapter="mi_marca" en su CapabilityManifest
12
+ 3. El hub lo maneja igual que cualquier otro dispositivo — sin cambios al núcleo
13
+
14
+ Publicación de adapters de terceros:
15
+ pip install dosync-adapter-philipshue
16
+ pip install dosync-adapter-shelly
17
+ pip install dosync-adapter-matter
18
+ """
19
+
20
+ from __future__ import annotations
21
+ import logging
22
+ from abc import ABC, abstractmethod
23
+
24
+ from ..models import ActionResult, DeviceAction, Urgency
25
+
26
+ log = logging.getLogger("dosync.adapters")
27
+
28
+
29
+ # ── Interfaz base que todo adapter debe implementar ───────────────────────────
30
+
31
+ class DoSyncAdapter(ABC):
32
+ """
33
+ Interfaz base para adapters de dispositivos físicos.
34
+
35
+ Cada adapter traduce acciones DoSync al protocolo nativo
36
+ del dispositivo (UDP, HTTP, GPIO, BLE, etc.).
37
+
38
+ Para implementar un adapter nuevo:
39
+
40
+ class MyBrandAdapter(DoSyncAdapter):
41
+ async def execute(self, action, urgency):
42
+ # traducir action.action + action.params al protocolo del dispositivo
43
+ return ActionResult(
44
+ device_id=action.device_id,
45
+ action=action.action,
46
+ success=True,
47
+ response={"status": "ok"},
48
+ )
49
+
50
+ async def connect(self, config):
51
+ # initialize connection with the device
52
+ pass
53
+
54
+ async def disconnect(self):
55
+ pass
56
+
57
+ @property
58
+ def adapter_name(self):
59
+ return "mybrand"
60
+ """
61
+
62
+ @abstractmethod
63
+ async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
64
+ """Execute an action on the physical device."""
65
+ ...
66
+
67
+ async def connect(self, config: dict) -> None:
68
+ """Initialize the connection to the device. Optional."""
69
+ pass
70
+
71
+ async def disconnect(self) -> None:
72
+ """Close the connection. Optional."""
73
+ pass
74
+
75
+ async def get_state(self, device_id: str) -> dict | None:
76
+ """
77
+ Query current device state directly from the physical device.
78
+
79
+ Returns a state dict if supported, None if not implemented.
80
+ The StateAwareResolver background refresher calls this periodically
81
+ to keep the state cache fresh without blocking intent resolution.
82
+
83
+ Example return values:
84
+ {"on": True, "brightness": 80} — WiZ bulb
85
+ {"on": False} — Shelly relay
86
+ {"state": "locked"} — door lock
87
+ None — adapter does not support state query
88
+ """
89
+ return None # default: not supported — override in subclasses
90
+
91
+ @property
92
+ @abstractmethod
93
+ def adapter_name(self) -> str:
94
+ """Unique adapter name. Must match the 'adapter' field in the device manifest."""
95
+ ...
96
+
97
+
98
+ # ── AdapterExecutor — el ejecutor central ────────────────────────────────────
99
+
100
+ class AdapterExecutor:
101
+ """
102
+ Ejecutor central que delega acciones al adapter correcto
103
+ según el campo 'adapter' del CapabilityManifest del dispositivo.
104
+
105
+ Si un dispositivo no tiene adapter registrado, cae al SimulatedExecutor.
106
+
107
+ Uso:
108
+ executor = AdapterExecutor(hub)
109
+ executor.register(WiZAdapter())
110
+ executor.register(GPIOAdapter())
111
+
112
+ # El hub usa este executor en lugar del SimulatedExecutor
113
+ result = await hub.execute_intent(intent, executor)
114
+ """
115
+
116
+ def __init__(self, hub, fallback_to_simulated: bool = True):
117
+ """
118
+ Args:
119
+ hub: instancia de DoSyncHub
120
+ fallback_to_simulated: si True, dispositivos sin adapter
121
+ usan SimulatedExecutor en lugar de fallar
122
+ """
123
+ self._hub = hub
124
+ self._adapters: dict[str, DoSyncAdapter] = {}
125
+ self._fallback = fallback_to_simulated
126
+
127
+ if fallback_to_simulated:
128
+ from ..executor import SimulatedExecutor
129
+ self._simulated = SimulatedExecutor()
130
+ else:
131
+ self._simulated = None
132
+
133
+ def register(self, adapter: DoSyncAdapter) -> None:
134
+ """Registra un adapter por su nombre."""
135
+ self._adapters[adapter.adapter_name] = adapter
136
+ log.info("Adapter registered: %s", adapter.adapter_name)
137
+
138
+ def get_adapter(self, adapter_name: str) -> DoSyncAdapter | None:
139
+ """Returns the adapter instance for a given adapter name, or None if not registered."""
140
+ return self._adapters.get(adapter_name)
141
+
142
+
143
+ def registered_adapters(self) -> list[str]:
144
+ """Lista de adapters registrados."""
145
+ return list(self._adapters.keys())
146
+
147
+ async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
148
+ """
149
+ Ejecuta una acción buscando el adapter correcto para el dispositivo.
150
+ Fallback al SimulatedExecutor si el dispositivo no tiene adapter.
151
+ """
152
+ device = self._hub.registry.get(action.device_id)
153
+
154
+ if device is None:
155
+ return ActionResult(
156
+ device_id=action.device_id,
157
+ action=action.action,
158
+ success=False,
159
+ error=f"Device '{action.device_id}' not found in registry",
160
+ )
161
+
162
+ adapter_name = getattr(device, "adapter", None)
163
+
164
+ # State awareness: skip redundant actions
165
+ from ..hub import StateAwareResolver
166
+ resolver = getattr(self._hub, 'resolver', None)
167
+ if isinstance(resolver, StateAwareResolver):
168
+ dummy_action = action
169
+ if resolver._is_redundant(dummy_action):
170
+ log.info("StateAwareResolver: skipped redundant %s on %s",
171
+ action.action, action.device_id)
172
+ # ActionResult already imported at module level (line 25)
173
+ return ActionResult(
174
+ device_id=action.device_id,
175
+ action=action.action,
176
+ success=True,
177
+ response={"status": "skipped_redundant"},
178
+ )
179
+
180
+ if adapter_name and adapter_name in self._adapters:
181
+ log.info(
182
+ "Dispatching %s.%s to adapter '%s'",
183
+ action.device_id, action.action, adapter_name,
184
+ )
185
+ try:
186
+ result = await self._adapters[adapter_name].execute(action, urgency)
187
+ if result.success:
188
+ self._update_resolver_state(action)
189
+ # Device Health Monitor — registrar resultado
190
+ self._record_health(action, result)
191
+ return result
192
+ except Exception as e:
193
+ log.error(
194
+ "Adapter '%s' failed for %s.%s: %s",
195
+ adapter_name, action.device_id, action.action, e,
196
+ )
197
+ err_result = ActionResult(
198
+ device_id=action.device_id,
199
+ action=action.action,
200
+ success=False,
201
+ error=f"Adapter error: {e}",
202
+ )
203
+ self._record_health(action, err_result)
204
+ return err_result
205
+
206
+ # Fallback
207
+ if self._simulated:
208
+ log.info(
209
+ "No adapter for '%s' (device %s) — using SimulatedExecutor",
210
+ adapter_name or "none", action.device_id,
211
+ )
212
+ return await self._simulated.execute(action, urgency)
213
+
214
+ return ActionResult(
215
+ device_id=action.device_id,
216
+ action=action.action,
217
+ success=False,
218
+ error=f"No adapter registered for '{adapter_name}'",
219
+ )
220
+
221
+ def _record_health(self, action: DeviceAction, result) -> None:
222
+ """Registra el resultado en el Device Health Monitor."""
223
+ try:
224
+ db = getattr(self._hub, 'db', None)
225
+ if db:
226
+ db.record_execution(
227
+ device_id=action.device_id,
228
+ action=action.action,
229
+ success=result.success,
230
+ error=getattr(result, 'error', None),
231
+ )
232
+ except Exception as _e:
233
+ log.warning('DeviceHealthMonitor: failed to record execution for %s: %s',
234
+ action.device_id, _e)
235
+
236
+ def _update_resolver_state(self, action: DeviceAction) -> None:
237
+ """Notifica al StateAwareResolver el nuevo estado tras una accion exitosa."""
238
+ from ..hub import StateAwareResolver
239
+ resolver = getattr(self._hub, 'resolver', None)
240
+ if not isinstance(resolver, StateAwareResolver):
241
+ return
242
+ state_update = {}
243
+ if action.action == 'turn_on':
244
+ state_update = {'on': True, 'brightness': action.params.get('brightness', 100)}
245
+ elif action.action == 'turn_off':
246
+ state_update = {'on': False, 'brightness': 0}
247
+ elif action.action == 'set_brightness':
248
+ state_update = {'on': True, 'brightness': action.params.get('brightness', 100)}
249
+ elif action.action == 'unlock':
250
+ state_update = {'locked': False}
251
+ elif action.action == 'lock':
252
+ state_update = {'locked': True}
253
+ elif action.action == 'set_temperature':
254
+ state_update = {'temperature': action.params.get('celsius')}
255
+ if state_update:
256
+ resolver.update_state(action.device_id, state_update)
257
+
258
+
dosync/adapters/ble.py ADDED
@@ -0,0 +1,199 @@
1
+ """
2
+ DoSync — Universal BLE Adapter
3
+ ==============================
4
+ A single adapter that controls ANY Bluetooth Low Energy device, by driving the
5
+ generic GATT primitives (connect, write characteristic) and reading the per-device
6
+ action→characteristic mapping from the device's manifest.
7
+
8
+ This is the "dumb body, external mind" principle at the transport layer: the BLE
9
+ device does not know DoSync exists. It only exposes its native GATT interface. The
10
+ adapter — running in the hub — speaks that native BLE and lends the device the
11
+ intelligence of being coordinated by an intent.
12
+
13
+ Why one adapter for all BLE devices:
14
+ BLE has no universal command. Each device exposes services and characteristics
15
+ identified by UUIDs, and which characteristic means "turn on" differs per
16
+ device. So the *code* is generic (GATT writes); the *mapping* lives in each
17
+ device's manifest under adapter_config. Adding a new BLE device requires a
18
+ manifest entry, not new code.
19
+
20
+ Manifest adapter_config schema (per device):
21
+ {
22
+ "address": "AA:BB:CC:DD:EE:FF", # BLE MAC (or platform UUID on macOS)
23
+ "actions": {
24
+ "turn_on": {"char": "0000fff1-0000-1000-8000-00805f9b34fb",
25
+ "write": "0F0D0300"}, # hex bytes to write
26
+ "turn_off": {"char": "0000fff1-0000-1000-8000-00805f9b34fb",
27
+ "write": "0F0D0400"}
28
+ }
29
+ }
30
+
31
+ The device's CapabilityManifest declares adapter="ble" and carries this config.
32
+ The hub routes any action on that device here, exactly like wiz/gpio/homeassistant.
33
+
34
+ Dependencies: bleak (cross-platform BLE; uses BlueZ on the Raspberry Pi).
35
+ """
36
+
37
+ from __future__ import annotations
38
+ import logging
39
+ from typing import Optional
40
+
41
+ from ..models import ActionResult, DeviceAction, Urgency
42
+ from . import DoSyncAdapter
43
+
44
+ log = logging.getLogger("dosync.adapters.ble")
45
+
46
+ # bleak is imported lazily so the module imports (and the adapter registers /
47
+ # unit-tests) on a host without a Bluetooth stack.
48
+ try:
49
+ from bleak import BleakClient
50
+ _BLEAK_AVAILABLE = True
51
+ except Exception: # pragma: no cover - depends on host
52
+ BleakClient = None # type: ignore
53
+ _BLEAK_AVAILABLE = False
54
+
55
+
56
+ def ble_manifest(
57
+ device_id: str,
58
+ device_name: str,
59
+ address: str,
60
+ actions: dict,
61
+ tags: Optional[list] = None,
62
+ emergency_capable: bool = False,
63
+ ):
64
+ """Helper to build a CapabilityManifest for a generic BLE device.
65
+
66
+ `actions` maps a DoSync action name to {"char": <uuid>, "write": <hex>}.
67
+ The actuator list is derived from the action keys, so the resolver sees
68
+ exactly the actions this device supports.
69
+ """
70
+ from ..models import (
71
+ ActuatorSpec, CapabilityManifest, CertTier, DeviceCategory,
72
+ )
73
+
74
+ # Derive one actuator per supported action (id == type, like wiz_manifest).
75
+ actuators = [
76
+ ActuatorSpec(name, name, f"BLE action {name}")
77
+ for name in actions.keys()
78
+ ]
79
+
80
+ manifest = CapabilityManifest(
81
+ device_id=device_id,
82
+ device_name=device_name,
83
+ manufacturer="Generic BLE",
84
+ model="BLE GATT device",
85
+ firmware="auto",
86
+ category=DeviceCategory.ACTUATOR,
87
+ tags=list(set(tags or [])),
88
+ sensors=[],
89
+ actuators=actuators,
90
+ events=[],
91
+ emergency_capable=emergency_capable,
92
+ cert_tier=CertTier.BASIC,
93
+ )
94
+
95
+ # Attach adapter config (address + action→characteristic map) to the manifest.
96
+ manifest.adapter = "ble"
97
+ manifest.adapter_config = {"address": address, "actions": actions}
98
+
99
+ return manifest
100
+
101
+
102
+ class BLEAdapter(DoSyncAdapter):
103
+ """Universal Bluetooth Low Energy adapter.
104
+
105
+ One instance handles every device whose manifest declares adapter="ble".
106
+ The per-device address and action→characteristic map come from the manifest's
107
+ adapter_config, read from the hub registry (same pattern as WiZAdapter).
108
+ """
109
+
110
+ def __init__(self, hub=None, connect_timeout: float = 10.0):
111
+ """
112
+ Args:
113
+ hub: reference to the DoSyncHub to read adapter_config from the
114
+ manifest. Optional — if absent, config must come in action.params.
115
+ connect_timeout: seconds to wait for a BLE connection.
116
+ """
117
+ self._hub = hub
118
+ self._connect_timeout = connect_timeout
119
+
120
+ @property
121
+ def adapter_name(self) -> str:
122
+ return "ble"
123
+
124
+ def _get_config(self, action: DeviceAction) -> dict:
125
+ """Resolve adapter_config: action.params override, then manifest."""
126
+ cfg = action.params.get("adapter_config")
127
+ if cfg:
128
+ return cfg
129
+ if self._hub:
130
+ device = self._hub.registry.get(action.device_id)
131
+ if device and device.adapter_config:
132
+ return device.adapter_config
133
+ return {}
134
+
135
+ async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
136
+ """Translate a DoSync action into a GATT characteristic write."""
137
+ cfg = self._get_config(action)
138
+ address = cfg.get("address")
139
+ actions_map = cfg.get("actions", {})
140
+
141
+ if not address:
142
+ return ActionResult(
143
+ device_id=action.device_id, action=action.action, success=False,
144
+ error="BLE manifest missing 'address'. Use ble_manifest(address=...).",
145
+ )
146
+
147
+ spec = actions_map.get(action.action)
148
+ if spec is None:
149
+ return ActionResult(
150
+ device_id=action.device_id, action=action.action, success=False,
151
+ error=f"BLE device has no mapping for action '{action.action}'.",
152
+ )
153
+
154
+ char_uuid = spec.get("char")
155
+ write_hex = spec.get("write")
156
+ if not char_uuid or write_hex is None:
157
+ return ActionResult(
158
+ device_id=action.device_id, action=action.action, success=False,
159
+ error=f"BLE action '{action.action}' mapping needs 'char' and 'write'.",
160
+ )
161
+
162
+ try:
163
+ payload = bytes.fromhex(write_hex)
164
+ except ValueError:
165
+ return ActionResult(
166
+ device_id=action.device_id, action=action.action, success=False,
167
+ error=f"BLE 'write' value is not valid hex: {write_hex!r}.",
168
+ )
169
+
170
+ if not _BLEAK_AVAILABLE:
171
+ # Simulated mode — bleak not installed on this host.
172
+ log.info("[SIMULATED] BLE %s @ %s: write %s to %s",
173
+ action.device_id, address, write_hex, char_uuid)
174
+ return ActionResult(
175
+ device_id=action.device_id, action=action.action, success=True,
176
+ response={"status": "simulated", "address": address,
177
+ "char": char_uuid, "wrote": write_hex},
178
+ )
179
+
180
+ try:
181
+ async with BleakClient(address, timeout=self._connect_timeout) as client:
182
+ await client.write_gatt_char(char_uuid, payload, response=True)
183
+ log.info("BLE %s @ %s: wrote %s to %s → OK",
184
+ action.device_id, address, write_hex, char_uuid)
185
+ return ActionResult(
186
+ device_id=action.device_id, action=action.action, success=True,
187
+ response={"address": address, "char": char_uuid, "wrote": write_hex},
188
+ )
189
+ except Exception as e:
190
+ log.warning("BLE %s @ %s failed: %s", action.action, address, e)
191
+ return ActionResult(
192
+ device_id=action.device_id, action=action.action, success=False,
193
+ error=f"BLE write failed: {e}",
194
+ )
195
+
196
+ async def get_state(self, device_id: str) -> Optional[dict]:
197
+ """BLE state query is optional and device-specific. Deferred, like other
198
+ adapters that return None by default."""
199
+ return None