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