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.
@@ -0,0 +1,252 @@
1
+ """
2
+ DoSync — deployment policy configuration (POL-1).
3
+ =================================================
4
+
5
+ Device preferences are DEPLOYMENT configuration, not protocol and not reference-hub
6
+ code (panel decision 2026-07-12, DoSync-Panel-Frontera-Deployment). The protocol
7
+ defines HOW intent maps to capability; WHICH devices exist and WHAT preferences
8
+ apply is the deployer's business — exactly as HTTP does not know which URLs live on
9
+ your server.
10
+
11
+ Until this module existed the principle was theory. `server.py` hard-coded one
12
+ deployment's choices into the reference hub:
13
+
14
+ policy_engine.add(NeverAfterHoursPolicy(
15
+ actuator_types=["unlock", "alarm"],
16
+ blocked_hours_start=0, blocked_hours_end=6, ...))
17
+
18
+ Who decided 00:00–06:00? One house. Every hub running this code inherited it, and
19
+ changing it meant editing the reference implementation — which, as Sosa put it,
20
+ means it was never configuration at all. Worse, policies that carry no deployment
21
+ values but only make sense for some deployments (GeofencePolicy: "each deployment
22
+ configures its own perimeter via the constructor", says its own docstring) could
23
+ not be registered AT ALL without forking the hub.
24
+
25
+ This module loads them from a file the deployer owns:
26
+
27
+ DOSYNC_POLICIES=/etc/dosync/policies.json (or --policies)
28
+
29
+ {
30
+ "version": 1,
31
+ "policies": [
32
+ {"type": "never_after_hours",
33
+ "actuator_types": ["unlock", "alarm"],
34
+ "blocked_hours_start": 0, "blocked_hours_end": 6,
35
+ "reason": "No remote unlocking overnight"}
36
+ ]
37
+ }
38
+
39
+ Being a file makes these shareable, forkable and versionable between deployments —
40
+ the ecosystem of shared configurations the project wants to enable without having
41
+ to curate it.
42
+
43
+ FAIL LOUDLY, ON PURPOSE
44
+ -----------------------
45
+ Every error here raises. A policy is usually a RESTRICTION: "do not unlock at
46
+ night", "confirm before the alarm", "never let the drone past this perimeter". A
47
+ typo that silently skips one leaves the operator believing they are protected when
48
+ they are not — strictly worse than refusing to start. So: unknown type, bad
49
+ argument, missing file when one was configured — all raise. Silence is the failure
50
+ mode this protocol has been paying for all along.
51
+
52
+ Infrastructure policies (rate limits, conflict resolution, contextual weighting)
53
+ are NOT loaded here: they carry no deployment values and are part of what the
54
+ reference hub is. Only policies expressing a deployment's own choices live in this
55
+ file.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import hashlib
61
+ import json
62
+ import logging
63
+ import os
64
+ from pathlib import Path
65
+ from typing import TYPE_CHECKING
66
+
67
+ from .policies import (BasePolicy, BlockIntentPolicy, DeviceExclusionPolicy,
68
+ GeofencePolicy, ManualControlActivePolicy,
69
+ NeverAfterHoursPolicy, RequireConfirmationPolicy)
70
+
71
+ if TYPE_CHECKING:
72
+ from .hub import DoSyncHub
73
+
74
+ log = logging.getLogger("dosync.policy_config")
75
+
76
+ CONFIG_VERSION = 1
77
+
78
+ # type string -> (class, needs_hub)
79
+ # Only deployment-expressing policies. Adding one here is the single step needed
80
+ # to make it configurable; nothing else in the loader changes.
81
+ POLICY_TYPES: dict[str, tuple[type[BasePolicy], bool]] = {
82
+ "never_after_hours": (NeverAfterHoursPolicy, False),
83
+ "require_confirmation": (RequireConfirmationPolicy, False),
84
+ "device_exclusion": (DeviceExclusionPolicy, False),
85
+ "block_intent": (BlockIntentPolicy, False),
86
+ "geofence": (GeofencePolicy, False),
87
+ "manual_control_active": (ManualControlActivePolicy, True),
88
+ }
89
+
90
+
91
+ class PolicyConfigError(Exception):
92
+ """A deployment policy file could not be loaded. Never swallowed."""
93
+
94
+
95
+ def _build_one(index: int, entry: dict, hub: "DoSyncHub | None") -> BasePolicy:
96
+ if not isinstance(entry, dict):
97
+ raise PolicyConfigError(f"policies[{index}]: expected an object, got {type(entry).__name__}")
98
+
99
+ ptype = entry.get("type")
100
+ if not ptype:
101
+ raise PolicyConfigError(f"policies[{index}]: missing required field 'type'")
102
+
103
+ if ptype not in POLICY_TYPES:
104
+ known = ", ".join(sorted(POLICY_TYPES))
105
+ raise PolicyConfigError(
106
+ f"policies[{index}]: unknown policy type {ptype!r}. Known types: {known}. "
107
+ "Refusing to start rather than silently skip a policy you asked for."
108
+ )
109
+
110
+ cls, needs_hub = POLICY_TYPES[ptype]
111
+ # Keys starting with "_" are metadata, not arguments. JSON has no comments,
112
+ # and a policy file MUST be documentable: a restriction whose reason nobody
113
+ # recorded is one nobody dares to remove later. "_why", "_owner", "_ticket"
114
+ # and friends are for humans and are ignored here.
115
+ kwargs = {k: v for k, v in entry.items()
116
+ if k != "type" and not k.startswith("_")}
117
+
118
+ if needs_hub:
119
+ if hub is None:
120
+ raise PolicyConfigError(
121
+ f"policies[{index}]: {ptype!r} needs the hub, but none was provided to the loader")
122
+ kwargs["hub"] = hub
123
+
124
+ try:
125
+ return cls(**kwargs)
126
+ except TypeError as e:
127
+ # Wrong/missing arguments: name the policy and the file position, because
128
+ # the raw TypeError ("__init__() got an unexpected keyword argument") does
129
+ # not tell an operator which entry of their file is wrong.
130
+ raise PolicyConfigError(f"policies[{index}] ({ptype}): {e}") from e
131
+
132
+
133
+ def load_policies(path: str | Path, hub: "DoSyncHub | None" = None) -> list[BasePolicy]:
134
+ """Build the policy objects declared in a deployment policy file.
135
+
136
+ Raises PolicyConfigError on any problem — see the module docstring for why a
137
+ policy file must never fail quietly.
138
+ """
139
+ path = Path(path)
140
+ if not path.exists():
141
+ raise PolicyConfigError(
142
+ f"policy file not found: {path}. A policy file was configured but does not "
143
+ "exist; refusing to start unprotected."
144
+ )
145
+
146
+ raw = path.read_text()
147
+ try:
148
+ doc = json.loads(raw)
149
+ except json.JSONDecodeError as e:
150
+ raise PolicyConfigError(f"{path}: invalid JSON — {e}") from e
151
+
152
+ # AUDIT-PROVENANCE (2026-07-18): fingerprint of the BYTES THAT WERE LOADED,
153
+ # computed here — at read time — and nowhere else. Hashing the file again
154
+ # later would fingerprint whatever is on disk THEN, which may not be what
155
+ # this hub is enforcing. The hash exists so an audit entry can answer
156
+ # "which exact policy file decided this?" months later, even if the file
157
+ # has since been edited.
158
+ global _last_loaded_fingerprint
159
+ _last_loaded_fingerprint = hashlib.sha256(raw.encode()).hexdigest()
160
+
161
+ if not isinstance(doc, dict):
162
+ raise PolicyConfigError(f"{path}: expected a JSON object at the top level")
163
+
164
+ version = doc.get("version")
165
+ if version != CONFIG_VERSION:
166
+ raise PolicyConfigError(
167
+ f"{path}: unsupported version {version!r} (this hub reads version {CONFIG_VERSION})")
168
+
169
+ # Top-level "_"-prefixed keys (e.g. "_README") are metadata too.
170
+ entries = doc.get("policies")
171
+ if entries is None:
172
+ raise PolicyConfigError(f"{path}: missing required field 'policies'")
173
+ if not isinstance(entries, list):
174
+ raise PolicyConfigError(f"{path}: 'policies' must be a list")
175
+
176
+ policies = [_build_one(i, entry, hub) for i, entry in enumerate(entries)]
177
+ log.info("Loaded %d deployment policy/policies from %s", len(policies), path)
178
+ return policies
179
+
180
+
181
+ _last_loaded_fingerprint: str | None = None
182
+
183
+
184
+ def load_into(engine, path: str | Path, hub: "DoSyncHub | None" = None) -> list[BasePolicy]:
185
+ """Load a policy file and register every policy on the engine.
186
+
187
+ Also stamps the engine with the SHA-256 of the loaded file
188
+ (engine.policies_fingerprint), so audit entries can bind decisions to the
189
+ exact configuration that made them.
190
+ """
191
+ policies = load_policies(path, hub=hub)
192
+ for p in policies:
193
+ engine.add(p)
194
+ engine.policies_fingerprint = _last_loaded_fingerprint
195
+ engine.policies_path = str(path)
196
+ return policies
197
+
198
+
199
+ def lint_emergency_satisfiability(hub, policies: list) -> list[str]:
200
+ """Config-load lint (EMERGENCY-UNSAT part b): warn the operator the day they
201
+ write the rule, not the night of the fire.
202
+
203
+ Checks whether the DECLARED deployment policies, applied at EMERGENCY
204
+ urgency, would empty the plan for the canonical emergency intent
205
+ (ensure_safety) against the CURRENT registry. Returns human-readable
206
+ warnings; empty list means satisfiable (or nothing resolvable to begin
207
+ with — an empty registry is not the policies' fault).
208
+
209
+ Deliberately evaluates through a THROWAWAY engine containing only the
210
+ deployment policies: running a synthetic intent through the production
211
+ engine would consume rate-limit state just for linting.
212
+ """
213
+ from .models import Intent, IntentClass, Urgency
214
+ from .policies import PolicyDecision, PolicyEngine
215
+
216
+ warnings: list[str] = []
217
+ probe = PolicyEngine()
218
+ for pol in policies:
219
+ probe.add(pol)
220
+
221
+ intent = Intent(intent_id="lint-emergency-satisfiability",
222
+ intent=IntentClass("ensure_safety"),
223
+ urgency=Urgency.EMERGENCY, context={})
224
+ try:
225
+ plan = hub.resolver.resolve(intent)
226
+ except Exception as e: # lint must never block startup
227
+ log.warning("satisfiability lint skipped: resolver raised %s", e)
228
+ return warnings
229
+
230
+ pre = sorted({a.device_id for a in plan.actions})
231
+ if not pre:
232
+ return warnings # nothing to empty — not a policy problem
233
+
234
+ result = probe.evaluate(intent, plan)
235
+ if result and result.decision == PolicyDecision.MODIFY and not result.modified_actions:
236
+ warnings.append(
237
+ f"declared policies make EMERGENCY intent 'ensure_safety' UNSATISFIABLE for the "
238
+ f"current registry: {len(pre)} device(s) resolve, 0 survive policy filtering "
239
+ f"(deciding: {result.policy_name}). The rules will be honored — an emergency "
240
+ f"would execute nothing. If that is not what you meant, review the exclusions."
241
+ )
242
+ return warnings
243
+
244
+
245
+ def configured_path() -> str | None:
246
+ """The deployment policy file, if this deployment configured one.
247
+
248
+ None means "this deployment declares no policies", which is a legitimate and
249
+ common state — not an error. The reference hub ships with NO deployment
250
+ policies, because the protocol has no opinion about your house.
251
+ """
252
+ return os.environ.get("DOSYNC_POLICIES") or None
dosync/py.typed ADDED
File without changes
dosync/reconciler.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ DoSync — Operation reconciler (execution_model, Layer 3).
3
+ ========================================================
4
+
5
+ Layer 1 defined the state machine. Layer 2 made operations survive a restart.
6
+ This layer is the active component that *drives* an operation through its states
7
+ based on what the device actually reports — telemetry.
8
+
9
+ THE ONE RULE, from the panel: telemetry wins. The hub never assumes an operation
10
+ succeeded; it advances only on a positive signal, and if telemetry contradicts the
11
+ hub's model, the telemetry is right and the model is corrected. Silence is not
12
+ success — a timeout or an absent signal never advances an operation to `completed`.
13
+
14
+ DELIBERATELY HARDWARE-AGNOSTIC. The reconciler knows nothing about MAVLink, drones,
15
+ ovens, or any protocol. It consumes ABSTRACT telemetry facts — a small vocabulary
16
+ of `TelemetryEvent`s — and maps them to state transitions. The future MAVLinkAdapter
17
+ is what translates real MAVLink messages into these abstract facts; the reconciler
18
+ stays pure logic, fully testable by injecting fake telemetry. That separation is
19
+ what lets the same reconciler serve a drone and an oven.
20
+
21
+ The reconciler does NOT decide policy. It moves operations between states and
22
+ records why. What to *do* about an operation that has been `paused_by_device` for
23
+ too long (notify the operator, trigger RTL) is the Policy Engine's job — the
24
+ reconciler only exposes the state and the time-in-state. (Panel: keep the model
25
+ and the policy separate, or the model becomes unimplementable.)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from enum import Enum
31
+
32
+ from dosync.operations import Operation, OperationState, InvalidTransition
33
+
34
+
35
+ class TelemetryEvent(str, Enum):
36
+ """The abstract vocabulary of facts a device can report about a running
37
+ operation. An adapter translates device-native signals into these; the
38
+ reconciler maps these to state transitions. This list is intentionally small
39
+ and device-independent — a drone and an oven both speak it."""
40
+
41
+ # ── Progress facts (positive signals — the only way forward) ──────────────
42
+ STARTED = "started" # the action is confirmed underway
43
+ PREPARING = "preparing" # a pre-action setup phase began (richer profile).
44
+ # Domain sub-phase (e.g. "arming") goes in the
45
+ # operation's `phase`, not in a new event name.
46
+ FINISHED = "finished" # the action completed — positive completion signal.
47
+ # (A speaker finishes a track; a vehicle arrives.)
48
+
49
+ # ── Trouble facts ─────────────────────────────────────────────────────────
50
+ REJECTED_BY_DEVICE = "rejected_by_device" # device refused (busy, not ready, unsafe)
51
+ FAILED = "failed" # failed partway through
52
+ DEVICE_PAUSED = "device_paused" # the device paused itself (lost stream, storage full, wind)
53
+ DEVICE_RESUMED = "device_resumed" # the device resumed on its own
54
+
55
+ # ── Human / control facts ─────────────────────────────────────────────────
56
+ MANUAL_CONTROL_TAKEN = "manual_control_taken" # a human took manual control — interrupt
57
+ CANCELLED = "cancelled" # explicit cancellation request honored
58
+
59
+
60
+ # Maps a telemetry fact to the operation state it should drive toward. This is the
61
+ # whole policy of "what does this fact mean for the lifecycle", in one table.
62
+ # Note FINISHED → COMPLETED is the ONLY path to completion: there is no way to reach
63
+ # COMPLETED without a positive FINISHED signal. Silence cannot complete an operation.
64
+ _EVENT_TO_STATE: dict[TelemetryEvent, OperationState] = {
65
+ TelemetryEvent.STARTED: OperationState.IN_PROGRESS,
66
+ TelemetryEvent.PREPARING: OperationState.PREPARING,
67
+ TelemetryEvent.FINISHED: OperationState.COMPLETED,
68
+ TelemetryEvent.REJECTED_BY_DEVICE: OperationState.REJECTED,
69
+ TelemetryEvent.FAILED: OperationState.FAILED,
70
+ TelemetryEvent.DEVICE_PAUSED: OperationState.PAUSED_BY_DEVICE,
71
+ TelemetryEvent.DEVICE_RESUMED: OperationState.IN_PROGRESS,
72
+ TelemetryEvent.MANUAL_CONTROL_TAKEN: OperationState.INTERRUPTED,
73
+ TelemetryEvent.CANCELLED: OperationState.CANCELLED,
74
+ }
75
+
76
+
77
+ class ReconcileResult:
78
+ """The outcome of applying one telemetry fact to an operation. Carries enough
79
+ detail for the caller to persist the change and write an audit entry."""
80
+
81
+ __slots__ = ("operation", "changed", "from_state", "to_state", "note")
82
+
83
+ def __init__(self, operation: Operation, changed: bool,
84
+ from_state: OperationState, to_state: OperationState, note: str = ""):
85
+ self.operation = operation
86
+ self.changed = changed
87
+ self.from_state = from_state
88
+ self.to_state = to_state
89
+ self.note = note
90
+
91
+ def __repr__(self) -> str:
92
+ if self.changed:
93
+ return (f"<Reconcile {self.operation.operation_id}: "
94
+ f"{self.from_state.value} → {self.to_state.value}>")
95
+ return (f"<Reconcile {self.operation.operation_id}: no change "
96
+ f"({self.from_state.value}); {self.note}>")
97
+
98
+
99
+ class OperationReconciler:
100
+ """Applies abstract telemetry facts to operations, advancing their state
101
+ machine. Pure logic — no I/O, no hardware. The hub feeds it telemetry (from an
102
+ adapter) and persists the result; the reconciler decides the state change.
103
+ """
104
+
105
+ def reconcile(self, operation: Operation, event: TelemetryEvent,
106
+ reason: str = "", now: float | None = None) -> ReconcileResult:
107
+ """Apply one telemetry fact to one operation.
108
+
109
+ Returns a ReconcileResult describing whether the state changed. Telemetry
110
+ wins: if the fact maps to a legal transition, it is applied. If the fact is
111
+ irrelevant to the current state (e.g. a duplicate "started" while already
112
+ in progress), it is a no-op, not an error — telemetry can be noisy and
113
+ repeat. If the fact would require an illegal jump, that is surfaced as a
114
+ no-op with an explanatory note rather than crashing the hub: a late or
115
+ out-of-order telemetry packet must never take the hub down.
116
+ """
117
+ if isinstance(event, str):
118
+ event = TelemetryEvent(event)
119
+
120
+ from_state = operation.state
121
+ target = _EVENT_TO_STATE[event]
122
+
123
+ # Already there (idempotent telemetry) — common and harmless.
124
+ if from_state == target:
125
+ return ReconcileResult(operation, False, from_state, target,
126
+ note=f"already in {target.value}")
127
+
128
+ # Terminal operations never move — an interrupted op does not resume, a
129
+ # completed op stays completed even if a stale packet arrives later.
130
+ if operation.is_terminal:
131
+ return ReconcileResult(operation, False, from_state, from_state,
132
+ note=f"operation is terminal ({from_state.value}); "
133
+ f"telemetry '{event.value}' ignored")
134
+
135
+ # Apply if legal; otherwise no-op with a note. We do NOT force illegal
136
+ # transitions — but we also never silently pretend success.
137
+ if not operation.can_transition_to(target):
138
+ return ReconcileResult(operation, False, from_state, from_state,
139
+ note=f"telemetry '{event.value}' implies "
140
+ f"{target.value}, illegal from {from_state.value}")
141
+
142
+ full_reason = reason or f"telemetry: {event.value}"
143
+ try:
144
+ operation.transition_to(target, reason=full_reason, now=now)
145
+ except InvalidTransition as e:
146
+ # Defensive: can_transition_to said yes but transition raised. Surface
147
+ # as no-op rather than propagate — the hub must stay up.
148
+ return ReconcileResult(operation, False, from_state, from_state,
149
+ note=f"transition rejected: {e}")
150
+
151
+ return ReconcileResult(operation, True, from_state, target, note=full_reason)
152
+
153
+ def reconcile_after_restart(self, operation: Operation,
154
+ now: float | None = None) -> ReconcileResult:
155
+ """Place a recovered operation into the transient RECONCILING state on hub
156
+ restart, so the hub re-confirms reality from telemetry before assuming the
157
+ operation is still where it left off. The failsafe may have acted while the
158
+ hub was down — the next real telemetry fact resolves it.
159
+
160
+ Only meaningful for telemetry-capable operations; a core-only device has no
161
+ telemetry to reconcile against, so it is left as-is.
162
+ """
163
+ from_state = operation.state
164
+ if not operation.telemetry_capable:
165
+ return ReconcileResult(operation, False, from_state, from_state,
166
+ note="core-only operation: nothing to reconcile")
167
+ if operation.is_terminal:
168
+ return ReconcileResult(operation, False, from_state, from_state,
169
+ note=f"terminal ({from_state.value}); no reconcile")
170
+ if not operation.can_transition_to(OperationState.RECONCILING):
171
+ return ReconcileResult(operation, False, from_state, from_state,
172
+ note=f"cannot reconcile from {from_state.value}")
173
+ operation.transition_to(OperationState.RECONCILING,
174
+ reason="hub restart: reconciling against telemetry",
175
+ now=now)
176
+ return ReconcileResult(operation, True, from_state, OperationState.RECONCILING,
177
+ note="awaiting telemetry to resolve")
@@ -0,0 +1,189 @@
1
+ """
2
+ DoSync — Route composer (turns a composition intent into an ordered step sequence).
3
+ ===================================================================================
4
+
5
+ The semantic resolver answers "which device and which capability for this intent?"
6
+ — it does matching and returns a flat ActionPlan (parallel actions). A perimeter
7
+ inspection is a different kind of problem: it is GEOMETRY (where are the waypoints
8
+ around the center?) plus ORDER (take off, then each waypoint in sequence, then come
9
+ home). That is route planning, not semantic matching.
10
+
11
+ The expert panel placed this in a SEPARATE component (not a resolver subclass)
12
+ because the BaseResolver contract is `resolve(intent) -> ActionPlan`, and an
13
+ ActionPlan is FLAT — it cannot express "these steps run in order, each waiting for
14
+ the previous to actually arrive." A route is a CompositeOperation, not an ActionPlan.
15
+ So the composer lives here, and the hub routes a composition intent to it instead of
16
+ to the flat resolver.
17
+
18
+ GENERIC IN FORM, perimeter as the first concrete case. The composer produces an
19
+ ordered list of CompositeStep. Today it knows one shape — a perimeter around a
20
+ center — but the structure (takeoff → waypoints → return) generalizes to any spatial
21
+ patrol. The navigation math (projecting waypoints around a center) lives here,
22
+ visible and testable, not buried inside a semantic resolver.
23
+
24
+ This module has NO drone dependencies and NO hub dependencies. It is pure geometry
25
+ plus step construction, fully unit-testable on its own.
26
+
27
+ DESIGN NOTE — who plans the route: the brain (AI) or the nervous system (DoSync)?
28
+ This is a subtle but defining boundary, so it is worth stating explicitly.
29
+
30
+ The AI (the brain) decides the GOAL and its STRATEGIC PARAMETERS: *that* an area
31
+ should be inspected, the center, the radius, the altitude, the shape. None of that
32
+ is invented here — it all arrives as the intent and its context, authored by the
33
+ AI. This composer never decides whether to fly, where, or how high.
34
+
35
+ This composer (the nervous system / cerebellum) does only the DETERMINISTIC
36
+ TRANSLATION of that goal into exact motor geometry: given "perimeter, 1km, centered
37
+ here," there is exactly one correct set of corner coordinates. That is computation,
38
+ not decision — the same way the cerebellum turns "walk to the kitchen" into precise
39
+ joint kinematics without the conscious mind computing a single angle. That is why
40
+ compose_inspect_area RAISES if the center is missing: it refuses to invent a
41
+ decision that belongs to the brain.
42
+
43
+ Crucially, this composer is NOT mandatory. It is a translation SERVICE the AI uses
44
+ when it wants to delegate the geometry. An AI that prefers to plan every waypoint
45
+ itself — irregular paths, avoiding a zone, optimizing for wind — simply emits its
46
+ own go_to sequence directly and skips the composer entirely, exactly as a brain can
47
+ move a single limb deliberately instead of delegating the gait to the cerebellum.
48
+
49
+ In one line: the brain decides the goal and the parameters; the nervous system
50
+ translates that goal into exact motor geometry and keeps the loop closed during
51
+ execution. This file is translation, never autonomous planning.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import math
57
+
58
+ from .composite_operations import CompositeStep
59
+
60
+
61
+ # Mean Earth radius in meters (same constant used by the haversine helpers elsewhere,
62
+ # kept local so this module depends on nothing).
63
+ _EARTH_RADIUS_M = 6371000.0
64
+
65
+
66
+ def destination_point(lat: float, lon: float, bearing_deg: float,
67
+ distance_m: float) -> tuple[float, float]:
68
+ """Given a start point, a compass bearing, and a distance, return the
69
+ destination (lat, lon). This is the INVERSE of haversine (which measures the
70
+ distance between two known points); here we know one point, a direction, and a
71
+ distance, and we project the second point.
72
+
73
+ Standard great-circle (spherical) forward geodesic. Accurate to well within a
74
+ meter at the scales DoSync cares about (perimeters of a few km), and it has no
75
+ dependency on a projection library.
76
+
77
+ bearing_deg: 0 = North, 90 = East, 180 = South, 270 = West.
78
+ """
79
+ ang = distance_m / _EARTH_RADIUS_M # angular distance in radians
80
+ brg = math.radians(bearing_deg)
81
+ p1 = math.radians(lat)
82
+ l1 = math.radians(lon)
83
+
84
+ p2 = math.asin(
85
+ math.sin(p1) * math.cos(ang)
86
+ + math.cos(p1) * math.sin(ang) * math.cos(brg)
87
+ )
88
+ l2 = l1 + math.atan2(
89
+ math.sin(brg) * math.sin(ang) * math.cos(p1),
90
+ math.cos(ang) - math.sin(p1) * math.sin(p2),
91
+ )
92
+ # Normalize longitude to [-180, 180].
93
+ lon2 = (math.degrees(l2) + 540) % 360 - 180
94
+ return (math.degrees(p2), lon2)
95
+
96
+
97
+ def perimeter_waypoints(center_lat: float, center_lon: float,
98
+ radius_m: float, sides: int = 4,
99
+ start_bearing_deg: float = 0.0) -> list[tuple[float, float]]:
100
+ """Compute the vertices of a regular polygon (default: square) inscribed in a
101
+ circle of `radius_m` around the center. These are the corners the vehicle flies
102
+ to in order to patrol the perimeter.
103
+
104
+ sides=4 gives a square (the natural perimeter of a property); higher values
105
+ approximate a circular patrol. The vertices are returned in clockwise order
106
+ starting from `start_bearing_deg`.
107
+ """
108
+ if sides < 3:
109
+ raise ValueError("a perimeter needs at least 3 sides")
110
+ if radius_m <= 0:
111
+ raise ValueError("radius must be positive")
112
+ step = 360.0 / sides
113
+ return [
114
+ destination_point(center_lat, center_lon,
115
+ (start_bearing_deg + i * step) % 360, radius_m)
116
+ for i in range(sides)
117
+ ]
118
+
119
+
120
+ class RouteComposer:
121
+ """Composes an ordered CompositeStep sequence for a spatial composition intent.
122
+
123
+ Today it handles one intent shape — `inspect_area` — by building a perimeter
124
+ patrol around a center: take off, fly each perimeter vertex in order, return
125
+ home. The hub routes a composition intent here instead of to the flat resolver,
126
+ then wraps the returned steps in a CompositeOperation for the supervisor to drive.
127
+
128
+ Stateless and dependency-free: give it a device id and a context, get back steps.
129
+ """
130
+
131
+ # Sensible defaults; every value can be overridden via the intent context so a
132
+ # deployment configures its own patrol without code changes.
133
+ DEFAULT_RADIUS_M = 1000.0
134
+ DEFAULT_ALTITUDE_M = 30.0
135
+ DEFAULT_SIDES = 4
136
+
137
+ def compose_inspect_area(self, device_id: str, context: dict) -> list[CompositeStep]:
138
+ """Build the step sequence for an `inspect_area` intent.
139
+
140
+ Required context:
141
+ center: (lat, lon) — the patrol center. The panel's design: this is the
142
+ hub's own location (the system's "home"), so the
143
+ perimeter is centered on where the coordinating mind
144
+ physically sits.
145
+ Optional context:
146
+ radius_m, altitude_m, sides, start_bearing_deg — override the defaults.
147
+
148
+ Returns: take_off → go_to(each vertex) → return_home, as ordered CompositeSteps.
149
+ Raises ValueError if the center is missing — we never invent a location.
150
+ """
151
+ center = context.get("center")
152
+ if not center or len(center) != 2:
153
+ raise ValueError(
154
+ "inspect_area requires context['center'] = (lat, lon) — "
155
+ "the patrol center (the hub's location). We never invent a position."
156
+ )
157
+ center_lat, center_lon = float(center[0]), float(center[1])
158
+ radius_m = float(context.get("radius_m", self.DEFAULT_RADIUS_M))
159
+ altitude_m = float(context.get("altitude_m", self.DEFAULT_ALTITUDE_M))
160
+ sides = int(context.get("sides", self.DEFAULT_SIDES))
161
+ start_bearing = float(context.get("start_bearing_deg", 0.0))
162
+
163
+ vertices = perimeter_waypoints(center_lat, center_lon, radius_m,
164
+ sides=sides, start_bearing_deg=start_bearing)
165
+
166
+ steps: list[CompositeStep] = []
167
+
168
+ # 1. Take off to patrol altitude.
169
+ steps.append(CompositeStep(
170
+ device_id=device_id, action="take_off",
171
+ params={"alt": altitude_m}, kind="takeoff",
172
+ ))
173
+
174
+ # 2. Fly each perimeter vertex in order, at patrol altitude.
175
+ for i, (wlat, wlon) in enumerate(vertices):
176
+ steps.append(CompositeStep(
177
+ device_id=device_id, action="go_to",
178
+ params={"lat": wlat, "lon": wlon, "alt": altitude_m},
179
+ kind="waypoint",
180
+ ))
181
+
182
+ # 3. Return home and land. The panel: a patrol always ends back at base; the
183
+ # return is part of the operation, not optional.
184
+ steps.append(CompositeStep(
185
+ device_id=device_id, action="return_home",
186
+ params={}, kind="return",
187
+ ))
188
+
189
+ return steps