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/policy_config.py
ADDED
|
@@ -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")
|
dosync/route_composer.py
ADDED
|
@@ -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
|