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/cli.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Console entry points for an installed DoSync.
|
|
2
|
+
|
|
3
|
+
`pip install dosync` should give you a hub you can actually run — not a library
|
|
4
|
+
you still have to wire up. This module is the thin layer that makes that true.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hub(argv=None) -> None:
|
|
12
|
+
"""Run the DoSync hub (console script: `dosync-hub`).
|
|
13
|
+
|
|
14
|
+
Deliberately thin: every real setting is already an environment variable
|
|
15
|
+
read by the application itself, because a deployment configures a hub
|
|
16
|
+
through its environment (systemd, Docker, compose), not through flags that
|
|
17
|
+
would then exist in two places and disagree. The flags below are only the
|
|
18
|
+
ones you need before the application starts — where to listen.
|
|
19
|
+
"""
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="dosync-hub",
|
|
22
|
+
description="Run the DoSync hub.",
|
|
23
|
+
epilog=(
|
|
24
|
+
"Configuration is by environment variable, e.g.:\n"
|
|
25
|
+
" DOSYNC_DB=/var/lib/dosync/dosync.db database path\n"
|
|
26
|
+
" DOSYNC_AUTH=true require bearer auth\n"
|
|
27
|
+
" DOSYNC_TOKEN=<token> the bearer token\n"
|
|
28
|
+
" DOSYNC_POLICIES=/etc/dosync/policies.json deployment policies\n"
|
|
29
|
+
" DOSYNC_CERTIFY=true certification mode (in-memory DB)\n"
|
|
30
|
+
"\nSee the deployment guide for the full list."
|
|
31
|
+
),
|
|
32
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("--host", default=os.environ.get("DOSYNC_HOST", "127.0.0.1"),
|
|
35
|
+
help="interface to bind (default: 127.0.0.1 — loopback. "
|
|
36
|
+
"Use 0.0.0.0 to accept connections from the network)")
|
|
37
|
+
parser.add_argument("--port", type=int, default=int(os.environ.get("DOSYNC_PORT", "47200")),
|
|
38
|
+
help="port to listen on (default: 47200)")
|
|
39
|
+
parser.add_argument("--reload", action="store_true",
|
|
40
|
+
help="auto-reload on code changes (development only)")
|
|
41
|
+
parser.add_argument("--log-level", default="info",
|
|
42
|
+
choices=["critical", "error", "warning", "info", "debug", "trace"])
|
|
43
|
+
args = parser.parse_args(argv)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
import uvicorn
|
|
47
|
+
except ImportError: # pragma: no cover - install-time problem, not runtime
|
|
48
|
+
sys.exit("uvicorn is required to run the hub: pip install 'dosync'")
|
|
49
|
+
|
|
50
|
+
# Publish the real values so the application can report them accurately: the
|
|
51
|
+
# startup log used to announce a hardcoded 47200 no matter where the hub was
|
|
52
|
+
# actually listening.
|
|
53
|
+
os.environ["DOSYNC_PORT"] = str(args.port)
|
|
54
|
+
os.environ["DOSYNC_HOST"] = args.host
|
|
55
|
+
|
|
56
|
+
uvicorn.run("dosync.server:app", host=args.host, port=args.port,
|
|
57
|
+
reload=args.reload, log_level=args.log_level)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__": # pragma: no cover
|
|
61
|
+
hub()
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Composite operations (a coordinated sequence of atomic operations).
|
|
3
|
+
============================================================================
|
|
4
|
+
|
|
5
|
+
`operations.py` models a SINGLE long-running action: one device, one action, one
|
|
6
|
+
state machine (a drone's `go_to`, an oven's `bake`). That is the ATOM.
|
|
7
|
+
|
|
8
|
+
This module models the MOLECULE: a high-level goal that decomposes into an ORDERED
|
|
9
|
+
SEQUENCE of atomic operations, coordinated continuously by the hub. "Inspect the
|
|
10
|
+
area" is not one movement — it is take_off, then go_to waypoint 1, then 2, 3, 4,
|
|
11
|
+
then return_home, then land. A brain directing a body to walk to a point is not one
|
|
12
|
+
instruction; it is a continuous stream of coordinated sub-actions, each one shaped
|
|
13
|
+
by what the previous one actually produced.
|
|
14
|
+
|
|
15
|
+
DESIGN (validated by a multi-pass expert panel incl. a drone manufacturer, a pilot,
|
|
16
|
+
and two systems professors):
|
|
17
|
+
|
|
18
|
+
* This is a SEPARATE TYPE from Operation, not an extension of it. The panel
|
|
19
|
+
rejected stuffing a waypoint list into the atomic Operation: an atom is
|
|
20
|
+
deliberately atomic (one device, one action), and a composite has a state
|
|
21
|
+
machine of a DIFFERENT LEVEL — `planning -> in_transit -> returning ->
|
|
22
|
+
completed/aborted` — with concepts (returning, abort-the-whole-sequence) that
|
|
23
|
+
only make sense for a SET, never for a single atom. Two genuinely different
|
|
24
|
+
state machines justify two types; it is not duplication.
|
|
25
|
+
|
|
26
|
+
* GENERIC, not drone-specific. The panel was explicit: `inspect_area` is the
|
|
27
|
+
INTENT (drone domain); the structure that executes it is generic. A robotic arm
|
|
28
|
+
(reach, grip, move, release), a 3D printer (preheat, print, cool), a rover —
|
|
29
|
+
all are coordinated sequences of atomic operations. The name belongs to the
|
|
30
|
+
abstraction level, not the domain. So: CompositeOperation, not Mission.
|
|
31
|
+
|
|
32
|
+
* The atom stays PURE. The atomic Operation and its reconciler are untouched.
|
|
33
|
+
A CompositeOperation REFERENCES its sub-operations by their operation_id (for
|
|
34
|
+
hierarchical audit-log traceability: "composite started -> [waypoint 1: op_abc
|
|
35
|
+
completed] -> ... -> composite completed"), but never reaches inside their state
|
|
36
|
+
machine. It coordinates; the atom executes.
|
|
37
|
+
|
|
38
|
+
* It REUSES the carpentry of operations.py (the StateTransition record, the
|
|
39
|
+
to_dict/from_dict discipline that faithfully preserves time_in_state) but has
|
|
40
|
+
its OWN, simpler, mission-level state machine. Same tools, different machine.
|
|
41
|
+
|
|
42
|
+
* SILENCE IS NOT SUCCESS — inherited. A composite only advances when its current
|
|
43
|
+
sub-operation produces a positive signal (the atom reaches `completed`). It
|
|
44
|
+
never advances on a timer. This is the whole point of the closed loop: the
|
|
45
|
+
brain reacts to what the body actually did, waypoint by waypoint.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import time
|
|
51
|
+
import uuid
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
from enum import Enum
|
|
54
|
+
|
|
55
|
+
from .operations import StateTransition # reuse the atomic transition record
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CompositeState(str, Enum):
|
|
59
|
+
"""Mission-level lifecycle states. Deliberately distinct from (and simpler than)
|
|
60
|
+
the atomic OperationState — these describe a SEQUENCE, not a single action.
|
|
61
|
+
|
|
62
|
+
Inherits from str so values serialize cleanly to JSON / SQLite, consistent with
|
|
63
|
+
the rest of DoSync's enums.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
PLANNING = "planning" # composed, sub-operations not yet started
|
|
67
|
+
IN_TRANSIT = "in_transit" # executing the ordered steps (the main phase)
|
|
68
|
+
RETURNING = "returning" # executing the final return-to-base step
|
|
69
|
+
COMPLETED = "completed" # all steps finished successfully (positive signal)
|
|
70
|
+
ABORTED = "aborted" # stopped early — a guard fired, or a step failed.
|
|
71
|
+
# On abort, remaining steps are cancelled and the
|
|
72
|
+
# safe default (return home) is what RETURNING is
|
|
73
|
+
# for; a composite that cannot even return goes
|
|
74
|
+
# straight to ABORTED.
|
|
75
|
+
FAILED = "failed" # could not complete and could not safely return
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# States in which a composite is finished and will not transition further.
|
|
79
|
+
COMPOSITE_TERMINAL_STATES: frozenset[CompositeState] = frozenset({
|
|
80
|
+
CompositeState.COMPLETED,
|
|
81
|
+
CompositeState.ABORTED,
|
|
82
|
+
CompositeState.FAILED,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
# Legal mission-level transitions.
|
|
86
|
+
# planning -> in_transit (start) | aborted (cancelled before takeoff) | failed
|
|
87
|
+
# in_transit -> returning (steps done, head home) | aborted (guard/failure mid-run)
|
|
88
|
+
# | failed (cannot continue nor return)
|
|
89
|
+
# returning -> completed (home reached) | failed (could not get home)
|
|
90
|
+
# terminals: no outgoing transitions
|
|
91
|
+
_ALLOWED_COMPOSITE_TRANSITIONS: dict[CompositeState, frozenset[CompositeState]] = {
|
|
92
|
+
CompositeState.PLANNING: frozenset({
|
|
93
|
+
CompositeState.IN_TRANSIT,
|
|
94
|
+
CompositeState.ABORTED,
|
|
95
|
+
CompositeState.FAILED,
|
|
96
|
+
}),
|
|
97
|
+
CompositeState.IN_TRANSIT: frozenset({
|
|
98
|
+
CompositeState.RETURNING,
|
|
99
|
+
CompositeState.ABORTED,
|
|
100
|
+
CompositeState.FAILED,
|
|
101
|
+
}),
|
|
102
|
+
CompositeState.RETURNING: frozenset({
|
|
103
|
+
CompositeState.COMPLETED,
|
|
104
|
+
CompositeState.ABORTED, # return interrupted (e.g. human took control)
|
|
105
|
+
CompositeState.FAILED,
|
|
106
|
+
}),
|
|
107
|
+
CompositeState.COMPLETED: frozenset(),
|
|
108
|
+
CompositeState.ABORTED: frozenset(),
|
|
109
|
+
CompositeState.FAILED: frozenset(),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class InvalidCompositeTransition(Exception):
|
|
114
|
+
"""Raised when a mission-level state transition is not permitted."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class CompositeStep:
|
|
119
|
+
"""One step in a composite: a single atomic action to dispatch, plus the
|
|
120
|
+
operation_id of the atomic Operation once it is created (for hierarchical
|
|
121
|
+
traceability). `kind` labels the step's role in the sequence so the supervisor
|
|
122
|
+
and the audit log can reason about it without parsing params.
|
|
123
|
+
"""
|
|
124
|
+
device_id: str
|
|
125
|
+
action: str # e.g. "take_off", "go_to", "return_home"
|
|
126
|
+
params: dict = field(default_factory=dict)
|
|
127
|
+
kind: str = "step" # "takeoff" | "waypoint" | "return" | "step"
|
|
128
|
+
operation_id: str | None = None # set when the atomic op is created
|
|
129
|
+
done: bool = False # set when this step reaches a terminal atom state
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict:
|
|
132
|
+
return {
|
|
133
|
+
"device_id": self.device_id,
|
|
134
|
+
"action": self.action,
|
|
135
|
+
"params": self.params,
|
|
136
|
+
"kind": self.kind,
|
|
137
|
+
"operation_id": self.operation_id,
|
|
138
|
+
"done": self.done,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_dict(cls, d: dict) -> "CompositeStep":
|
|
143
|
+
return cls(
|
|
144
|
+
device_id=d["device_id"],
|
|
145
|
+
action=d["action"],
|
|
146
|
+
params=d.get("params", {}),
|
|
147
|
+
kind=d.get("kind", "step"),
|
|
148
|
+
operation_id=d.get("operation_id"),
|
|
149
|
+
done=d.get("done", False),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class CompositeOperation:
|
|
155
|
+
"""A high-level operation coordinating an ordered sequence of atomic operations.
|
|
156
|
+
|
|
157
|
+
The hub creates one when a composing intent (e.g. inspect_area) resolves into a
|
|
158
|
+
multi-step plan. The supervisor (separate component) drives it forward: it
|
|
159
|
+
dispatches the current step, waits for that step's atomic operation to reach a
|
|
160
|
+
positive terminal state, advances the index, and repeats — reacting in real time
|
|
161
|
+
to telemetry, never on a timer.
|
|
162
|
+
"""
|
|
163
|
+
device_id: str # the primary device (e.g. the drone)
|
|
164
|
+
intent: str # the originating intent (e.g. "inspect_area")
|
|
165
|
+
steps: list[CompositeStep]
|
|
166
|
+
composite_id: str = field(default_factory=lambda: f"comp_{uuid.uuid4().hex[:12]}")
|
|
167
|
+
state: CompositeState = CompositeState.PLANNING
|
|
168
|
+
current_index: int = 0 # which step is active
|
|
169
|
+
created_at: float = field(default_factory=time.time)
|
|
170
|
+
state_entered_at: float = field(default_factory=time.time)
|
|
171
|
+
history: list[StateTransition] = field(default_factory=list)
|
|
172
|
+
context: dict = field(default_factory=dict) # e.g. geofence center/radius, altitude
|
|
173
|
+
|
|
174
|
+
def __post_init__(self):
|
|
175
|
+
if isinstance(self.state, str):
|
|
176
|
+
self.state = CompositeState(self.state)
|
|
177
|
+
if not self.history:
|
|
178
|
+
# Reuse the atomic StateTransition record; from_state None = initial entry.
|
|
179
|
+
self.history.append(StateTransition(
|
|
180
|
+
from_state=None, to_state=self.state, # type: ignore[arg-type]
|
|
181
|
+
at=self.created_at, reason="composed",
|
|
182
|
+
))
|
|
183
|
+
|
|
184
|
+
# ── State ────────────────────────────────────────────────────────────────
|
|
185
|
+
@property
|
|
186
|
+
def is_terminal(self) -> bool:
|
|
187
|
+
return self.state in COMPOSITE_TERMINAL_STATES
|
|
188
|
+
|
|
189
|
+
def time_in_state(self, now: float | None = None) -> float:
|
|
190
|
+
"""Seconds spent in the current mission-level state. First-class data for a
|
|
191
|
+
guard/supervisor to reason about (this module never acts on it itself)."""
|
|
192
|
+
return (now if now is not None else time.time()) - self.state_entered_at
|
|
193
|
+
|
|
194
|
+
def can_transition_to(self, target: CompositeState) -> bool:
|
|
195
|
+
if isinstance(target, str):
|
|
196
|
+
target = CompositeState(target)
|
|
197
|
+
return target in _ALLOWED_COMPOSITE_TRANSITIONS.get(self.state, frozenset())
|
|
198
|
+
|
|
199
|
+
def transition_to(self, target: CompositeState, reason: str = "",
|
|
200
|
+
now: float | None = None) -> StateTransition:
|
|
201
|
+
"""Advance the mission-level state, recording the transition. Raises
|
|
202
|
+
InvalidCompositeTransition on an illegal move — a finished mission does not
|
|
203
|
+
resume, and an illegal jump is never silently accepted."""
|
|
204
|
+
if isinstance(target, str):
|
|
205
|
+
target = CompositeState(target)
|
|
206
|
+
ts = now if now is not None else time.time()
|
|
207
|
+
|
|
208
|
+
if self.is_terminal:
|
|
209
|
+
raise InvalidCompositeTransition(
|
|
210
|
+
f"composite {self.composite_id} is terminal ({self.state.value}); "
|
|
211
|
+
f"cannot transition to {target.value}."
|
|
212
|
+
)
|
|
213
|
+
if target not in _ALLOWED_COMPOSITE_TRANSITIONS.get(self.state, frozenset()):
|
|
214
|
+
raise InvalidCompositeTransition(
|
|
215
|
+
f"illegal composite transition {self.state.value} -> {target.value} "
|
|
216
|
+
f"for {self.composite_id}."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
transition = StateTransition(from_state=self.state, to_state=target, # type: ignore[arg-type]
|
|
220
|
+
at=ts, reason=reason)
|
|
221
|
+
self.history.append(transition)
|
|
222
|
+
self.state = target
|
|
223
|
+
self.state_entered_at = ts
|
|
224
|
+
return transition
|
|
225
|
+
|
|
226
|
+
# ── Step coordination (the supervisor uses these; they hold no policy) ─────
|
|
227
|
+
@property
|
|
228
|
+
def current_step(self) -> CompositeStep | None:
|
|
229
|
+
"""The step currently being executed, or None if the index is past the end."""
|
|
230
|
+
if 0 <= self.current_index < len(self.steps):
|
|
231
|
+
return self.steps[self.current_index]
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def remaining_steps(self) -> list[CompositeStep]:
|
|
236
|
+
"""Steps not yet started (from current_index onward). Used on abort to know
|
|
237
|
+
what must be cancelled."""
|
|
238
|
+
return self.steps[self.current_index:]
|
|
239
|
+
|
|
240
|
+
def advance(self) -> CompositeStep | None:
|
|
241
|
+
"""Mark the current step done and move to the next. Returns the new current
|
|
242
|
+
step, or None if the sequence is exhausted. Pure bookkeeping — the supervisor
|
|
243
|
+
decides WHETHER to advance (only on a positive signal); this just moves the
|
|
244
|
+
cursor."""
|
|
245
|
+
step = self.current_step
|
|
246
|
+
if step is not None:
|
|
247
|
+
step.done = True
|
|
248
|
+
self.current_index += 1
|
|
249
|
+
return self.current_step
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def all_steps_done(self) -> bool:
|
|
253
|
+
return all(s.done for s in self.steps) if self.steps else True
|
|
254
|
+
|
|
255
|
+
# ── Persistence (reuses the to_dict/from_dict discipline of operations.py) ──
|
|
256
|
+
def to_dict(self) -> dict:
|
|
257
|
+
return {
|
|
258
|
+
"composite_id": self.composite_id,
|
|
259
|
+
"device_id": self.device_id,
|
|
260
|
+
"intent": self.intent,
|
|
261
|
+
"state": self.state.value,
|
|
262
|
+
"current_index": self.current_index,
|
|
263
|
+
"created_at": self.created_at,
|
|
264
|
+
"state_entered_at": self.state_entered_at,
|
|
265
|
+
"context": self.context,
|
|
266
|
+
"steps": [s.to_dict() for s in self.steps],
|
|
267
|
+
"history": [
|
|
268
|
+
{
|
|
269
|
+
"from_state": t.from_state.value if t.from_state else None,
|
|
270
|
+
"to_state": t.to_state.value,
|
|
271
|
+
"at": t.at,
|
|
272
|
+
"reason": t.reason,
|
|
273
|
+
}
|
|
274
|
+
for t in self.history
|
|
275
|
+
],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
def from_dict(cls, data: dict) -> "CompositeOperation":
|
|
280
|
+
"""Rehydrate from a persisted dict — the inverse of to_dict(). Fidelity
|
|
281
|
+
matters: restore the real state, the real state_entered_at, and the full
|
|
282
|
+
history, exactly as operations.py does for the atom. A naive constructor call
|
|
283
|
+
would let __post_init__ synthesize a fresh history and reset state_entered_at
|
|
284
|
+
to now, destroying time_in_state — the signal a guard relies on to spot a
|
|
285
|
+
stalled mission. So build, then overwrite with the stored values."""
|
|
286
|
+
comp = cls(
|
|
287
|
+
device_id=data["device_id"],
|
|
288
|
+
intent=data["intent"],
|
|
289
|
+
steps=[CompositeStep.from_dict(s) for s in data.get("steps", [])],
|
|
290
|
+
composite_id=data["composite_id"],
|
|
291
|
+
state=CompositeState(data["state"]),
|
|
292
|
+
current_index=data.get("current_index", 0),
|
|
293
|
+
created_at=data["created_at"],
|
|
294
|
+
context=data.get("context", {}),
|
|
295
|
+
)
|
|
296
|
+
comp.state_entered_at = data["state_entered_at"]
|
|
297
|
+
comp.history = [
|
|
298
|
+
StateTransition(
|
|
299
|
+
from_state=CompositeState(h["from_state"]) if h["from_state"] else None, # type: ignore[arg-type]
|
|
300
|
+
to_state=CompositeState(h["to_state"]), # type: ignore[arg-type]
|
|
301
|
+
at=h["at"],
|
|
302
|
+
reason=h.get("reason", ""),
|
|
303
|
+
)
|
|
304
|
+
for h in data.get("history", [])
|
|
305
|
+
]
|
|
306
|
+
return comp
|