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