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/executor.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Device Executor
|
|
3
|
+
Abstracts the actual sending of actions to physical devices.
|
|
4
|
+
Provides a SimulatedExecutor for testing without hardware.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .models import ActionResult, DeviceAction, Urgency
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("dosync.executor")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DeviceExecutor(ABC):
|
|
20
|
+
"""
|
|
21
|
+
Abstract executor. In production, subclass this for each transport:
|
|
22
|
+
WiFiExecutor, BLEExecutor, ZigbeeExecutor, etc.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SimulatedExecutor(DeviceExecutor):
|
|
31
|
+
"""
|
|
32
|
+
Simulates device responses for development and certification testing.
|
|
33
|
+
Each device_id can be configured with custom behaviors.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, failure_rate: float = 0.0):
|
|
37
|
+
"""
|
|
38
|
+
Args:
|
|
39
|
+
failure_rate: probability [0.0–1.0] that a device randomly fails.
|
|
40
|
+
Use 0.1 to simulate a 10% hardware failure rate.
|
|
41
|
+
"""
|
|
42
|
+
self.failure_rate = failure_rate
|
|
43
|
+
self._custom: dict[str, Any] = {}
|
|
44
|
+
|
|
45
|
+
def set_device_behavior(self, device_id: str, always_fail: bool = False,
|
|
46
|
+
latency_ms: int = 50) -> None:
|
|
47
|
+
self._custom[device_id] = {"always_fail": always_fail, "latency_ms": latency_ms}
|
|
48
|
+
|
|
49
|
+
async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
|
|
50
|
+
behavior = self._custom.get(action.device_id, {})
|
|
51
|
+
latency = behavior.get("latency_ms", 50) / 1000
|
|
52
|
+
await asyncio.sleep(latency)
|
|
53
|
+
|
|
54
|
+
if behavior.get("always_fail") or random.random() < self.failure_rate:
|
|
55
|
+
log.warning("Simulated failure: %s / %s", action.device_id, action.action)
|
|
56
|
+
return ActionResult(
|
|
57
|
+
device_id=action.device_id,
|
|
58
|
+
action=action.action,
|
|
59
|
+
success=False,
|
|
60
|
+
error="Simulated device failure",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
response = self._simulate_response(action, urgency)
|
|
64
|
+
log.info("Executed: %s.%s → %s", action.device_id, action.action, response)
|
|
65
|
+
|
|
66
|
+
return ActionResult(
|
|
67
|
+
device_id=action.device_id,
|
|
68
|
+
action=action.action,
|
|
69
|
+
success=True,
|
|
70
|
+
response=response,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _simulate_response(self, action: DeviceAction, urgency: Urgency) -> dict:
|
|
74
|
+
responses = {
|
|
75
|
+
# Seguridad
|
|
76
|
+
"unlock": {"status": "unlocked", "duration_seconds": action.params.get("duration_seconds", 300)},
|
|
77
|
+
"lock": {"status": "locked"},
|
|
78
|
+
"call": {"status": "calling", "number": action.params.get("number", "911")},
|
|
79
|
+
"alarm": {"status": "activated", "pattern": action.params.get("pattern", "alert")},
|
|
80
|
+
"arm": {"status": "armed", "mode": action.params.get("mode", "away")},
|
|
81
|
+
# Notificaciones
|
|
82
|
+
"notify": {"status": "sent", "channels": ["push", "sms"]},
|
|
83
|
+
"display": {"status": "shown", "message": action.params.get("message", "")},
|
|
84
|
+
# Iluminacion
|
|
85
|
+
"light": {"status": "on", "brightness": action.params.get("brightness", 100)},
|
|
86
|
+
"set_brightness": {"status": "set", "brightness": action.params.get("brightness", 0)},
|
|
87
|
+
# Clima
|
|
88
|
+
"set_temperature": {"status": "set", "celsius": action.params.get("celsius", 21)},
|
|
89
|
+
"set_position": {"status": "set", "position": action.params.get("position", 0)},
|
|
90
|
+
# Energia
|
|
91
|
+
"turn_off": {"status": "off", "device": action.device_id},
|
|
92
|
+
"turn_on": {"status": "on", "device": action.device_id},
|
|
93
|
+
# Sensores
|
|
94
|
+
"read_sensors": {"readings": {sid: round(random.uniform(15, 25), 1)
|
|
95
|
+
for sid in action.params.get("sensor_ids", [])}},
|
|
96
|
+
}
|
|
97
|
+
return responses.get(action.action, {"status": "ok"})
|
dosync/geo.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — shared geospatial helpers.
|
|
3
|
+
===================================
|
|
4
|
+
|
|
5
|
+
The single source of truth for the geofence math. Before this module the
|
|
6
|
+
haversine formula was copied in three places (the GeofencePolicy, the MAVLink
|
|
7
|
+
listener's waypoint-arrival check, and the RouteComposer). The expert panel flagged
|
|
8
|
+
that as the one real trap: two defenses in two moments (admission + in-flight
|
|
9
|
+
monitoring) are GOOD, but the same FORMULA living in several copies is a latent bug —
|
|
10
|
+
fix it in one place and the others drift. So the rule lives here, once, and every
|
|
11
|
+
caller invokes it.
|
|
12
|
+
|
|
13
|
+
Pure functions, no dependencies beyond the stdlib, fully unit-testable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
|
|
20
|
+
# Mean Earth radius in meters. One constant, shared by every distance calculation.
|
|
21
|
+
EARTH_RADIUS_M = 6371000.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
25
|
+
"""Great-circle distance between two lat/lon points, in meters.
|
|
26
|
+
|
|
27
|
+
The canonical distance function for DoSync. Accurate to well within a meter at
|
|
28
|
+
the scales the protocol cares about (perimeters of a few km).
|
|
29
|
+
"""
|
|
30
|
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
31
|
+
dp = math.radians(lat2 - lat1)
|
|
32
|
+
dl = math.radians(lon2 - lon1)
|
|
33
|
+
a = (math.sin(dp / 2) ** 2
|
|
34
|
+
+ math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2)
|
|
35
|
+
return EARTH_RADIUS_M * 2 * math.asin(math.sqrt(a))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_within_perimeter(lat: float, lon: float,
|
|
39
|
+
center_lat: float, center_lon: float,
|
|
40
|
+
max_radius_m: float,
|
|
41
|
+
alt: float | None = None,
|
|
42
|
+
max_altitude_m: float | None = None) -> tuple[bool, str]:
|
|
43
|
+
"""Is a point inside the permitted perimeter (a horizontal circle plus an
|
|
44
|
+
optional altitude ceiling)?
|
|
45
|
+
|
|
46
|
+
Returns (ok, reason). When ok is False, reason explains the breach in
|
|
47
|
+
human-readable terms — the same wording the GeofencePolicy (admission) and the
|
|
48
|
+
in-flight geofence guard (monitoring) both surface, so a breach reads identically
|
|
49
|
+
whether it was caught before takeoff or during flight.
|
|
50
|
+
|
|
51
|
+
This is the shared rule. The two callers differ only in WHEN they call it and on
|
|
52
|
+
WHICH point: the policy checks a go_to's *target* before dispatch; the guard
|
|
53
|
+
checks the vehicle's *reported position* during flight. Same rule, two moments.
|
|
54
|
+
"""
|
|
55
|
+
distance = haversine_m(center_lat, center_lon, lat, lon)
|
|
56
|
+
if distance > max_radius_m:
|
|
57
|
+
return (False,
|
|
58
|
+
f"({lat:.5f}, {lon:.5f}) is {distance:.0f}m from center — "
|
|
59
|
+
f"outside the {max_radius_m:.0f}m geofence.")
|
|
60
|
+
if max_altitude_m is not None and alt is not None and alt > max_altitude_m:
|
|
61
|
+
return (False,
|
|
62
|
+
f"altitude {alt:.0f}m exceeds the ceiling of {max_altitude_m:.0f}m.")
|
|
63
|
+
return (True, "")
|