koebelt-dash-scripts 1.3.0b1__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,17 @@
1
+ """koebelt-dash-scripts: control devices connected in koebelt/dash from Python.
2
+
3
+ from koebelt_dash_scripts import connect, every
4
+ dev = connect()
5
+ for _ in every(0.05):
6
+ dev.sticks(throttle=0.3)
7
+ """
8
+
9
+ __version__ = "1.3.0b1"
10
+
11
+ from .camera import Camera, CameraError, decode_jpeg # noqa: E402
12
+ from .client import Device, Released, ScriptError, Setting, Telemetry, connect, every, wait # noqa: E402
13
+ from .tools import angle_diff, bearing, clamp, distance, geo_bearing, geo_distance, lowpass, pid, rate_limit # noqa: E402
14
+
15
+ __all__ = ["connect", "every", "wait", "Device", "Telemetry", "Setting", "ScriptError", "Released", "Camera", "CameraError", "decode_jpeg",
16
+ "pid", "lowpass", "rate_limit", "clamp", "distance", "bearing", "angle_diff", "geo_distance", "geo_bearing",
17
+ "__version__"]
@@ -0,0 +1,4 @@
1
+ """python -m koebelt_dash_scripts host"""
2
+ from .host import cli
3
+
4
+ cli()
@@ -0,0 +1,214 @@
1
+ """A device's camera, as numpy images for OpenCV, PyTorch, YOLO…
2
+
3
+ The stream is read straight from the device (MJPEG, or a still JPEG polled),
4
+ on a background thread that keeps only the newest picture: a slow model
5
+ always gets the latest frame, never a backlog.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import threading
11
+ import time
12
+ import urllib.request
13
+ from typing import Any, Dict, Iterator, Optional
14
+
15
+
16
+ class CameraError(RuntimeError):
17
+ pass
18
+
19
+
20
+ def decode_jpeg(data: bytes):
21
+ """Image bytes (JPEG, PNG) → H×W×3 numpy array, BGR (OpenCV's order), with OpenCV or Pillow."""
22
+ try:
23
+ import cv2
24
+ import numpy as np
25
+ except ImportError:
26
+ cv2 = None
27
+ if cv2 is not None:
28
+ img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
29
+ if img is None:
30
+ raise CameraError("could not decode a camera frame")
31
+ return img
32
+ try:
33
+ import numpy as np
34
+ from PIL import Image
35
+ except ImportError:
36
+ raise CameraError("camera frames need numpy and OpenCV (pip install opencv-python) or Pillow (pip install pillow)") from None
37
+ return np.asarray(Image.open(io.BytesIO(data)).convert("RGB"))[:, :, ::-1].copy()
38
+
39
+
40
+ class Camera:
41
+ """One of the device's cameras. ``frame()`` is the newest picture;
42
+ ``for img in camera.frames():`` yields each new one."""
43
+
44
+ def __init__(self, info: Dict[str, Any]):
45
+ self.id: str = info.get("id", "cam")
46
+ self.label: str = info.get("label") or self.id
47
+ self.url: Optional[str] = info.get("url")
48
+ self.fmt: str = info.get("fmt", "mjpeg")
49
+ self._jpeg: Optional[bytes] = None
50
+ self._count = 0
51
+ self._cond = threading.Condition()
52
+ self._thread: Optional[threading.Thread] = None
53
+ self._stop = threading.Event()
54
+ self.error: Optional[str] = None
55
+ self.fps = 0.0
56
+
57
+ def __repr__(self):
58
+ return f"Camera({self.label!r}, {self.url!r})"
59
+
60
+ def _start(self) -> None:
61
+ if self._thread is not None:
62
+ return
63
+ if not self.url:
64
+ raise CameraError(f"camera {self.label} is not reachable from this computer")
65
+ self._thread = threading.Thread(target=self._read, daemon=True, name=f"koebelt-dash-camera-{self.id}")
66
+ self._thread.start()
67
+
68
+ def _publish(self, jpeg: bytes) -> None:
69
+ with self._cond:
70
+ self._jpeg = jpeg
71
+ self._count += 1
72
+ self._cond.notify_all()
73
+
74
+ def _read(self) -> None:
75
+ while not self._stop.is_set():
76
+ try:
77
+ with urllib.request.urlopen(self.url, timeout=5) as r:
78
+ ctype = r.headers.get("Content-Type", "")
79
+ if self.fmt == "jpeg" or not ctype.startswith("multipart/"):
80
+ self._publish(r.read())
81
+ self._stop.wait(0.1)
82
+ continue
83
+ boundary = ctype.split("boundary=", 1)[-1].strip().strip('"').encode()
84
+ self._parts(r, b"--" + boundary.lstrip(b"-") if boundary else b"")
85
+ self.error = None
86
+ except Exception as e:
87
+ self.error = str(e)
88
+ self._stop.wait(1.0)
89
+
90
+ def _parts(self, r, boundary: bytes) -> None:
91
+ """Reads multipart/x-mixed-replace parts: headers, then Content-Length
92
+ bytes (or, without a length, up to the next boundary)."""
93
+ rd = _Buffered(r)
94
+ last = time.monotonic()
95
+ while not self._stop.is_set():
96
+ line = rd.readline()
97
+ if line is None:
98
+ return
99
+ if not line.strip().startswith(b"--"):
100
+ continue
101
+ length = None
102
+ while True:
103
+ h = rd.readline()
104
+ if h is None:
105
+ return
106
+ if not h.strip():
107
+ break
108
+ k, _, v = h.decode("latin-1").partition(":")
109
+ if k.strip().lower() == "content-length":
110
+ length = int(v.strip())
111
+ data = rd.read(length) if length is not None else rd.until(b"\r\n" + boundary)
112
+ if data is None:
113
+ return
114
+ self._publish(data)
115
+ now = time.monotonic()
116
+ self.fps = self.fps * 0.9 + (1 / max(now - last, 1e-3)) * 0.1
117
+ last = now
118
+
119
+ def jpeg(self, timeout: float = 5.0) -> bytes:
120
+ """The newest frame as the camera sends it (JPEG, or PNG for simulated cameras)."""
121
+ self._start()
122
+ with self._cond:
123
+ if not self._cond.wait_for(lambda: self._jpeg is not None, timeout=timeout):
124
+ raise CameraError(f"no picture from camera {self.label} within {timeout} s" + (f": {self.error}" if self.error else ""))
125
+ return self._jpeg # type: ignore[return-value]
126
+
127
+ def frame(self, timeout: float = 5.0):
128
+ """The newest frame, H×W×3 BGR numpy array."""
129
+ return decode_jpeg(self.jpeg(timeout))
130
+
131
+ def frames(self, timeout: float = 5.0) -> Iterator[Any]:
132
+ """Each new frame, newest first: frames the loop was too slow for are skipped."""
133
+ self._start()
134
+ seen = -1
135
+ while True:
136
+ with self._cond:
137
+ if not self._cond.wait_for(lambda: self._count != seen and self._jpeg is not None, timeout=timeout):
138
+ raise CameraError(f"no picture from camera {self.label} within {timeout} s" + (f": {self.error}" if self.error else ""))
139
+ seen, data = self._count, self._jpeg
140
+ yield decode_jpeg(data) # type: ignore[arg-type]
141
+
142
+ def close(self) -> None:
143
+ self._stop.set()
144
+
145
+
146
+ class _Buffered:
147
+ """Line, length and marker reads over a live HTTP body, returning what has
148
+ arrived without waiting for more than it needs."""
149
+
150
+ def __init__(self, r):
151
+ self.r = r
152
+ self.buf = b""
153
+
154
+ def _more(self) -> bool:
155
+ chunk = self.r.read1(65536) if hasattr(self.r, "read1") else self.r.read(4096)
156
+ if not chunk:
157
+ return False
158
+ self.buf += chunk
159
+ if len(self.buf) > 32_000_000:
160
+ raise CameraError("camera stream without frame boundaries")
161
+ return True
162
+
163
+ def readline(self) -> Optional[bytes]:
164
+ while b"\n" not in self.buf:
165
+ if not self._more():
166
+ return None
167
+ line, _, self.buf = self.buf.partition(b"\n")
168
+ return line + b"\n"
169
+
170
+ def read(self, n: int) -> Optional[bytes]:
171
+ while len(self.buf) < n:
172
+ if not self._more():
173
+ return None
174
+ data, self.buf = self.buf[:n], self.buf[n:]
175
+ return data
176
+
177
+ def until(self, marker: bytes) -> Optional[bytes]:
178
+ """The bytes before marker; the marker stays for the next part."""
179
+ while marker not in self.buf:
180
+ if not self._more():
181
+ return None
182
+ i = self.buf.index(marker)
183
+ data, self.buf = self.buf[:i], self.buf[i + 2:]
184
+ return data
185
+
186
+
187
+ def boxes_of(items: Any, width: Optional[float] = None, height: Optional[float] = None) -> list:
188
+ """Boxes for dev.overlay(): from (x, y, w, h[, label]) tuples, normalised
189
+ or in pixels (with width and height), or straight from an ultralytics
190
+ result (``model(frame)[0]``) or its ``.boxes``."""
191
+ names = getattr(items, "names", None)
192
+ if hasattr(items, "boxes") and not hasattr(items, "xyxyn"):
193
+ items = items.boxes
194
+ if hasattr(items, "xyxyn"):
195
+ xyxy = items.xyxyn.tolist()
196
+ cls = items.cls.tolist() if hasattr(items, "cls") else [None] * len(xyxy)
197
+ conf = items.conf.tolist() if hasattr(items, "conf") else [None] * len(xyxy)
198
+ out = []
199
+ for (x1, y1, x2, y2), c, p in zip(xyxy, cls, conf):
200
+ label = "" if c is None else (names.get(int(c), str(int(c))) if isinstance(names, dict) else str(int(c)))
201
+ if p is not None:
202
+ label = f"{label} {p:.2f}".strip()
203
+ out.append([x1, y1, x2 - x1, y2 - y1, label])
204
+ return out
205
+ out = []
206
+ for b in items:
207
+ if hasattr(b, "xyxyn"):
208
+ out.extend(boxes_of(b))
209
+ continue
210
+ x, y, w, h = (float(v) for v in b[:4])
211
+ if width and height:
212
+ x, y, w, h = x / width, y / height, w / width, h / height
213
+ out.append([x, y, w, h, str(b[4]) if len(b) > 4 else ""])
214
+ return out
@@ -0,0 +1,550 @@
1
+ """The connection to koebelt/dash: a device a script reads and drives.
2
+
3
+ The messages are in protocol/SCRIPTS.md. A background thread receives
4
+ telemetry and events; handlers (triggers, settings, takeover) run on a second
5
+ thread, so a slow handler never delays telemetry, and a slow main loop (a
6
+ model taking 200 ms per frame) never trips dash's watchdog: keep-alives go
7
+ out from their own thread every 100 ms.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import itertools
12
+ import json
13
+ import os
14
+ import pathlib
15
+ import queue
16
+ import sys
17
+ import threading
18
+ import time
19
+ import traceback
20
+ from typing import Any, Callable, Dict, Iterator, List, Optional
21
+
22
+ from websockets.exceptions import ConnectionClosed
23
+ from websockets.sync.client import connect as ws_connect
24
+
25
+ from .camera import Camera, boxes_of
26
+
27
+ API_VERSION = 1
28
+ DISCOVERY = pathlib.Path.home() / ".koebelt" / "dash-api.json"
29
+
30
+
31
+ class ScriptError(RuntimeError):
32
+ """dash refused a request; the message says why."""
33
+
34
+
35
+ class Released(ScriptError):
36
+ """dash took the sticks away (watchdog, geofence, STOP…) and the operator has not resumed the script yet."""
37
+
38
+
39
+ class Telemetry:
40
+ """The device's latest telemetry. Attributes are the fields dash sends
41
+ (``pos``, ``vel``, ``roll``, ``mode``, ``armed``, ``battery``…, see
42
+ protocol/SCRIPTS.md), plus ``alt``, ``gps`` and ``age``."""
43
+
44
+ def __init__(self, data: Dict[str, Any], at: float):
45
+ self.raw = data
46
+ self._at = at
47
+
48
+ @property
49
+ def age(self) -> float:
50
+ """Seconds since this telemetry arrived."""
51
+ return time.monotonic() - self._at
52
+
53
+ @property
54
+ def alt(self) -> float:
55
+ """Height above home, metres."""
56
+ return float(self.raw.get("pos", [0, 0, 0])[2])
57
+
58
+ @property
59
+ def gps(self):
60
+ """(lat, lon, alt) when the device has a GPS fix, else None."""
61
+ if "lat" not in self.raw:
62
+ return None
63
+ return (self.raw["lat"], self.raw["lon"], self.raw.get("galt", 0.0))
64
+
65
+ def __getattr__(self, name: str):
66
+ try:
67
+ return self.raw[name]
68
+ except KeyError:
69
+ raise AttributeError(f"this device sends no '{name}' (it sends: {', '.join(sorted(self.raw))})") from None
70
+
71
+ def __repr__(self):
72
+ keys = ("mode", "armed", "pos", "speed", "battery")
73
+ return "Telemetry(" + ", ".join(f"{k}={self.raw[k]!r}" for k in keys if k in self.raw) + ")"
74
+
75
+
76
+ class Setting:
77
+ """A value the operator can change in dash while the script runs."""
78
+
79
+ def __init__(self, name: str, value: Any):
80
+ self.name = name
81
+ self.value = value
82
+
83
+ def __repr__(self):
84
+ return f"Setting({self.name!r}, {self.value!r})"
85
+
86
+
87
+ class _Store(dict):
88
+ """A dictionary saved to disk on every change, kept between runs."""
89
+
90
+ def __init__(self, path: pathlib.Path):
91
+ self._path = path
92
+ try:
93
+ super().__init__(json.loads(path.read_text()))
94
+ except (OSError, ValueError):
95
+ super().__init__()
96
+
97
+ def _save(self):
98
+ try:
99
+ self._path.parent.mkdir(parents=True, exist_ok=True)
100
+ self._path.write_text(json.dumps(self, indent=1))
101
+ except OSError:
102
+ pass
103
+
104
+ def __setitem__(self, k, v):
105
+ super().__setitem__(k, v)
106
+ self._save()
107
+
108
+ def __delitem__(self, k):
109
+ super().__delitem__(k)
110
+ self._save()
111
+
112
+ def update(self, *a, **kw):
113
+ super().update(*a, **kw)
114
+ self._save()
115
+
116
+
117
+ class Params:
118
+ """The device's parameters: ``dev.params["MAX_TILT"]``. Writing needs the
119
+ operator's leave (Limits in dash)."""
120
+
121
+ def __init__(self, dev: "Device"):
122
+ self._dev = dev
123
+
124
+ def all(self) -> Dict[str, float]:
125
+ return dict(self._dev._call("cmd", c="params") or {})
126
+
127
+ def __getitem__(self, key: str) -> float:
128
+ values = self.all()
129
+ if key not in values:
130
+ raise KeyError(f"{key} (the device has: {', '.join(sorted(values))})")
131
+ return values[key]
132
+
133
+ def set(self, **values: float) -> None:
134
+ self._dev._call("cmd", c="pset", a=values)
135
+
136
+ def save(self) -> None:
137
+ self._dev._call("cmd", c="psave")
138
+
139
+
140
+ def _find_dash(url: Optional[str], token: Optional[str]):
141
+ url = url or os.environ.get("KOEBELT_DASH_URL")
142
+ token = token or os.environ.get("KOEBELT_DASH_TOKEN")
143
+ if url and token:
144
+ return url, token
145
+ try:
146
+ found = json.loads(DISCOVERY.read_text())
147
+ except (OSError, ValueError):
148
+ raise ScriptError("koebelt dash is not running on this computer (no ~/.koebelt/dash-api.json). "
149
+ "Start it, or pass url= and token=.") from None
150
+ return url or found["url"], token or found["token"]
151
+
152
+
153
+ class Device:
154
+ """A device connected in koebelt dash, as a script sees it. Get one with
155
+ :func:`connect`."""
156
+
157
+ def __init__(self, ws, welcome: Dict[str, Any], name: str):
158
+ self._ws = ws
159
+ self._name = name
160
+ self._send_lock = threading.Lock()
161
+ self._ids = itertools.count(1)
162
+ self._waiting: Dict[int, "queue.Queue[Dict[str, Any]]"] = {}
163
+ self._tlm: Optional[Telemetry] = None
164
+ self._tlm_ready = threading.Condition()
165
+ self._events: "queue.Queue[tuple]" = queue.Queue()
166
+ self._settings: Dict[str, Setting] = {}
167
+ self._triggers: Dict[str, Callable] = {}
168
+ self._any_trigger: List[Callable] = []
169
+ self._on_stop: List[Callable] = []
170
+ self._on_takeover: List[Callable] = []
171
+ self._closed = threading.Event()
172
+ self._store: Optional[_Store] = None
173
+ self.hz: Optional[float] = None
174
+ self.released: Optional[str] = None
175
+ """Why dash took the sticks away, until the operator resumes the script."""
176
+ self.taken_over = False
177
+ """True while the operator holds the sticks."""
178
+ self.app = welcome.get("app")
179
+ self.devices = welcome.get("devices", [])
180
+ self.params = Params(self)
181
+ self.info: Dict[str, Any] = {}
182
+ self.cameras: List[Camera] = []
183
+ for target in (self._receive, self._dispatch, self._keepalive):
184
+ threading.Thread(target=target, daemon=True, name=f"koebelt-dash-{target.__name__}").start()
185
+
186
+ # ------------------------------------------------------------------ plumbing
187
+ def _send(self, msg: Dict[str, Any]) -> None:
188
+ with self._send_lock:
189
+ self._ws.send(json.dumps(msg))
190
+
191
+ def _call(self, t: str, timeout: float = 5, **fields) -> Any:
192
+ if self._closed.is_set():
193
+ raise ScriptError("the connection to koebelt dash is closed")
194
+ i = next(self._ids)
195
+ box: "queue.Queue[Dict[str, Any]]" = queue.Queue(1)
196
+ self._waiting[i] = box
197
+ try:
198
+ self._send({"t": t, "id": i, **fields})
199
+ try:
200
+ res = box.get(timeout=timeout)
201
+ except queue.Empty:
202
+ raise ScriptError(f"koebelt dash did not answer '{t}' within {timeout} s") from None
203
+ finally:
204
+ self._waiting.pop(i, None)
205
+ if res.get("closed"):
206
+ raise ScriptError("the connection to koebelt dash closed")
207
+ if not res.get("ok"):
208
+ err = res.get("err") or "refused"
209
+ raise (Released if self.released and "resume" in err else ScriptError)(err)
210
+ return res.get("value")
211
+
212
+ def _receive(self) -> None:
213
+ try:
214
+ for raw in self._ws:
215
+ m = json.loads(raw)
216
+ t = m.get("t")
217
+ if t == "res":
218
+ box = self._waiting.get(m.get("id"))
219
+ if box is not None:
220
+ box.put(m)
221
+ elif t == "tlm":
222
+ with self._tlm_ready:
223
+ self._tlm = Telemetry(m["d"], time.monotonic())
224
+ self._tlm_ready.notify_all()
225
+ elif t == "setting":
226
+ s = self._settings.get(m["name"])
227
+ if s is not None:
228
+ s.value = m["value"]
229
+ elif t == "fire":
230
+ self._events.put(("fire", m["name"], m.get("value")))
231
+ elif t == "takeover":
232
+ self.taken_over = bool(m.get("on"))
233
+ self._events.put(("takeover", self.taken_over))
234
+ elif t == "released":
235
+ self.released = m.get("why") or "released"
236
+ print(f"koebelt dash: {self.released}", file=sys.stderr)
237
+ self._events.put(("stop", self.released))
238
+ elif t == "resumed":
239
+ self.released = None
240
+ elif t == "stop":
241
+ self._events.put(("stop", "STOP"))
242
+ except ConnectionClosed:
243
+ pass
244
+ finally:
245
+ self._closed.set()
246
+ for box in list(self._waiting.values()):
247
+ box.put({"ok": False, "closed": True})
248
+ self._events.put(("closed",))
249
+
250
+ def _dispatch(self) -> None:
251
+ while True:
252
+ ev = self._events.get()
253
+ kind = ev[0]
254
+ if kind == "closed":
255
+ return
256
+ handlers: List[Callable] = []
257
+ args: tuple = ()
258
+ if kind == "fire":
259
+ _, name, value = ev
260
+ h = self._triggers.get(name)
261
+ if h is not None:
262
+ handlers, args = [h], (() if value is None else (value,))
263
+ elif self._any_trigger:
264
+ handlers, args = self._any_trigger, (name, value)
265
+ else:
266
+ print(f"koebelt dash: trigger '{name}' has no handler (add @dev.on_trigger)", file=sys.stderr)
267
+ elif kind == "takeover":
268
+ handlers, args = self._on_takeover, (ev[1],)
269
+ elif kind == "stop":
270
+ handlers, args = self._on_stop, ()
271
+ for h in handlers:
272
+ try:
273
+ h(*args)
274
+ except Exception:
275
+ traceback.print_exc()
276
+
277
+ def _keepalive(self) -> None:
278
+ while not self._closed.wait(0.1):
279
+ try:
280
+ self._send({"t": "ka", **({"hz": round(self.hz, 2)} if self.hz else {})})
281
+ except Exception:
282
+ return
283
+
284
+ def _use(self, device: Optional[str]) -> None:
285
+ self.info = self._call("use", device=device)
286
+ self.cameras = [Camera(c) for c in self.info.get("cameras", [])]
287
+ with self._tlm_ready:
288
+ self._tlm_ready.wait_for(lambda: self._tlm is not None, timeout=3)
289
+
290
+ # ------------------------------------------------------------------ reading
291
+ @property
292
+ def name(self) -> str:
293
+ return self.info.get("name", "")
294
+
295
+ @property
296
+ def kind(self) -> str:
297
+ """drone, rover, arm, bench, rocket or generic."""
298
+ return self.info.get("kind", "generic")
299
+
300
+ @property
301
+ def simulated(self) -> bool:
302
+ return bool(self.info.get("sim"))
303
+
304
+ @property
305
+ def tlm(self) -> Telemetry:
306
+ """The latest telemetry (20 Hz)."""
307
+ if self._tlm is None:
308
+ raise ScriptError("no telemetry from the device yet")
309
+ return self._tlm
310
+
311
+ def next_tlm(self, timeout: float = 1.0) -> Telemetry:
312
+ """Waits for the next telemetry message and returns it."""
313
+ with self._tlm_ready:
314
+ before = self._tlm
315
+ if not self._tlm_ready.wait_for(lambda: self._tlm is not before, timeout=timeout):
316
+ raise ScriptError(f"no telemetry for {timeout} s")
317
+ return self._tlm # type: ignore[return-value]
318
+
319
+ # ------------------------------------------------------------------ acting
320
+ def sticks(self, **axes: float) -> None:
321
+ """Sets the sticks, −1…1, until changed. Drones: ``throttle``, ``yaw``,
322
+ ``pitch``, ``roll``; rovers: ``throttle``, ``steer``; any device:
323
+ ``lx``, ``ly``, ``rx``, ``ry``. dash scales them to the script's speed
324
+ limit, and the operator's sticks win."""
325
+ self._call("sticks", a=axes)
326
+
327
+ def release(self) -> None:
328
+ """Hands the sticks back; the device holds."""
329
+ self._call("release")
330
+
331
+ def mode(self, mode: str) -> None:
332
+ self._call("cmd", c="mode", a={"m": mode})
333
+
334
+ def arm(self) -> None:
335
+ """Needs the operator's leave (Limits in dash)."""
336
+ self._call("cmd", c="arm")
337
+
338
+ def disarm(self) -> None:
339
+ self._call("cmd", c="disarm")
340
+
341
+ def speed_limit(self, v: float) -> None:
342
+ """The device's speed limit, 0.1–1, capped by the script's."""
343
+ self._call("cmd", c="speed", a={"v": v})
344
+
345
+ def cmd(self, name: str, **args: Any) -> None:
346
+ """A command of this kind of device: ``dev.cmd("release")``, ``dev.cmd("countdown", s=10)``…"""
347
+ self._call("cmd", c=name, a=args)
348
+
349
+ def stop(self) -> None:
350
+ """STOP: every device, at once, as the STOP button."""
351
+ self._call("cmd", c="stop")
352
+
353
+ # ------------------------------------------------------------------ steps
354
+ def _step(self, step: str, wait: bool, **args: Any) -> None:
355
+ self._call("cmd", timeout=3600 if wait else 5, c="step", a={"type": step, "wait": wait, **args})
356
+
357
+ def takeoff(self, alt: float = 6, *, wait: bool = True) -> None:
358
+ """Drones: arms and climbs to ``alt`` metres (needs leave in Limits). Returns once there."""
359
+ self._step("takeoff", wait, alt=alt)
360
+
361
+ def goto(self, x: float, y: float, z: Optional[float] = None, *, wait: bool = True) -> None:
362
+ """Goes to x metres east, y north of home (and z up, drones). Returns once there."""
363
+ self._step("goto", wait, x=x, y=y, **({} if z is None else {"z": z}))
364
+
365
+ def land(self, *, wait: bool = True) -> None:
366
+ self._step("land", wait)
367
+
368
+ def rth(self, *, wait: bool = True) -> None:
369
+ """Drones: returns home and lands."""
370
+ self._step("rth", wait)
371
+
372
+ def pose(self, *joints: float, wait: bool = True) -> None:
373
+ """Arms: moves to joint angles, degrees (J1, J2…)."""
374
+ self._step("pose", wait, **{f"j{i + 1}": float(v) for i, v in enumerate(joints)})
375
+
376
+ def gripper(self, open_pct: float, *, wait: bool = True) -> None:
377
+ """Arms: 0 (closed) to 100 % open."""
378
+ self._step("gripper", wait, open=open_pct)
379
+
380
+ def home(self, *, wait: bool = True) -> None:
381
+ """Arms: the home pose."""
382
+ self._step("home", wait)
383
+
384
+ def throttle(self, pct: float, seconds: float, *, wait: bool = True) -> None:
385
+ """Test benches: holds a throttle for some seconds."""
386
+ self._step("throttle", wait, pct=pct, s=seconds)
387
+
388
+ def ramp(self, start: float, end: float, seconds: float, *, wait: bool = True) -> None:
389
+ """Test benches: ramps the throttle from one percentage to another."""
390
+ self._step("ramp", wait, **{"from": start, "to": end, "s": seconds})
391
+
392
+ def run_program(self, name: str, *, wait: bool = True) -> None:
393
+ """Runs a program from dash's Program screen (on the device when it can
394
+ store it: it keeps going if the link drops)."""
395
+ self._call("cmd", timeout=24 * 3600 if wait else 5, c="program", a={"name": name, "wait": wait})
396
+
397
+ def pause_program(self) -> None:
398
+ self._call("cmd", c="seqctl", a={"c": "pause"})
399
+
400
+ def resume_program(self) -> None:
401
+ self._call("cmd", c="seqctl", a={"c": "resume"})
402
+
403
+ def stop_program(self) -> None:
404
+ self._call("cmd", c="seqctl", a={"c": "stop"})
405
+
406
+ # ------------------------------------------------------------------ camera
407
+ @property
408
+ def camera(self) -> Camera:
409
+ """The device's first camera."""
410
+ if not self.cameras:
411
+ raise ScriptError(f"{self.name} has no camera")
412
+ return self.cameras[0]
413
+
414
+ def overlay(self, boxes: Any, width: Optional[float] = None, height: Optional[float] = None) -> None:
415
+ """Draws boxes over the camera picture in dash: (x, y, w, h[, label])
416
+ tuples, normalised 0–1 (or in pixels with width and height), or an
417
+ ultralytics result. They fade a second after the last call."""
418
+ self._send({"t": "overlay", "boxes": boxes_of(boxes, width, height)})
419
+
420
+ # ------------------------------------------------------------------ dash
421
+ def setting(self, name: str, default: Any, *, min: Optional[float] = None, max: Optional[float] = None,
422
+ label: Optional[str] = None) -> Setting:
423
+ """A slider (numbers) or switch (True/False) in dash. Read ``.value``;
424
+ the operator's last value is kept between runs."""
425
+ value = self._call("setting", name=name, value=default, min=min, max=max, label=label)
426
+ s = self._settings[name] = Setting(name, value)
427
+ return s
428
+
429
+ def trigger(self, name: str, kind: str = "button", *, key: Optional[str] = None, gamepad: Optional[str] = None,
430
+ min: Optional[float] = None, max: Optional[float] = None):
431
+ """Decorator: runs the function when the trigger fires. ``kind``:
432
+ ``button`` (no argument), ``switch`` (True/False) or ``value`` (a
433
+ number). ``key`` and ``gamepad`` suggest bindings; the operator can
434
+ change them."""
435
+ def register(fn: Callable) -> Callable:
436
+ self._triggers[name] = fn
437
+ self._call("trigger", name=name, kind=kind, key=key, gamepad=gamepad, min=min, max=max)
438
+ return fn
439
+ return register
440
+
441
+ def on_trigger(self, fn: Callable) -> Callable:
442
+ """Decorator: ``fn(name, value)`` for triggers without their own handler."""
443
+ self._any_trigger.append(fn)
444
+ return fn
445
+
446
+ def on_stop(self, fn: Callable) -> Callable:
447
+ """Decorator: called when dash takes the sticks away or STOP is pressed."""
448
+ self._on_stop.append(fn)
449
+ return fn
450
+
451
+ def on_takeover(self, fn: Callable) -> Callable:
452
+ """Decorator: ``fn(True)`` when the operator takes the sticks, ``fn(False)`` when they give them back."""
453
+ self._on_takeover.append(fn)
454
+ return fn
455
+
456
+ def plot(self, name: str, value: float) -> None:
457
+ """Shows a value in dash (channel ``script:<name>``) and records it."""
458
+ self._send({"t": "plot", "name": name, "v": float(value)})
459
+
460
+ def mark(self, text: str) -> None:
461
+ """An event on the recording's timeline."""
462
+ self._call("mark", text=text)
463
+
464
+ def notify(self, text: str, level: str = "info") -> None:
465
+ """A message to the operator in dash (``info``, ``warn``, ``err``)."""
466
+ self._call("notify", text=text, level=level)
467
+
468
+ @property
469
+ def store(self) -> dict:
470
+ """A dictionary kept between runs of this script."""
471
+ if self._store is None:
472
+ safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in self._name)
473
+ self._store = _Store(DISCOVERY.parent / "script-store" / f"{safe}.json")
474
+ return self._store
475
+
476
+ def run(self) -> None:
477
+ """Waits until dash closes the connection: for scripts that only react to triggers."""
478
+ try:
479
+ self._closed.wait()
480
+ except KeyboardInterrupt:
481
+ pass
482
+
483
+ def close(self) -> None:
484
+ for c in self.cameras:
485
+ c.close()
486
+ try:
487
+ self._ws.close()
488
+ finally:
489
+ self._closed.set()
490
+
491
+ def __enter__(self):
492
+ return self
493
+
494
+ def __exit__(self, *exc):
495
+ self.close()
496
+
497
+ def __repr__(self):
498
+ return f"Device({self.name!r}, {self.kind}{', simulated' if self.simulated else ''})"
499
+
500
+
501
+ _current: Optional[Device] = None
502
+
503
+
504
+ def connect(device: Optional[str] = None, *, url: Optional[str] = None, token: Optional[str] = None,
505
+ name: Optional[str] = None) -> Device:
506
+ """Connects to koebelt dash and returns a device: the one selected in dash,
507
+ or the one called ``device``. dash on this computer is found by itself."""
508
+ global _current
509
+ url, token = _find_dash(url, token)
510
+ name = name or os.path.basename(sys.argv[0] or "") or "python"
511
+ try:
512
+ # Entering the context opens it, in every websockets version from 12 on.
513
+ ws = ws_connect(url, open_timeout=5, max_size=None).__enter__()
514
+ except OSError as e:
515
+ raise ScriptError(f"cannot reach koebelt dash at {url}: {e}") from None
516
+ ws.send(json.dumps({"t": "hello", "v": API_VERSION, "token": token, "name": name, "pid": os.getpid()}))
517
+ welcome = json.loads(ws.recv(timeout=5))
518
+ if welcome.get("t") != "welcome":
519
+ ws.close()
520
+ raise ScriptError(welcome.get("why") or "koebelt dash refused the connection")
521
+ dev = Device(ws, welcome, name)
522
+ dev._use(device)
523
+ _current = dev
524
+ return dev
525
+
526
+
527
+ def every(seconds: float) -> Iterator[int]:
528
+ """A steady loop: ``for i in every(0.05): ...`` runs the body every 50 ms
529
+ (or as fast as it can), and reports the real rate to dash."""
530
+ period = float(seconds)
531
+ nxt = time.monotonic()
532
+ window, count = nxt, 0
533
+ for i in itertools.count():
534
+ yield i
535
+ now = time.monotonic()
536
+ # The rate over each second, not single intervals.
537
+ count += 1
538
+ if now - window >= 1.0 and _current is not None:
539
+ _current.hz = count / (now - window)
540
+ window, count = now, 0
541
+ nxt += period
542
+ delay = nxt - time.monotonic()
543
+ if delay > 0:
544
+ time.sleep(delay)
545
+ else:
546
+ nxt = time.monotonic()
547
+
548
+
549
+ def wait(seconds: float) -> None:
550
+ time.sleep(seconds)
@@ -0,0 +1,268 @@
1
+ """A script host: runs scripts for koebelt dash on a phone or tablet.
2
+
3
+ koebelt-dash-scripts host
4
+
5
+ Phones cannot run Python with machine learning, so they send their scripts
6
+ here. The script runs in this computer's Python and connects back to dash on
7
+ the phone, which keeps the device link, the operator's sticks and STOP.
8
+
9
+ The phone finds the host on the local network (or is given its address),
10
+ and pairs once with the code the host prints. Messages are in
11
+ protocol/SCRIPTS.md (Script hosts).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import base64
17
+ import json
18
+ import os
19
+ import pathlib
20
+ import secrets
21
+ import shutil
22
+ import socket
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ import threading
27
+ import time
28
+ from typing import Dict, Optional
29
+
30
+ from websockets.exceptions import ConnectionClosed
31
+ from websockets.sync.server import serve
32
+
33
+ from . import __version__
34
+
35
+ HOST_PORT = 47921
36
+ DISCOVERY_PORT = 47922
37
+ CONFIG = pathlib.Path.home() / ".koebelt" / "script-host.json"
38
+
39
+
40
+ def _load() -> dict:
41
+ try:
42
+ return json.loads(CONFIG.read_text())
43
+ except (OSError, ValueError):
44
+ return {}
45
+
46
+
47
+ def _save(cfg: dict) -> None:
48
+ CONFIG.parent.mkdir(parents=True, exist_ok=True)
49
+ CONFIG.write_text(json.dumps(cfg, indent=1))
50
+ try:
51
+ CONFIG.chmod(0o600)
52
+ except OSError:
53
+ pass
54
+
55
+
56
+ def _python_version(python: str) -> str:
57
+ try:
58
+ out = subprocess.run([python, "-c", "import sys; print(sys.version.split()[0])"], capture_output=True, text=True, timeout=10)
59
+ return out.stdout.strip() or "?"
60
+ except Exception:
61
+ return "?"
62
+
63
+
64
+ class Host:
65
+ """Pairs phones and runs their scripts."""
66
+
67
+ def __init__(self, name: Optional[str] = None, port: int = HOST_PORT, python: Optional[str] = None,
68
+ discovery: bool = True, say=print, discovery_port: int = DISCOVERY_PORT):
69
+ self.cfg = _load()
70
+ self.name = name or self.cfg.get("name") or socket.gethostname().split(".")[0]
71
+ self.port = port
72
+ self.python = python or sys.executable
73
+ self.version = _python_version(self.python)
74
+ self.discovery = discovery
75
+ self.discovery_port = discovery_port
76
+ self.say = say
77
+ self.ready = threading.Event()
78
+ self.code = self._new_code()
79
+ self.runs: Dict[str, "_Run"] = {}
80
+ self._server = None
81
+
82
+ @staticmethod
83
+ def _new_code() -> str:
84
+ alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
85
+ return "".join(secrets.choice(alphabet) for _ in range(3)) + "-" + "".join(secrets.choice(alphabet) for _ in range(3))
86
+
87
+ @property
88
+ def tokens(self) -> Dict[str, str]:
89
+ """Paired phones: token → their name."""
90
+ return self.cfg.setdefault("paired", {})
91
+
92
+ def info(self) -> dict:
93
+ return {"t": "host", "v": 1, "name": self.name, "port": self.port, "python": self.version, "pkg": __version__}
94
+
95
+ # ------------------------------------------------------------- discovery
96
+ def _discovery(self) -> None:
97
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
98
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
99
+ try:
100
+ s.bind(("", self.discovery_port))
101
+ except OSError as e:
102
+ self.say(f"Discovery is off (port {self.discovery_port}: {e}); phones can still use this computer's address.")
103
+ return
104
+ while True:
105
+ try:
106
+ data, addr = s.recvfrom(2048)
107
+ if json.loads(data).get("t") == "hostdiscover":
108
+ s.sendto(json.dumps(self.info()).encode(), addr)
109
+ except Exception:
110
+ continue
111
+
112
+ # ------------------------------------------------------------- sessions
113
+ def _handle(self, ws) -> None:
114
+ peer = ws.remote_address[0] if ws.remote_address else "?"
115
+ try:
116
+ hello = json.loads(ws.recv(timeout=10))
117
+ except Exception:
118
+ return
119
+ t = hello.get("t")
120
+ if t == "pair":
121
+ if str(hello.get("code", "")).upper().replace(" ", "") != self.code:
122
+ ws.send(json.dumps({"t": "bye", "why": "Wrong pairing code."}))
123
+ return
124
+ token = secrets.token_hex(16)
125
+ self.tokens[token] = str(hello.get("name") or peer)
126
+ _save(self.cfg)
127
+ self.code = self._new_code()
128
+ self.say(f"Paired with {self.tokens[token]}. Next pairing code: {self.code}")
129
+ ws.send(json.dumps({**self.info(), "t": "paired", "token": token}))
130
+ return
131
+ if t != "hello" or hello.get("token") not in self.tokens:
132
+ ws.send(json.dumps({"t": "bye", "why": "Not paired with this host: pair again with its code."}))
133
+ return
134
+ who = self.tokens[hello["token"]]
135
+ ws.send(json.dumps({**self.info(), "t": "welcome"}))
136
+ run: Optional[_Run] = None
137
+ try:
138
+ for raw in ws:
139
+ m = json.loads(raw)
140
+ if m.get("t") == "run" and run is None:
141
+ run = _Run(self, ws, m, peer)
142
+ self.say(f"{who}: running {m.get('name', 'a script')}")
143
+ run.start()
144
+ elif m.get("t") == "stop" and run is not None:
145
+ run.stop()
146
+ except ConnectionClosed:
147
+ pass
148
+ finally:
149
+ # The phone went away: its script cannot reach dash any more anyway.
150
+ if run is not None:
151
+ run.stop()
152
+
153
+ def serve(self) -> None:
154
+ if self.discovery:
155
+ threading.Thread(target=self._discovery, daemon=True).start()
156
+ with serve(self._handle, "0.0.0.0", self.port, max_size=None) as server:
157
+ self._server = server
158
+ self.port = server.socket.getsockname()[1]
159
+ self.ready.set()
160
+ server.serve_forever()
161
+
162
+ def shutdown(self) -> None:
163
+ if self._server is not None:
164
+ self._server.shutdown()
165
+
166
+
167
+ class _Run:
168
+ """One script a phone runs here."""
169
+
170
+ def __init__(self, host: Host, ws, m: dict, peer: str):
171
+ self.host, self.ws, self.m = host, ws, m
172
+ self.dir = pathlib.Path(tempfile.mkdtemp(prefix="koebelt-script-"))
173
+ self.proc: Optional[subprocess.Popen] = None
174
+ self.lock = threading.Lock()
175
+ # dash on the phone, as this computer reaches it.
176
+ self.dash = f"ws://{m.get('dashHost') or peer}:{int(m['dashPort'])}/scripts"
177
+
178
+ def _send(self, msg: dict) -> None:
179
+ with self.lock:
180
+ try:
181
+ self.ws.send(json.dumps(msg))
182
+ except Exception:
183
+ pass
184
+
185
+ def start(self) -> None:
186
+ main = None
187
+ for rel, b64 in self.m.get("files", {}).items():
188
+ path = (self.dir / rel).resolve()
189
+ if self.dir.resolve() not in path.parents:
190
+ continue
191
+ path.parent.mkdir(parents=True, exist_ok=True)
192
+ path.write_bytes(base64.b64decode(b64))
193
+ if rel == self.m.get("main", "main.py"):
194
+ main = path
195
+ if main is None:
196
+ self._send({"t": "out", "level": "err", "text": "The script's file is missing."})
197
+ self._send({"t": "exit", "code": 1})
198
+ return
199
+ env = dict(os.environ, KOEBELT_DASH_URL=self.dash, KOEBELT_DASH_TOKEN=self.m["dashToken"], PYTHONUNBUFFERED="1",
200
+ PYTHONIOENCODING="utf-8")
201
+ self.proc = subprocess.Popen([self.host.python, "-u", str(main)], cwd=self.dir, env=env, stdout=subprocess.PIPE,
202
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
203
+ self._send({"t": "started", "pid": self.proc.pid, "python": self.host.version})
204
+ for stream, level in ((self.proc.stdout, "out"), (self.proc.stderr, "err")):
205
+ threading.Thread(target=self._pump, args=(stream, level), daemon=True).start()
206
+ threading.Thread(target=self._wait, daemon=True).start()
207
+
208
+ def _pump(self, stream, level: str) -> None:
209
+ for line in stream:
210
+ self._send({"t": "out", "level": level, "text": line.rstrip("\n")})
211
+
212
+ def _wait(self) -> None:
213
+ code = self.proc.wait()
214
+ time.sleep(0.2) # let the last lines out first
215
+ self._send({"t": "exit", "code": code})
216
+ shutil.rmtree(self.dir, ignore_errors=True)
217
+
218
+ def stop(self) -> None:
219
+ p = self.proc
220
+ if p is None or p.poll() is not None:
221
+ return
222
+ p.terminate()
223
+ try:
224
+ p.wait(2)
225
+ except subprocess.TimeoutExpired:
226
+ p.kill()
227
+
228
+
229
+ def main(argv=None) -> None:
230
+ ap = argparse.ArgumentParser(prog="koebelt-dash-scripts host", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
231
+ ap.add_argument("--name", help="the name phones see (default: this computer's)")
232
+ ap.add_argument("--port", type=int, default=HOST_PORT)
233
+ ap.add_argument("--python", help="the Python scripts run with (default: this one)")
234
+ ap.add_argument("--no-discovery", action="store_true", help="do not answer phones looking for hosts")
235
+ ap.add_argument("--forget", action="store_true", help="unpair every phone, then exit")
236
+ a = ap.parse_args(argv)
237
+ if a.forget:
238
+ cfg = _load()
239
+ cfg["paired"] = {}
240
+ _save(cfg)
241
+ print("Every phone is unpaired.")
242
+ return
243
+ h = Host(name=a.name, port=a.port, python=a.python, discovery=not a.no_discovery)
244
+ if a.name:
245
+ h.cfg["name"] = a.name
246
+ _save(h.cfg)
247
+ print(f"koebelt dash script host “{h.name}”, port {h.port}, Python {h.version}")
248
+ print(f"Pairing code: {h.code} (in koebelt dash: Scripts > Script host > Pair)")
249
+ print(f"Paired phones: {len(h.tokens)}. Ctrl-C to stop.", flush=True)
250
+ try:
251
+ h.serve()
252
+ except KeyboardInterrupt:
253
+ pass
254
+
255
+
256
+ def cli(argv=None) -> None:
257
+ """`koebelt-dash-scripts <command>`."""
258
+ argv = sys.argv[1:] if argv is None else argv
259
+ if not argv or argv[0] in ("-h", "--help"):
260
+ print("usage: koebelt-dash-scripts host [--name NAME] [--port PORT] [--python PATH] [--forget]")
261
+ print(" koebelt-dash-scripts --version")
262
+ return
263
+ if argv[0] == "--version":
264
+ print(__version__)
265
+ return
266
+ if argv[0] == "host":
267
+ return main(argv[1:])
268
+ sys.exit(f"unknown command: {argv[0]}")
File without changes
@@ -0,0 +1,112 @@
1
+ """Small helpers for control loops: PID, filters, limits and geometry.
2
+
3
+ Local positions are metres from home, x east and y north (as dash's
4
+ telemetry ``pos``); headings and bearings are compass degrees.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import time
10
+ from typing import Optional, Sequence
11
+
12
+
13
+ def clamp(x: float, lo: float = -1.0, hi: float = 1.0) -> float:
14
+ return lo if x < lo else hi if x > hi else x
15
+
16
+
17
+ class pid:
18
+ """A PID controller. ``out = pid(1.2, 0.1, 0.3).update(error)``; the time
19
+ step is measured unless given. The output is clamped to ±limit, and the
20
+ integral stops growing while the output is clamped (anti-windup)."""
21
+
22
+ def __init__(self, kp: float, ki: float = 0.0, kd: float = 0.0, limit: float = 1.0):
23
+ self.kp, self.ki, self.kd, self.limit = kp, ki, kd, limit
24
+ self.reset()
25
+
26
+ def reset(self) -> None:
27
+ self.integral = 0.0
28
+ self._last_error: Optional[float] = None
29
+ self._last_t: Optional[float] = None
30
+
31
+ def update(self, error: float, dt: Optional[float] = None) -> float:
32
+ now = time.monotonic()
33
+ if dt is None:
34
+ dt = 0.0 if self._last_t is None else now - self._last_t
35
+ self._last_t = now
36
+ deriv = 0.0 if self._last_error is None or dt <= 0 else (error - self._last_error) / dt
37
+ self._last_error = error
38
+ integral = self.integral + error * dt
39
+ out = self.kp * error + self.ki * integral + self.kd * deriv
40
+ if -self.limit < out < self.limit:
41
+ self.integral = integral
42
+ return clamp(out, -self.limit, self.limit)
43
+
44
+
45
+ class lowpass:
46
+ """A first-order low-pass filter with a cut-off in Hz: ``f = lowpass(2); y = f.update(x)``."""
47
+
48
+ def __init__(self, hz: float):
49
+ self.hz = hz
50
+ self.value: Optional[float] = None
51
+ self._last_t: Optional[float] = None
52
+
53
+ def update(self, x: float, dt: Optional[float] = None) -> float:
54
+ now = time.monotonic()
55
+ if dt is None:
56
+ dt = 0.0 if self._last_t is None else now - self._last_t
57
+ self._last_t = now
58
+ if self.value is None:
59
+ self.value = x
60
+ else:
61
+ a = 1 - math.exp(-2 * math.pi * self.hz * dt)
62
+ self.value += a * (x - self.value)
63
+ return self.value
64
+
65
+
66
+ class rate_limit:
67
+ """Moves towards a target at most ``per_second`` units a second: smooth stick changes."""
68
+
69
+ def __init__(self, per_second: float, start: float = 0.0):
70
+ self.per_second = per_second
71
+ self.value = start
72
+ self._last_t: Optional[float] = None
73
+
74
+ def update(self, target: float, dt: Optional[float] = None) -> float:
75
+ now = time.monotonic()
76
+ if dt is None:
77
+ dt = 0.0 if self._last_t is None else now - self._last_t
78
+ self._last_t = now
79
+ step = self.per_second * dt
80
+ self.value += clamp(target - self.value, -step, step)
81
+ return self.value
82
+
83
+
84
+ def distance(a: Sequence[float], b: Sequence[float] = (0, 0, 0)) -> float:
85
+ """Straight-line distance between two local positions (2 or 3 coordinates); from home by default."""
86
+ return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
87
+
88
+
89
+ def bearing(a: Sequence[float], b: Sequence[float]) -> float:
90
+ """Compass bearing from local position a to b, degrees (0 = north, 90 = east)."""
91
+ return math.degrees(math.atan2(b[0] - a[0], b[1] - a[1])) % 360
92
+
93
+
94
+ def angle_diff(target: float, current: float) -> float:
95
+ """Shortest signed turn from one heading to another, degrees in −180…180."""
96
+ return (target - current + 180) % 360 - 180
97
+
98
+
99
+ def geo_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
100
+ """Metres between two GPS positions."""
101
+ p1, p2 = math.radians(lat1), math.radians(lat2)
102
+ dp, dl = p2 - p1, math.radians(lon2 - lon1)
103
+ h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
104
+ return 2 * 6371000 * math.asin(math.sqrt(h))
105
+
106
+
107
+ def geo_bearing(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
108
+ """Compass bearing between two GPS positions, degrees."""
109
+ p1, p2, dl = math.radians(lat1), math.radians(lat2), math.radians(lon2 - lon1)
110
+ y = math.sin(dl) * math.cos(p2)
111
+ x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
112
+ return math.degrees(math.atan2(y, x)) % 360
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: koebelt-dash-scripts
3
+ Version: 1.3.0b1
4
+ Summary: Control drones, robots and test rigs connected in the koebelt/dash app from Python.
5
+ Author-email: Thomas Koebel <contact@koebelt.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://koebelt.com/work/koebelt-dash
8
+ Project-URL: Source, https://github.com/koebelt/koebelt_dash/tree/main/libs/python-dash-scripts
9
+ Project-URL: Protocol, https://github.com/koebelt/koebelt_dash/blob/main/protocol/SCRIPTS.md
10
+ Project-URL: Issues, https://github.com/koebelt/koebelt_dash/issues
11
+ Keywords: robotics,drone,rover,automation,computer-vision,koebelt
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Classifier: Topic :: System :: Hardware
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: websockets>=12
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # koebelt-dash-scripts
30
+
31
+ Control drones, robots and test rigs connected in
32
+ [koebelt/dash](https://koebelt.com/work/koebelt-dash) from Python: read their
33
+ telemetry, drive them, react to buttons and conditions, with any Python
34
+ package you like (OpenCV, PyTorch, YOLO…).
35
+
36
+ ```sh
37
+ pip install koebelt-dash-scripts
38
+ ```
39
+
40
+ ```python
41
+ from koebelt_dash_scripts import connect, every, pid
42
+
43
+ dev = connect() # the device selected in koebelt dash
44
+ speed = dev.setting("speed", 0.4, min=0, max=1) # a slider in dash
45
+ hold = pid(1.5, 0.0, 0.2)
46
+
47
+ @dev.trigger("Stop here", key="S") # a button in dash
48
+ def stop_here():
49
+ dev.mode("HOLD")
50
+
51
+ dev.mode("MANUAL")
52
+ for _ in every(0.05): # 20 Hz
53
+ error = 0.0 - dev.tlm.pos[0] # stay on the north-south line
54
+ dev.sticks(throttle=speed.value, steer=hold.update(error))
55
+ ```
56
+
57
+ Run it from koebelt dash (Scripts), or from a terminal while dash is open on
58
+ the same computer: it finds dash by itself.
59
+
60
+ With a model on the camera:
61
+
62
+ ```python
63
+ from ultralytics import YOLO
64
+ from koebelt_dash_scripts import connect, pid
65
+
66
+ model = YOLO("yolov8n.pt")
67
+ dev = connect()
68
+ steer = pid(1.2, 0, 0.1)
69
+
70
+ for frame in dev.camera.frames():
71
+ result = model(frame, verbose=False)[0]
72
+ dev.overlay(result) # see what the model sees, in dash
73
+ people = [b for b in result.boxes if int(b.cls) == 0]
74
+ if not people:
75
+ dev.sticks(throttle=0, steer=0)
76
+ continue
77
+ x = float(people[0].xywhn[0][0]) - 0.5 # −0.5 left … 0.5 right
78
+ dev.sticks(throttle=0.4, steer=steer.update(-x))
79
+ ```
80
+
81
+ ## Script hosts: scripts for phones and tablets
82
+
83
+ koebelt dash on an iPhone, iPad or Android device sends its scripts to a
84
+ computer on the same network, which runs them with its own Python (and its
85
+ GPU), while the phone keeps the device link, the sticks and STOP:
86
+
87
+ ```sh
88
+ pip install koebelt-dash-scripts
89
+ koebelt-dash-scripts host
90
+ ```
91
+
92
+ It prints a pairing code; in dash: Scripts > Runs on > Add a script host. A
93
+ laptop on the field, a Raspberry Pi 5 or a Jetson all work. A computer
94
+ running koebelt dash can also be a host (Settings > Scripts).
95
+
96
+ ## Safety
97
+
98
+ dash stays in charge, whatever the script does:
99
+
100
+ - STOP stops every device and ends the script.
101
+ - Moving the sticks in dash takes over; the script's sticks are ignored until
102
+ you resume it.
103
+ - Each script has limits set in dash: maximum speed, geofence, ceiling.
104
+ Arming, take-off and parameter writes are refused unless you allow them.
105
+ - If the script crashes, hangs or leaves the geofence, the device holds, and
106
+ the script cannot drive again until you resume it.
107
+
108
+ ## The API
109
+
110
+ | | |
111
+ |---|---|
112
+ | `connect(device=None)` | The device selected in dash, or the one with that name. |
113
+ | `dev.tlm` | Latest telemetry (20 Hz): `pos` (m from home, x east, y north), `alt`, `vel`, `speed`, `roll` `pitch` `yaw`, `heading`, `mode`, `armed`, `battery`, `gps`, `joints`… |
114
+ | `dev.next_tlm()` | Waits for the next telemetry. |
115
+ | `dev.sticks(...)` | −1…1, held until changed. Drones: `throttle yaw pitch roll`; rovers: `throttle steer`. |
116
+ | `dev.mode(m)`, `dev.arm()`, `dev.disarm()`, `dev.speed_limit(v)`, `dev.cmd(name, **args)`, `dev.stop()`, `dev.release()` | Commands. |
117
+ | `dev.takeoff(alt)`, `dev.goto(x, y, z)`, `dev.land()`, `dev.rth()`, `dev.pose(*deg)`, `dev.gripper(pct)`, `dev.home()`, `dev.throttle(pct, s)`, `dev.ramp(a, b, s)` | Program steps; they return once done (or at once with `wait=False`). |
118
+ | `dev.run_program(name)`, `dev.pause_program()`, `dev.resume_program()`, `dev.stop_program()` | Programs from dash's Program screen. |
119
+ | `dev.camera.frame()`, `for img in dev.camera.frames():` | The camera as numpy images (BGR, for OpenCV; needs `opencv-python` or `pillow`). The newest frame only: a slow model never falls behind. |
120
+ | `dev.overlay(boxes)` | Boxes over the camera in dash: `(x, y, w, h, label)` tuples or an ultralytics result. |
121
+ | `dev.params["KEY"]`, `dev.params.set(KEY=v)`, `dev.params.save()` | Parameters. |
122
+ | `dev.setting(name, default, min, max)` | A slider or switch in dash; read `.value`. |
123
+ | `@dev.trigger(name, kind, key, gamepad)` | A button (`button`), switch (`switch`) or number (`value`) in dash. |
124
+ | `@dev.on_trigger`, `@dev.on_stop`, `@dev.on_takeover` | Other events. |
125
+ | `dev.plot(name, v)`, `dev.mark(text)`, `dev.notify(text)` | Shown in dash and recorded. |
126
+ | `dev.store` | A dictionary kept between runs. |
127
+ | `dev.run()` | Waits, for scripts that only react to triggers. |
128
+ | `every(seconds)`, `wait(seconds)` | Steady loops; dash shows the real rate. |
129
+ | `pid`, `lowpass`, `rate_limit`, `clamp`, `distance`, `bearing`, `angle_diff`, `geo_distance`, `geo_bearing` | Helpers. |
130
+
131
+ The messages between scripts and dash are documented in
132
+ [protocol/SCRIPTS.md](https://github.com/koebelt/koebelt_dash/blob/main/protocol/SCRIPTS.md).
133
+
134
+ MIT licence.
@@ -0,0 +1,13 @@
1
+ koebelt_dash_scripts/__init__.py,sha256=SaFTxURDob1NCzP0yVpacR87uY9Y_8fmCkAWmzf7KMA,825
2
+ koebelt_dash_scripts/__main__.py,sha256=mvh09q2N7omAyyVpFWNlZD6NEBLhJWHtErZHjnvDhx4,71
3
+ koebelt_dash_scripts/camera.py,sha256=4bJjOUizDOeGjopl5I7tHqDqR2upaTVxe66zoyQPUkI,8325
4
+ koebelt_dash_scripts/client.py,sha256=x60YMTZYktMoBpX89UEh0dkW8ElQ1ryjD_hhWedyuRg,21217
5
+ koebelt_dash_scripts/host.py,sha256=Z7cEp7744JXC_QVjseCM5QLfvWTfFHVjrHqvfaDhBrw,10243
6
+ koebelt_dash_scripts/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ koebelt_dash_scripts/tools.py,sha256=AV1UvBtQq1tCTE33L6uvxHT8f-5Oipzb_iYEkGl55JQ,4311
8
+ koebelt_dash_scripts-1.3.0b1.dist-info/licenses/LICENSE,sha256=m-y3HxYhKiD6YgEawRG_c1sL_u_6XpaXAVtEZB1EIVo,1070
9
+ koebelt_dash_scripts-1.3.0b1.dist-info/METADATA,sha256=m79qkAaXxruU47dwZcQD6Era0INSTgwvS_pgyjaB0fc,6027
10
+ koebelt_dash_scripts-1.3.0b1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ koebelt_dash_scripts-1.3.0b1.dist-info/entry_points.txt,sha256=Wtb84cGA602-C0JvAFceC7Gg0c8Uxz4-GKmxMlj4Mww,71
12
+ koebelt_dash_scripts-1.3.0b1.dist-info/top_level.txt,sha256=pwU67_VGj7C5eYW4F0UGtuXejCx-wJTaAFjfeBwq4RA,21
13
+ koebelt_dash_scripts-1.3.0b1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ koebelt-dash-scripts = koebelt_dash_scripts.host:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Koebel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ koebelt_dash_scripts