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.
- koebelt_dash_scripts/__init__.py +17 -0
- koebelt_dash_scripts/__main__.py +4 -0
- koebelt_dash_scripts/camera.py +214 -0
- koebelt_dash_scripts/client.py +550 -0
- koebelt_dash_scripts/host.py +268 -0
- koebelt_dash_scripts/py.typed +0 -0
- koebelt_dash_scripts/tools.py +112 -0
- koebelt_dash_scripts-1.3.0b1.dist-info/METADATA +134 -0
- koebelt_dash_scripts-1.3.0b1.dist-info/RECORD +13 -0
- koebelt_dash_scripts-1.3.0b1.dist-info/WHEEL +5 -0
- koebelt_dash_scripts-1.3.0b1.dist-info/entry_points.txt +2 -0
- koebelt_dash_scripts-1.3.0b1.dist-info/licenses/LICENSE +21 -0
- koebelt_dash_scripts-1.3.0b1.dist-info/top_level.txt +1 -0
|
@@ -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,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,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
|