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/operations.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Long-running operation lifecycle.
|
|
3
|
+
==========================================
|
|
4
|
+
|
|
5
|
+
Most DoSync actions are instantaneous: send the command, get a result, done.
|
|
6
|
+
That is the `instant` execution model and it is unchanged by this module.
|
|
7
|
+
|
|
8
|
+
Some actions take time and pass through states while they run — "fly to this
|
|
9
|
+
waypoint" arms, takes off, navigates, and only then arrives. A network timeout
|
|
10
|
+
is not the same as a failure; a pilot grabbing the sticks is not a failure at
|
|
11
|
+
all. This module models that lifecycle. It is the foundation the drone adapter
|
|
12
|
+
will build on, but it knows nothing about drones, MAVLink, or any hardware —
|
|
13
|
+
it is pure state-machine logic, fully unit-testable on its own.
|
|
14
|
+
|
|
15
|
+
DESIGN (validated by a two-pass expert panel incl. a drone manufacturer and a
|
|
16
|
+
real pilot):
|
|
17
|
+
|
|
18
|
+
* The HUB owns operation state, not the device. The device reports raw facts
|
|
19
|
+
("arrived", "failed", "pilot took control"); the hub models the lifecycle.
|
|
20
|
+
The device remains the source of truth for its *real* state — the hub keeps
|
|
21
|
+
a replica reconciled against telemetry and never assumes. (Reconciliation
|
|
22
|
+
itself lives in a later layer; this module defines the states and the legal
|
|
23
|
+
transitions between them.)
|
|
24
|
+
|
|
25
|
+
* The state machine is HIERARCHICAL, not flat:
|
|
26
|
+
- A minimal CORE every long-running device satisfies:
|
|
27
|
+
pending -> in_progress -> completed | failed
|
|
28
|
+
- First-class added states (always available):
|
|
29
|
+
rejected — the device refused by its physical state (not armed,
|
|
30
|
+
no GPS, failsafe active). "Can't even start" — distinct
|
|
31
|
+
from failing partway through.
|
|
32
|
+
cancelled — explicit cancellation (only if supports_cancel).
|
|
33
|
+
interrupted — human intervention (a pilot takes the sticks). NOT a
|
|
34
|
+
failure: a normal, expected outcome. Does NOT resume —
|
|
35
|
+
if the system retakes control it is a NEW operation.
|
|
36
|
+
- Optional SUB-STATES, only meaningful for richer profiles:
|
|
37
|
+
preparing — a pre-action setup phase, made visible instead of
|
|
38
|
+
hidden inside an opaque in_progress. Universal: a
|
|
39
|
+
camera focusing, a blind releasing its lock, an
|
|
40
|
+
aerial vehicle arming/taking off. The device-
|
|
41
|
+
specific sub-phase is carried in Operation.phase
|
|
42
|
+
(the protocol understands `preparing`; a domain
|
|
43
|
+
profile specializes it).
|
|
44
|
+
paused_by_device — the device paused *itself*. Universal: a speaker
|
|
45
|
+
whose stream dropped, a camera whose storage filled,
|
|
46
|
+
an aerial vehicle holding position in wind. Neither
|
|
47
|
+
failed, nor interrupted, nor advancing.
|
|
48
|
+
reconciling — transient state after a hub restart: the hub
|
|
49
|
+
reconciles against telemetry before assuming
|
|
50
|
+
anything. Generic; its criticality varies by domain.
|
|
51
|
+
A simple device (an oven) only ever sees the core. A speaker adds cancellation;
|
|
52
|
+
a camera adds preparing; an aerial vehicle uses all of it. Each device declares
|
|
53
|
+
how rich it is — the names belong to the protocol, the domain detail to `phase`.
|
|
54
|
+
|
|
55
|
+
* SILENCE IS NOT SUCCESS. Reaching `completed` requires a positive signal, never
|
|
56
|
+
the mere absence of an error or a timeout. This is the opposite of the instant
|
|
57
|
+
model and is non-negotiable where the error is physical.
|
|
58
|
+
|
|
59
|
+
* Time-in-state is first-class data: every state records when it was entered,
|
|
60
|
+
so a Policy Engine (separate component) can decide what to do about a state
|
|
61
|
+
that has lasted too long. This module exposes the timing; it does NOT decide
|
|
62
|
+
policy.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import time
|
|
68
|
+
import uuid
|
|
69
|
+
from dataclasses import dataclass, field
|
|
70
|
+
from enum import Enum
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class OperationState(str, Enum):
|
|
74
|
+
"""Lifecycle states of a long-running operation.
|
|
75
|
+
|
|
76
|
+
Inherits from str so values serialize cleanly to JSON / SQLite and compare
|
|
77
|
+
against plain strings, consistent with the rest of DoSync's enums.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# ── Core (every long_running device) ──────────────────────────────────────
|
|
81
|
+
PENDING = "pending" # accepted by the hub, not yet confirmed started
|
|
82
|
+
IN_PROGRESS = "in_progress" # confirmed underway (positive signal received)
|
|
83
|
+
COMPLETED = "completed" # confirmed finished successfully (positive signal)
|
|
84
|
+
FAILED = "failed" # failed partway through
|
|
85
|
+
|
|
86
|
+
# ── First-class added states (always available) ──────────────────────────
|
|
87
|
+
REJECTED = "rejected" # device refused by physical state — never started
|
|
88
|
+
CANCELLED = "cancelled" # explicit cancellation
|
|
89
|
+
INTERRUPTED = "interrupted" # human intervention — normal outcome, does not resume
|
|
90
|
+
|
|
91
|
+
# ── Optional sub-states (richer profiles only) ────────────────────────────
|
|
92
|
+
PREPARING = "preparing" # pre-action setup phase (camera focusing, blind
|
|
93
|
+
# releasing its lock, a drone arming/taking off).
|
|
94
|
+
# Device-specific sub-phase carried in Operation.phase.
|
|
95
|
+
PAUSED_BY_DEVICE = "paused_by_device" # the device paused itself (speaker lost its
|
|
96
|
+
# stream, camera storage full, drone holding in wind)
|
|
97
|
+
RECONCILING = "reconciling" # transient: hub reconciling against telemetry after restart
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# States in which an operation is finished and will not transition further.
|
|
101
|
+
TERMINAL_STATES: frozenset[OperationState] = frozenset({
|
|
102
|
+
OperationState.COMPLETED,
|
|
103
|
+
OperationState.FAILED,
|
|
104
|
+
OperationState.REJECTED,
|
|
105
|
+
OperationState.CANCELLED,
|
|
106
|
+
OperationState.INTERRUPTED,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
# Sub-states that only apply to telemetry-capable profiles. A core-only device
|
|
110
|
+
# (declares long_running without emits_telemetry) must never be driven into one.
|
|
111
|
+
TELEMETRY_ONLY_STATES: frozenset[OperationState] = frozenset({
|
|
112
|
+
OperationState.PREPARING,
|
|
113
|
+
OperationState.PAUSED_BY_DEVICE,
|
|
114
|
+
OperationState.RECONCILING,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
# Legal transitions. Key = from-state, value = set of allowed next states.
|
|
118
|
+
# Encodes the panel's rules:
|
|
119
|
+
# - core flow pending -> in_progress -> completed/failed
|
|
120
|
+
# - rejected only from pending (can't even start)
|
|
121
|
+
# - interrupted/failed/cancelled reachable from any active (non-terminal) state
|
|
122
|
+
# - terminal states have no outgoing transitions (interrupted does NOT resume)
|
|
123
|
+
# - sub-state (preparing) sits between pending and in_progress
|
|
124
|
+
# - paused_by_device is reachable from active states and can resume
|
|
125
|
+
# - reconciling can reach any non-pending outcome once telemetry clarifies
|
|
126
|
+
_ALLOWED_TRANSITIONS: dict[OperationState, frozenset[OperationState]] = {
|
|
127
|
+
OperationState.PENDING: frozenset({
|
|
128
|
+
OperationState.PREPARING,
|
|
129
|
+
OperationState.IN_PROGRESS,
|
|
130
|
+
OperationState.REJECTED,
|
|
131
|
+
OperationState.CANCELLED,
|
|
132
|
+
OperationState.INTERRUPTED,
|
|
133
|
+
OperationState.FAILED,
|
|
134
|
+
OperationState.RECONCILING, # recovered on restart while pending
|
|
135
|
+
}),
|
|
136
|
+
OperationState.PREPARING: frozenset({
|
|
137
|
+
OperationState.IN_PROGRESS,
|
|
138
|
+
OperationState.FAILED,
|
|
139
|
+
OperationState.CANCELLED,
|
|
140
|
+
OperationState.INTERRUPTED,
|
|
141
|
+
OperationState.REJECTED,
|
|
142
|
+
OperationState.RECONCILING, # recovered on restart while preparing
|
|
143
|
+
}),
|
|
144
|
+
OperationState.IN_PROGRESS: frozenset({
|
|
145
|
+
OperationState.COMPLETED,
|
|
146
|
+
OperationState.FAILED,
|
|
147
|
+
OperationState.CANCELLED,
|
|
148
|
+
OperationState.INTERRUPTED,
|
|
149
|
+
OperationState.PAUSED_BY_DEVICE,
|
|
150
|
+
OperationState.RECONCILING, # recovered on restart while in progress
|
|
151
|
+
}),
|
|
152
|
+
OperationState.PAUSED_BY_DEVICE: frozenset({
|
|
153
|
+
OperationState.IN_PROGRESS, # device resumes on its own (wind eased, stream back)
|
|
154
|
+
OperationState.FAILED,
|
|
155
|
+
OperationState.CANCELLED,
|
|
156
|
+
OperationState.INTERRUPTED,
|
|
157
|
+
OperationState.RECONCILING, # recovered on restart while paused
|
|
158
|
+
}),
|
|
159
|
+
OperationState.RECONCILING: frozenset({
|
|
160
|
+
OperationState.IN_PROGRESS,
|
|
161
|
+
OperationState.PAUSED_BY_DEVICE,
|
|
162
|
+
OperationState.COMPLETED,
|
|
163
|
+
OperationState.FAILED,
|
|
164
|
+
OperationState.INTERRUPTED,
|
|
165
|
+
OperationState.CANCELLED,
|
|
166
|
+
}),
|
|
167
|
+
# Terminal states: no outgoing transitions.
|
|
168
|
+
OperationState.COMPLETED: frozenset(),
|
|
169
|
+
OperationState.FAILED: frozenset(),
|
|
170
|
+
OperationState.REJECTED: frozenset(),
|
|
171
|
+
OperationState.CANCELLED: frozenset(),
|
|
172
|
+
OperationState.INTERRUPTED: frozenset(),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class InvalidTransition(Exception):
|
|
177
|
+
"""Raised when a state transition is not permitted by the state machine."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class StateTransition:
|
|
182
|
+
"""A single recorded transition — the audit trail of an operation's life."""
|
|
183
|
+
from_state: OperationState | None # None for the initial entry into PENDING
|
|
184
|
+
to_state: OperationState
|
|
185
|
+
at: float # unix timestamp
|
|
186
|
+
reason: str = "" # human-readable cause (e.g. "telemetry: arrived")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class Operation:
|
|
191
|
+
"""A long-running operation tracked by the hub.
|
|
192
|
+
|
|
193
|
+
The hub creates one of these when a long_running action begins, then advances
|
|
194
|
+
it through the state machine as positive signals arrive. The device is the
|
|
195
|
+
source of truth; this object is the hub's reconciled replica.
|
|
196
|
+
"""
|
|
197
|
+
device_id: str
|
|
198
|
+
action: str
|
|
199
|
+
operation_id: str = field(default_factory=lambda: f"op_{uuid.uuid4().hex[:12]}")
|
|
200
|
+
state: OperationState = OperationState.PENDING
|
|
201
|
+
created_at: float = field(default_factory=time.time)
|
|
202
|
+
state_entered_at: float = field(default_factory=time.time)
|
|
203
|
+
history: list[StateTransition] = field(default_factory=list)
|
|
204
|
+
# Whether the device backing this operation emits telemetry. Gates the
|
|
205
|
+
# richer sub-states so a core-only device can't be driven into them.
|
|
206
|
+
telemetry_capable: bool = False
|
|
207
|
+
# Optional domain-specific sub-phase detail, meaningful while in PREPARING.
|
|
208
|
+
# The protocol understands the generic PREPARING state; a domain profile can
|
|
209
|
+
# specialize it — e.g. an aerial vehicle sets phase="arming" then "taking_off",
|
|
210
|
+
# a camera "focusing". The protocol never interprets this string; the device
|
|
211
|
+
# and its operator do. Keeps the core universal while preserving granularity.
|
|
212
|
+
phase: str = ""
|
|
213
|
+
|
|
214
|
+
def __post_init__(self):
|
|
215
|
+
if isinstance(self.state, str):
|
|
216
|
+
self.state = OperationState(self.state)
|
|
217
|
+
# Record the initial entry into the starting state.
|
|
218
|
+
if not self.history:
|
|
219
|
+
self.history.append(StateTransition(
|
|
220
|
+
from_state=None, to_state=self.state,
|
|
221
|
+
at=self.created_at, reason="created",
|
|
222
|
+
))
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def is_terminal(self) -> bool:
|
|
226
|
+
return self.state in TERMINAL_STATES
|
|
227
|
+
|
|
228
|
+
def time_in_state(self, now: float | None = None) -> float:
|
|
229
|
+
"""Seconds spent in the current state. First-class data for the Policy
|
|
230
|
+
Engine to reason about (this module never acts on it itself)."""
|
|
231
|
+
return (now if now is not None else time.time()) - self.state_entered_at
|
|
232
|
+
|
|
233
|
+
def can_transition_to(self, target: OperationState) -> bool:
|
|
234
|
+
"""Whether `target` is a legal next state from the current one, honoring
|
|
235
|
+
both the transition table and the telemetry gate."""
|
|
236
|
+
if isinstance(target, str):
|
|
237
|
+
target = OperationState(target)
|
|
238
|
+
if target in TELEMETRY_ONLY_STATES and not self.telemetry_capable:
|
|
239
|
+
return False
|
|
240
|
+
return target in _ALLOWED_TRANSITIONS.get(self.state, frozenset())
|
|
241
|
+
|
|
242
|
+
def transition_to(self, target: OperationState, reason: str = "",
|
|
243
|
+
now: float | None = None) -> "StateTransition":
|
|
244
|
+
"""Advance to `target`, recording the transition. Raises InvalidTransition
|
|
245
|
+
if the move is not permitted — silence is never success, and an illegal
|
|
246
|
+
jump is never silently accepted."""
|
|
247
|
+
if isinstance(target, str):
|
|
248
|
+
target = OperationState(target)
|
|
249
|
+
ts = now if now is not None else time.time()
|
|
250
|
+
|
|
251
|
+
if self.is_terminal:
|
|
252
|
+
raise InvalidTransition(
|
|
253
|
+
f"operation {self.operation_id} is terminal ({self.state.value}); "
|
|
254
|
+
f"cannot transition to {target.value}. "
|
|
255
|
+
f"(An interrupted/finished operation does not resume — start a new one.)"
|
|
256
|
+
)
|
|
257
|
+
if target in TELEMETRY_ONLY_STATES and not self.telemetry_capable:
|
|
258
|
+
raise InvalidTransition(
|
|
259
|
+
f"state {target.value} requires a telemetry-capable device; "
|
|
260
|
+
f"operation {self.operation_id} backs a core-only device."
|
|
261
|
+
)
|
|
262
|
+
if target not in _ALLOWED_TRANSITIONS.get(self.state, frozenset()):
|
|
263
|
+
raise InvalidTransition(
|
|
264
|
+
f"illegal transition {self.state.value} -> {target.value} "
|
|
265
|
+
f"for operation {self.operation_id}."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
transition = StateTransition(from_state=self.state, to_state=target,
|
|
269
|
+
at=ts, reason=reason)
|
|
270
|
+
self.history.append(transition)
|
|
271
|
+
self.state = target
|
|
272
|
+
self.state_entered_at = ts
|
|
273
|
+
return transition
|
|
274
|
+
|
|
275
|
+
def to_dict(self) -> dict:
|
|
276
|
+
"""Serialize for persistence / API. Mirrors the project's dataclass style."""
|
|
277
|
+
return {
|
|
278
|
+
"operation_id": self.operation_id,
|
|
279
|
+
"device_id": self.device_id,
|
|
280
|
+
"action": self.action,
|
|
281
|
+
"state": self.state.value,
|
|
282
|
+
"created_at": self.created_at,
|
|
283
|
+
"state_entered_at": self.state_entered_at,
|
|
284
|
+
"telemetry_capable": self.telemetry_capable,
|
|
285
|
+
"phase": self.phase,
|
|
286
|
+
"history": [
|
|
287
|
+
{
|
|
288
|
+
"from_state": t.from_state.value if t.from_state else None,
|
|
289
|
+
"to_state": t.to_state.value,
|
|
290
|
+
"at": t.at,
|
|
291
|
+
"reason": t.reason,
|
|
292
|
+
}
|
|
293
|
+
for t in self.history
|
|
294
|
+
],
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def from_dict(cls, data: dict) -> "Operation":
|
|
299
|
+
"""Rehydrate an Operation from its persisted dict — the inverse of
|
|
300
|
+
to_dict(). Used when telemetry arrives for an operation that lives in the
|
|
301
|
+
DB and must be reconciled against.
|
|
302
|
+
|
|
303
|
+
Fidelity matters here: we restore the real state, the real
|
|
304
|
+
state_entered_at, and the full transition history. A naive
|
|
305
|
+
Operation(state=...) would let __post_init__ synthesize a fresh history
|
|
306
|
+
and reset state_entered_at to *now*, silently destroying time_in_state —
|
|
307
|
+
the very signal the Policy Engine relies on to spot a stalled operation.
|
|
308
|
+
So we build the object, then overwrite those fields with the stored ones.
|
|
309
|
+
"""
|
|
310
|
+
op = cls(
|
|
311
|
+
device_id=data["device_id"],
|
|
312
|
+
action=data["action"],
|
|
313
|
+
operation_id=data["operation_id"],
|
|
314
|
+
state=OperationState(data["state"]),
|
|
315
|
+
created_at=data["created_at"],
|
|
316
|
+
telemetry_capable=data.get("telemetry_capable", False),
|
|
317
|
+
phase=data.get("phase", ""),
|
|
318
|
+
)
|
|
319
|
+
# Restore the true timing and history (the constructor reset/synthesized
|
|
320
|
+
# these — replace them with what actually happened).
|
|
321
|
+
op.state_entered_at = data["state_entered_at"]
|
|
322
|
+
op.history = [
|
|
323
|
+
StateTransition(
|
|
324
|
+
from_state=OperationState(h["from_state"]) if h["from_state"] else None,
|
|
325
|
+
to_state=OperationState(h["to_state"]),
|
|
326
|
+
at=h["at"],
|
|
327
|
+
reason=h.get("reason", ""),
|
|
328
|
+
)
|
|
329
|
+
for h in data.get("history", [])
|
|
330
|
+
]
|
|
331
|
+
return op
|