koebelt-dash-scripts 1.3.0b1__tar.gz → 1.3.0b2__tar.gz
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-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/PKG-INFO +21 -1
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/README.md +20 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/__init__.py +6 -2
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/camera.py +11 -8
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/client.py +35 -26
- koebelt_dash_scripts-1.3.0b2/koebelt_dash_scripts/errors.py +297 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/PKG-INFO +21 -1
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/SOURCES.txt +2 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/pyproject.toml +1 -1
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/tests/test_camera.py +2 -1
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/tests/test_client.py +14 -5
- koebelt_dash_scripts-1.3.0b2/tests/test_errors.py +91 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/LICENSE +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/__main__.py +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/host.py +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/py.typed +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/tools.py +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/dependency_links.txt +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/entry_points.txt +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/requires.txt +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/top_level.txt +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/setup.cfg +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/tests/test_host.py +0 -0
- {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/tests/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: koebelt-dash-scripts
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.0b2
|
|
4
4
|
Summary: Control drones, robots and test rigs connected in the koebelt/dash app from Python.
|
|
5
5
|
Author-email: Thomas Koebel <contact@koebelt.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -128,6 +128,26 @@ dash stays in charge, whatever the script does:
|
|
|
128
128
|
| `every(seconds)`, `wait(seconds)` | Steady loops; dash shows the real rate. |
|
|
129
129
|
| `pid`, `lowpass`, `rate_limit`, `clamp`, `distance`, `bearing`, `angle_diff`, `geo_distance`, `geo_bearing` | Helpers. |
|
|
130
130
|
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
When something goes wrong, the error says what, points at the line of your
|
|
134
|
+
script, says why and how to fix it, and suggests the name you meant:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
error[E0201]: “pitch” is not a stick of a rover
|
|
138
|
+
--> follow_me.py:21
|
|
139
|
+
|
|
|
140
|
+
21 | dev.sticks(throttle=0.4, pitch=steer)
|
|
141
|
+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
142
|
+
|
|
|
143
|
+
= why: sticks are named for the kind of device
|
|
144
|
+
= help: a rover has: throttle, steer, lx, ly, rx, ry
|
|
145
|
+
= docs: https://github.com/koebelt/koebelt_dash/blob/main/libs/python-dash-scripts/ERRORS.md#e0201
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Every code is explained in [ERRORS.md](ERRORS.md). Catch them as `ScriptError`
|
|
149
|
+
(`e.code`, `e.why`, `e.help`).
|
|
150
|
+
|
|
131
151
|
The messages between scripts and dash are documented in
|
|
132
152
|
[protocol/SCRIPTS.md](https://github.com/koebelt/koebelt_dash/blob/main/protocol/SCRIPTS.md).
|
|
133
153
|
|
|
@@ -100,6 +100,26 @@ dash stays in charge, whatever the script does:
|
|
|
100
100
|
| `every(seconds)`, `wait(seconds)` | Steady loops; dash shows the real rate. |
|
|
101
101
|
| `pid`, `lowpass`, `rate_limit`, `clamp`, `distance`, `bearing`, `angle_diff`, `geo_distance`, `geo_bearing` | Helpers. |
|
|
102
102
|
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
When something goes wrong, the error says what, points at the line of your
|
|
106
|
+
script, says why and how to fix it, and suggests the name you meant:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
error[E0201]: “pitch” is not a stick of a rover
|
|
110
|
+
--> follow_me.py:21
|
|
111
|
+
|
|
|
112
|
+
21 | dev.sticks(throttle=0.4, pitch=steer)
|
|
113
|
+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
114
|
+
|
|
|
115
|
+
= why: sticks are named for the kind of device
|
|
116
|
+
= help: a rover has: throttle, steer, lx, ly, rx, ry
|
|
117
|
+
= docs: https://github.com/koebelt/koebelt_dash/blob/main/libs/python-dash-scripts/ERRORS.md#e0201
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Every code is explained in [ERRORS.md](ERRORS.md). Catch them as `ScriptError`
|
|
121
|
+
(`e.code`, `e.why`, `e.help`).
|
|
122
|
+
|
|
103
123
|
The messages between scripts and dash are documented in
|
|
104
124
|
[protocol/SCRIPTS.md](https://github.com/koebelt/koebelt_dash/blob/main/protocol/SCRIPTS.md).
|
|
105
125
|
|
{koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/__init__.py
RENAMED
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
dev.sticks(throttle=0.3)
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
__version__ = "1.3.
|
|
9
|
+
__version__ = "1.3.0b2"
|
|
10
10
|
|
|
11
11
|
from .camera import Camera, CameraError, decode_jpeg # noqa: E402
|
|
12
12
|
from .client import Device, Released, ScriptError, Setting, Telemetry, connect, every, wait # noqa: E402
|
|
13
|
+
from .errors import TelemetryFieldError, install as _install_errors # noqa: E402
|
|
13
14
|
from .tools import angle_diff, bearing, clamp, distance, geo_bearing, geo_distance, lowpass, pid, rate_limit # noqa: E402
|
|
14
15
|
|
|
15
|
-
__all__ = ["connect", "every", "wait", "Device", "Telemetry", "Setting", "ScriptError", "Released", "Camera", "CameraError", "decode_jpeg",
|
|
16
|
+
__all__ = ["connect", "every", "wait", "Device", "Telemetry", "Setting", "ScriptError", "Released", "Camera", "CameraError", "decode_jpeg", "TelemetryFieldError",
|
|
16
17
|
"pid", "lowpass", "rate_limit", "clamp", "distance", "bearing", "angle_diff", "geo_distance", "geo_bearing",
|
|
17
18
|
"__version__"]
|
|
19
|
+
|
|
20
|
+
# Uncaught errors from this library print Rust style (errors.py).
|
|
21
|
+
_install_errors()
|
{koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/camera.py
RENAMED
|
@@ -13,8 +13,11 @@ import urllib.request
|
|
|
13
13
|
from typing import Any, Dict, Iterator, Optional
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
from .errors import ScriptError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CameraError(ScriptError):
|
|
20
|
+
"""A camera problem (E0602, E0603)."""
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
def decode_jpeg(data: bytes):
|
|
@@ -27,13 +30,13 @@ def decode_jpeg(data: bytes):
|
|
|
27
30
|
if cv2 is not None:
|
|
28
31
|
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
|
29
32
|
if img is None:
|
|
30
|
-
raise CameraError("
|
|
33
|
+
raise CameraError("E0602", {"camera": "?", "timeout": 0, "reason": "a frame could not be decoded"})
|
|
31
34
|
return img
|
|
32
35
|
try:
|
|
33
36
|
import numpy as np
|
|
34
37
|
from PIL import Image
|
|
35
38
|
except ImportError:
|
|
36
|
-
raise CameraError("
|
|
39
|
+
raise CameraError("E0603") from None
|
|
37
40
|
return np.asarray(Image.open(io.BytesIO(data)).convert("RGB"))[:, :, ::-1].copy()
|
|
38
41
|
|
|
39
42
|
|
|
@@ -61,7 +64,7 @@ class Camera:
|
|
|
61
64
|
if self._thread is not None:
|
|
62
65
|
return
|
|
63
66
|
if not self.url:
|
|
64
|
-
raise CameraError(
|
|
67
|
+
raise CameraError("E0602", {"camera": self.label, "timeout": 0, "reason": "dash knows no address for it that this computer can reach"})
|
|
65
68
|
self._thread = threading.Thread(target=self._read, daemon=True, name=f"koebelt-dash-camera-{self.id}")
|
|
66
69
|
self._thread.start()
|
|
67
70
|
|
|
@@ -121,7 +124,7 @@ class Camera:
|
|
|
121
124
|
self._start()
|
|
122
125
|
with self._cond:
|
|
123
126
|
if not self._cond.wait_for(lambda: self._jpeg is not None, timeout=timeout):
|
|
124
|
-
raise CameraError(
|
|
127
|
+
raise CameraError("E0602", {"camera": self.label, "timeout": timeout, "reason": self.error or "the stream sent nothing"})
|
|
125
128
|
return self._jpeg # type: ignore[return-value]
|
|
126
129
|
|
|
127
130
|
def frame(self, timeout: float = 5.0):
|
|
@@ -135,7 +138,7 @@ class Camera:
|
|
|
135
138
|
while True:
|
|
136
139
|
with self._cond:
|
|
137
140
|
if not self._cond.wait_for(lambda: self._count != seen and self._jpeg is not None, timeout=timeout):
|
|
138
|
-
raise CameraError(
|
|
141
|
+
raise CameraError("E0602", {"camera": self.label, "timeout": timeout, "reason": self.error or "the stream sent nothing"})
|
|
139
142
|
seen, data = self._count, self._jpeg
|
|
140
143
|
yield decode_jpeg(data) # type: ignore[arg-type]
|
|
141
144
|
|
|
@@ -157,7 +160,7 @@ class _Buffered:
|
|
|
157
160
|
return False
|
|
158
161
|
self.buf += chunk
|
|
159
162
|
if len(self.buf) > 32_000_000:
|
|
160
|
-
raise CameraError("camera stream
|
|
163
|
+
raise CameraError("E0602", {"camera": "?", "timeout": 0, "reason": "the stream has no frame boundaries"})
|
|
161
164
|
return True
|
|
162
165
|
|
|
163
166
|
def readline(self) -> Optional[bytes]:
|
{koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/client.py
RENAMED
|
@@ -16,26 +16,18 @@ import queue
|
|
|
16
16
|
import sys
|
|
17
17
|
import threading
|
|
18
18
|
import time
|
|
19
|
-
import traceback
|
|
20
19
|
from typing import Any, Callable, Dict, Iterator, List, Optional
|
|
21
20
|
|
|
22
21
|
from websockets.exceptions import ConnectionClosed
|
|
23
22
|
from websockets.sync.client import connect as ws_connect
|
|
24
23
|
|
|
25
24
|
from .camera import Camera, boxes_of
|
|
25
|
+
from .errors import Released, ScriptError, TelemetryFieldError, report, warn_once
|
|
26
26
|
|
|
27
27
|
API_VERSION = 1
|
|
28
28
|
DISCOVERY = pathlib.Path.home() / ".koebelt" / "dash-api.json"
|
|
29
29
|
|
|
30
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
31
|
class Telemetry:
|
|
40
32
|
"""The device's latest telemetry. Attributes are the fields dash sends
|
|
41
33
|
(``pos``, ``vel``, ``roll``, ``mode``, ``armed``, ``battery``…, see
|
|
@@ -63,10 +55,12 @@ class Telemetry:
|
|
|
63
55
|
return (self.raw["lat"], self.raw["lon"], self.raw.get("galt", 0.0))
|
|
64
56
|
|
|
65
57
|
def __getattr__(self, name: str):
|
|
58
|
+
if name.startswith("__"):
|
|
59
|
+
raise AttributeError(name)
|
|
66
60
|
try:
|
|
67
61
|
return self.raw[name]
|
|
68
62
|
except KeyError:
|
|
69
|
-
raise
|
|
63
|
+
raise TelemetryFieldError("E0501", {"name": name, "names": sorted([*self.raw, "alt", "gps", "age"])}) from None
|
|
70
64
|
|
|
71
65
|
def __repr__(self):
|
|
72
66
|
keys = ("mode", "armed", "pos", "speed", "battery")
|
|
@@ -145,8 +139,7 @@ def _find_dash(url: Optional[str], token: Optional[str]):
|
|
|
145
139
|
try:
|
|
146
140
|
found = json.loads(DISCOVERY.read_text())
|
|
147
141
|
except (OSError, ValueError):
|
|
148
|
-
raise ScriptError("
|
|
149
|
-
"Start it, or pass url= and token=.") from None
|
|
142
|
+
raise ScriptError("E0010") from None
|
|
150
143
|
return url or found["url"], token or found["token"]
|
|
151
144
|
|
|
152
145
|
|
|
@@ -190,7 +183,7 @@ class Device:
|
|
|
190
183
|
|
|
191
184
|
def _call(self, t: str, timeout: float = 5, **fields) -> Any:
|
|
192
185
|
if self._closed.is_set():
|
|
193
|
-
raise ScriptError("
|
|
186
|
+
raise ScriptError("E0013")
|
|
194
187
|
i = next(self._ids)
|
|
195
188
|
box: "queue.Queue[Dict[str, Any]]" = queue.Queue(1)
|
|
196
189
|
self._waiting[i] = box
|
|
@@ -199,14 +192,15 @@ class Device:
|
|
|
199
192
|
try:
|
|
200
193
|
res = box.get(timeout=timeout)
|
|
201
194
|
except queue.Empty:
|
|
202
|
-
raise ScriptError(
|
|
195
|
+
raise ScriptError("E0014", {"request": t, "timeout": timeout}) from None
|
|
203
196
|
finally:
|
|
204
197
|
self._waiting.pop(i, None)
|
|
205
198
|
if res.get("closed"):
|
|
206
|
-
raise ScriptError("
|
|
199
|
+
raise ScriptError("E0013")
|
|
207
200
|
if not res.get("ok"):
|
|
208
|
-
|
|
209
|
-
|
|
201
|
+
code = res.get("code") or "E0900"
|
|
202
|
+
data = {**(res.get("data") or {}), "message": res.get("err") or "refused"}
|
|
203
|
+
raise (Released if code == "E0102" else ScriptError)(code, data, res.get("err"))
|
|
210
204
|
return res.get("value")
|
|
211
205
|
|
|
212
206
|
def _receive(self) -> None:
|
|
@@ -271,8 +265,8 @@ class Device:
|
|
|
271
265
|
for h in handlers:
|
|
272
266
|
try:
|
|
273
267
|
h(*args)
|
|
274
|
-
except Exception:
|
|
275
|
-
|
|
268
|
+
except Exception as e:
|
|
269
|
+
report(e)
|
|
276
270
|
|
|
277
271
|
def _keepalive(self) -> None:
|
|
278
272
|
while not self._closed.wait(0.1):
|
|
@@ -305,7 +299,7 @@ class Device:
|
|
|
305
299
|
def tlm(self) -> Telemetry:
|
|
306
300
|
"""The latest telemetry (20 Hz)."""
|
|
307
301
|
if self._tlm is None:
|
|
308
|
-
raise ScriptError("
|
|
302
|
+
raise ScriptError("E0502")
|
|
309
303
|
return self._tlm
|
|
310
304
|
|
|
311
305
|
def next_tlm(self, timeout: float = 1.0) -> Telemetry:
|
|
@@ -313,15 +307,21 @@ class Device:
|
|
|
313
307
|
with self._tlm_ready:
|
|
314
308
|
before = self._tlm
|
|
315
309
|
if not self._tlm_ready.wait_for(lambda: self._tlm is not before, timeout=timeout):
|
|
316
|
-
raise ScriptError(
|
|
310
|
+
raise ScriptError("E0502")
|
|
317
311
|
return self._tlm # type: ignore[return-value]
|
|
318
312
|
|
|
319
313
|
# ------------------------------------------------------------------ acting
|
|
320
|
-
def sticks(self, **axes: float) -> None:
|
|
314
|
+
def sticks(self, *values: float, **axes: float) -> None:
|
|
321
315
|
"""Sets the sticks, −1…1, until changed. Drones: ``throttle``, ``yaw``,
|
|
322
316
|
``pitch``, ``roll``; rovers: ``throttle``, ``steer``; any device:
|
|
323
317
|
``lx``, ``ly``, ``rx``, ``ry``. dash scales them to the script's speed
|
|
324
318
|
limit, and the operator's sticks win."""
|
|
319
|
+
if values:
|
|
320
|
+
raise ScriptError("E0202")
|
|
321
|
+
for k, v in list(axes.items()):
|
|
322
|
+
if not -1 <= v <= 1:
|
|
323
|
+
axes[k] = max(-1.0, min(1.0, float(v)))
|
|
324
|
+
warn_once("W0201", k, {"name": k, "value": round(float(v), 3), "clamped": axes[k]})
|
|
325
325
|
self._call("sticks", a=axes)
|
|
326
326
|
|
|
327
327
|
def release(self) -> None:
|
|
@@ -408,7 +408,7 @@ class Device:
|
|
|
408
408
|
def camera(self) -> Camera:
|
|
409
409
|
"""The device's first camera."""
|
|
410
410
|
if not self.cameras:
|
|
411
|
-
raise ScriptError(
|
|
411
|
+
raise ScriptError("E0601", {"device": self.name})
|
|
412
412
|
return self.cameras[0]
|
|
413
413
|
|
|
414
414
|
def overlay(self, boxes: Any, width: Optional[float] = None, height: Optional[float] = None) -> None:
|
|
@@ -422,6 +422,10 @@ class Device:
|
|
|
422
422
|
label: Optional[str] = None) -> Setting:
|
|
423
423
|
"""A slider (numbers) or switch (True/False) in dash. Read ``.value``;
|
|
424
424
|
the operator's last value is kept between runs."""
|
|
425
|
+
if not isinstance(default, (bool, int, float)):
|
|
426
|
+
raise ScriptError("E0701", {"name": name, "reason": f"the default must be a number or True/False, not {type(default).__name__}"})
|
|
427
|
+
if min is not None and max is not None and min >= max:
|
|
428
|
+
raise ScriptError("E0701", {"name": name, "reason": f"min ({min}) must be below max ({max})"})
|
|
425
429
|
value = self._call("setting", name=name, value=default, min=min, max=max, label=label)
|
|
426
430
|
s = self._settings[name] = Setting(name, value)
|
|
427
431
|
return s
|
|
@@ -432,6 +436,9 @@ class Device:
|
|
|
432
436
|
``button`` (no argument), ``switch`` (True/False) or ``value`` (a
|
|
433
437
|
number). ``key`` and ``gamepad`` suggest bindings; the operator can
|
|
434
438
|
change them."""
|
|
439
|
+
if kind not in ("button", "switch", "value"):
|
|
440
|
+
raise ScriptError("E0702", {"name": name, "kind": kind})
|
|
441
|
+
|
|
435
442
|
def register(fn: Callable) -> Callable:
|
|
436
443
|
self._triggers[name] = fn
|
|
437
444
|
self._call("trigger", name=name, kind=kind, key=key, gamepad=gamepad, min=min, max=max)
|
|
@@ -511,13 +518,13 @@ def connect(device: Optional[str] = None, *, url: Optional[str] = None, token: O
|
|
|
511
518
|
try:
|
|
512
519
|
# Entering the context opens it, in every websockets version from 12 on.
|
|
513
520
|
ws = ws_connect(url, open_timeout=5, max_size=None).__enter__()
|
|
514
|
-
except OSError as e:
|
|
515
|
-
raise ScriptError(
|
|
521
|
+
except (OSError, TimeoutError) as e:
|
|
522
|
+
raise ScriptError("E0011", {"url": url, "reason": str(e) or type(e).__name__}) from None
|
|
516
523
|
ws.send(json.dumps({"t": "hello", "v": API_VERSION, "token": token, "name": name, "pid": os.getpid()}))
|
|
517
524
|
welcome = json.loads(ws.recv(timeout=5))
|
|
518
525
|
if welcome.get("t") != "welcome":
|
|
519
526
|
ws.close()
|
|
520
|
-
raise ScriptError(welcome.get("why") or "
|
|
527
|
+
raise ScriptError("E0012", {"reason": welcome.get("why") or "no reason given"})
|
|
521
528
|
dev = Device(ws, welcome, name)
|
|
522
529
|
dev._use(device)
|
|
523
530
|
_current = dev
|
|
@@ -528,6 +535,8 @@ def every(seconds: float) -> Iterator[int]:
|
|
|
528
535
|
"""A steady loop: ``for i in every(0.05): ...`` runs the body every 50 ms
|
|
529
536
|
(or as fast as it can), and reports the real rate to dash."""
|
|
530
537
|
period = float(seconds)
|
|
538
|
+
if period <= 0:
|
|
539
|
+
raise ScriptError("E0703", {"seconds": seconds})
|
|
531
540
|
nxt = time.monotonic()
|
|
532
541
|
window, count = nxt, 0
|
|
533
542
|
for i in itertools.count():
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Errors that say what went wrong, where in your script, why, and what to do.
|
|
2
|
+
|
|
3
|
+
error[E0301]: arming is not allowed for this script
|
|
4
|
+
--> follow_me.py:14
|
|
5
|
+
|
|
|
6
|
+
14 | dev.arm()
|
|
7
|
+
| ^^^^^^^^^
|
|
8
|
+
|
|
|
9
|
+
= why: the script's limits in koebelt dash do not allow arming or take-off
|
|
10
|
+
= help: in dash, open the script, then Limits > Allow arming and take-off
|
|
11
|
+
= docs: https://github.com/koebelt/koebelt_dash/blob/main/libs/python-dash-scripts/ERRORS.md#e0301
|
|
12
|
+
|
|
13
|
+
Every error has a code, explained in ERRORS.md. Uncaught, they print like
|
|
14
|
+
this instead of a traceback through the library (set KOEBELT_DASH_TRACEBACK=1
|
|
15
|
+
for the full one).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
import linecache
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import traceback
|
|
26
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
DOCS = "https://github.com/koebelt/koebelt_dash/blob/main/libs/python-dash-scripts/ERRORS.md"
|
|
29
|
+
_PACKAGE = str(pathlib.Path(__file__).resolve().parent)
|
|
30
|
+
|
|
31
|
+
# code: (title, why, help). Titles and texts are formatted with the error's data.
|
|
32
|
+
CATALOG: Dict[str, Tuple[str, str, str]] = {
|
|
33
|
+
# Connecting
|
|
34
|
+
"E0001": ("no device is connected in koebelt dash",
|
|
35
|
+
"connect() uses the device selected in dash, and none is connected",
|
|
36
|
+
"connect a device in dash (Devices), or a simulated one (Devices > Pair > Simulated)"),
|
|
37
|
+
"E0002": ("no connected device called “{name}”",
|
|
38
|
+
"connect(\"{name}\") looks for a connected device by name or id",
|
|
39
|
+
"{suggest}connected now: {names_list}"),
|
|
40
|
+
"E0003": ("this script has no device yet",
|
|
41
|
+
"commands need a device, and connect() did not get one",
|
|
42
|
+
"use the Device returned by connect()"),
|
|
43
|
+
"E0010": ("koebelt dash is not running on this computer",
|
|
44
|
+
"connect() looks for ~/.koebelt/dash-api.json, which dash writes when it starts",
|
|
45
|
+
"open koebelt dash, or run the script from it (Scripts); on another computer, set KOEBELT_DASH_URL and KOEBELT_DASH_TOKEN"),
|
|
46
|
+
"E0011": ("cannot reach koebelt dash at {url}",
|
|
47
|
+
"{reason}",
|
|
48
|
+
"check that dash is open; a script host needs the phone or computer running dash on the same network"),
|
|
49
|
+
"E0012": ("koebelt dash refused the connection",
|
|
50
|
+
"{reason}",
|
|
51
|
+
"run the script from dash, or restart dash so that ~/.koebelt/dash-api.json is up to date"),
|
|
52
|
+
"E0013": ("the connection to koebelt dash closed",
|
|
53
|
+
"dash was closed, or the script was stopped from it",
|
|
54
|
+
"start the script again once dash is open"),
|
|
55
|
+
"E0014": ("koebelt dash did not answer “{request}” within {timeout} s",
|
|
56
|
+
"dash is busy or stuck, or the network to it dropped",
|
|
57
|
+
"try again; if it keeps happening, restart dash"),
|
|
58
|
+
# Safety: the operator decides
|
|
59
|
+
"E0101": ("STOP is on",
|
|
60
|
+
"the operator pressed STOP, and dash refuses commands until they clear it",
|
|
61
|
+
"clear STOP in dash, then run the script again; a script can react with @dev.on_stop"),
|
|
62
|
+
"E0102": ("this script lost the sticks: {why}",
|
|
63
|
+
"dash took the sticks away for safety, and the script may not take them back by itself",
|
|
64
|
+
"in dash, press Resume on the script (or on Control); handle it in the script with @dev.on_stop"),
|
|
65
|
+
"E0103": ("{other} already drives {device}",
|
|
66
|
+
"one script at a time drives a device's sticks",
|
|
67
|
+
"stop {other} in dash, or connect this script to another device"),
|
|
68
|
+
# Driving
|
|
69
|
+
"E0201": ("“{name}” is not a stick of a {kind}",
|
|
70
|
+
"sticks are named for the kind of device",
|
|
71
|
+
"{suggest}a {kind} has: {names_list}"),
|
|
72
|
+
"E0202": ("sticks() takes named axes",
|
|
73
|
+
"each axis has a name, so a value cannot go to the wrong one",
|
|
74
|
+
"dev.sticks(throttle=0.5, steer=-0.2); a drone has throttle, yaw, pitch, roll"),
|
|
75
|
+
# Limits set in dash
|
|
76
|
+
"E0301": ("arming and take-off are not allowed for this script",
|
|
77
|
+
"the script's limits in koebelt dash do not allow arming or take-off",
|
|
78
|
+
"in dash, open the script, then Limits > Allow arming and take-off; or arm the device yourself first"),
|
|
79
|
+
"E0302": ("parameter writes are not allowed for this script",
|
|
80
|
+
"the script's limits in koebelt dash do not allow writing parameters",
|
|
81
|
+
"in dash, open the script, then Limits > Allow parameter writes"),
|
|
82
|
+
"E0303": ("that point is {distance:.0f} m from home, beyond the {fence:.0f} m geofence",
|
|
83
|
+
"the script's geofence keeps it within {fence:.0f} m of home",
|
|
84
|
+
"aim closer to home, or raise the geofence in dash (the script's Limits)"),
|
|
85
|
+
"E0304": ("{height} m is above the {ceiling:.0f} m ceiling",
|
|
86
|
+
"the script's ceiling keeps drones below {ceiling:.0f} m",
|
|
87
|
+
"fly lower, or raise the ceiling in dash (the script's Limits)"),
|
|
88
|
+
# Steps and programs
|
|
89
|
+
"E0401": ("a {kind} has no “{name}” step",
|
|
90
|
+
"Program steps depend on the kind of device",
|
|
91
|
+
"{suggest}a {kind} has: {names_list}"),
|
|
92
|
+
"E0402": ("no program called “{name}” on {device}",
|
|
93
|
+
"run_program() runs a sequence from dash's Program screen, by name",
|
|
94
|
+
"{suggest}programs on {device}: {names_list}"),
|
|
95
|
+
"E0403": ("“{running}” is already running",
|
|
96
|
+
"a device runs one sequence at a time",
|
|
97
|
+
"wait for it, or dev.stop_program() first"),
|
|
98
|
+
"E0404": ("the device cannot run it now",
|
|
99
|
+
"it is stopped, disconnected, or replaying a recording in dash",
|
|
100
|
+
"check the device in dash"),
|
|
101
|
+
"E0405": ("the step did not finish: {message}",
|
|
102
|
+
"the device, the operator or a safety check ended it",
|
|
103
|
+
"look at what the device is doing in dash; the operator taking the sticks ends a step"),
|
|
104
|
+
"E0406": ("no program is running",
|
|
105
|
+
"pause_program(), resume_program() and stop_program() act on a running one",
|
|
106
|
+
"start one with dev.run_program(\"…\")"),
|
|
107
|
+
# Reading
|
|
108
|
+
"E0501": ("this device sends no “{name}”",
|
|
109
|
+
"telemetry has the fields the device sends",
|
|
110
|
+
"{suggest}it sends: {names_list}"),
|
|
111
|
+
"E0502": ("no telemetry from the device yet",
|
|
112
|
+
"the device has not sent any since the script connected",
|
|
113
|
+
"check that it is connected and its link is up in dash"),
|
|
114
|
+
# Camera
|
|
115
|
+
"E0601": ("{device} has no camera",
|
|
116
|
+
"the device declares no video stream",
|
|
117
|
+
"add one in the firmware: video() in the koebelt/link library (see the Live video guide)"),
|
|
118
|
+
"E0602": ("no picture from camera {camera} within {timeout} s",
|
|
119
|
+
"{reason}",
|
|
120
|
+
"check the camera in dash (Live > CAM); a camera reached by IP needs this computer on the same network"),
|
|
121
|
+
"E0603": ("camera frames need numpy and OpenCV or Pillow",
|
|
122
|
+
"frames are decoded into numpy images",
|
|
123
|
+
"pip install opencv-python (or: pip install pillow numpy)"),
|
|
124
|
+
# Declarations
|
|
125
|
+
"E0701": ("setting “{name}”: {reason}",
|
|
126
|
+
"settings become sliders (numbers) or switches (True/False) in dash",
|
|
127
|
+
"dev.setting(\"speed\", 0.4, min=0, max=1) or dev.setting(\"patrol\", False)"),
|
|
128
|
+
"E0702": ("trigger “{name}”: unknown kind “{kind}”",
|
|
129
|
+
"a trigger is a button, a switch or a value",
|
|
130
|
+
"kind=\"button\", kind=\"switch\" or kind=\"value\""),
|
|
131
|
+
"E0703": ("every() needs a period above 0 s, not {seconds}",
|
|
132
|
+
"it is the time between two turns of the loop",
|
|
133
|
+
"for _ in every(0.05): # 20 times a second"),
|
|
134
|
+
# Anything else dash refuses
|
|
135
|
+
"E0900": ("koebelt dash refused: {message}", "", "if it looks like a bug, report it with the script on GitHub"),
|
|
136
|
+
"E0901": ("koebelt dash does not know “{message}”",
|
|
137
|
+
"this library and dash disagree on the messages",
|
|
138
|
+
"update koebelt dash and koebelt-dash-scripts to matching versions"),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
WARNINGS: Dict[str, Tuple[str, str, str]] = {
|
|
142
|
+
"W0201": ("{name}={value} is outside −1…1: dash uses {clamped}",
|
|
143
|
+
"sticks go from −1 to 1",
|
|
144
|
+
"clamp it yourself (koebelt_dash_scripts.clamp) to silence this"),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _suggestion(name: Any, names: Iterable[str]) -> str:
|
|
149
|
+
close = difflib.get_close_matches(str(name), [str(n) for n in names], n=1, cutoff=0.6)
|
|
150
|
+
return f"did you mean “{close[0]}”? " if close else ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _user_frame() -> Optional[traceback.FrameSummary]:
|
|
154
|
+
"""The innermost frame of the script calling the library."""
|
|
155
|
+
for f in reversed(traceback.extract_stack()):
|
|
156
|
+
if not str(pathlib.Path(f.filename).resolve()).startswith(_PACKAGE) and not f.filename.startswith("<frozen"):
|
|
157
|
+
return f
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ScriptError(RuntimeError):
|
|
162
|
+
"""An error from koebelt-dash-scripts or koebelt dash, with its code."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, code: str, data: Optional[Dict[str, Any]] = None, message: Optional[str] = None):
|
|
165
|
+
self.code = code
|
|
166
|
+
self.data = dict(data or {})
|
|
167
|
+
names = self.data.get("names")
|
|
168
|
+
if names is not None:
|
|
169
|
+
self.data.setdefault("names_list", ", ".join(str(n) for n in names) or "none")
|
|
170
|
+
self.data.setdefault("suggest", _suggestion(self.data.get("name", ""), names))
|
|
171
|
+
self.data.setdefault("suggest", "")
|
|
172
|
+
title, why, help_ = (WARNINGS if code.startswith("W") else CATALOG).get(code, ("{message}", "", ""))
|
|
173
|
+
self.data.setdefault("message", message or "")
|
|
174
|
+
self.title = _fmt(title, self.data) or message or code
|
|
175
|
+
self.why = _fmt(why, self.data)
|
|
176
|
+
self.help = _fmt(help_, self.data)
|
|
177
|
+
self.where = _user_frame()
|
|
178
|
+
super().__init__(self.title)
|
|
179
|
+
|
|
180
|
+
def render(self, color: Optional[bool] = None) -> str:
|
|
181
|
+
return render(self, color=color)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Released(ScriptError):
|
|
185
|
+
"""dash took the sticks away (watchdog, geofence, STOP…) and the operator has not resumed the script yet."""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class TelemetryFieldError(ScriptError, AttributeError):
|
|
189
|
+
"""A telemetry field the device does not send."""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _fmt(t: str, data: Dict[str, Any]) -> str:
|
|
193
|
+
class Safe(dict):
|
|
194
|
+
def __missing__(self, k):
|
|
195
|
+
return "{" + k + "}"
|
|
196
|
+
try:
|
|
197
|
+
return t.format_map(Safe(data))
|
|
198
|
+
except (ValueError, TypeError):
|
|
199
|
+
return t
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _use_color(stream) -> bool:
|
|
203
|
+
if os.environ.get("NO_COLOR"):
|
|
204
|
+
return False
|
|
205
|
+
if os.environ.get("KOEBELT_DASH_COLOR"):
|
|
206
|
+
return True
|
|
207
|
+
return hasattr(stream, "isatty") and stream.isatty()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def render(e: ScriptError, color: Optional[bool] = None) -> str:
|
|
211
|
+
"""The error, Rust style: title, the script's line, why, help, docs."""
|
|
212
|
+
c = _use_color(sys.stderr) if color is None else color
|
|
213
|
+
|
|
214
|
+
def paint(s: str, code: str) -> str:
|
|
215
|
+
return f"\x1b[{code}m{s}\x1b[0m" if c else s
|
|
216
|
+
|
|
217
|
+
kind = "warning" if e.code.startswith("W") else "error"
|
|
218
|
+
head = paint(f"{kind}[{e.code}]", "1;33" if kind == "warning" else "1;31") + paint(f": {e.title}", "1")
|
|
219
|
+
out = [head]
|
|
220
|
+
f = e.where
|
|
221
|
+
if f is not None and f.lineno:
|
|
222
|
+
src = (linecache.getline(f.filename, f.lineno) or "").rstrip("\n")
|
|
223
|
+
n = str(f.lineno)
|
|
224
|
+
pad = " " * len(n)
|
|
225
|
+
out.append(f"{pad}{paint('-->', '1;34')} {os.path.basename(f.filename)}:{f.lineno}")
|
|
226
|
+
if src.strip():
|
|
227
|
+
indent = len(src) - len(src.lstrip())
|
|
228
|
+
out.append(f"{pad} {paint('|', '1;34')}")
|
|
229
|
+
out.append(f"{paint(n, '1;34')} {paint('|', '1;34')} {src}")
|
|
230
|
+
out.append(f"{pad} {paint('|', '1;34')} {' ' * indent}{paint('^' * max(1, len(src.strip())), '1;31' if kind == 'error' else '1;33')}")
|
|
231
|
+
out.append(f"{pad} {paint('|', '1;34')}")
|
|
232
|
+
lead = f"{pad} {paint('=', '1;34')}"
|
|
233
|
+
else:
|
|
234
|
+
lead = f" {paint('=', '1;34')}"
|
|
235
|
+
if e.why:
|
|
236
|
+
out.append(f"{lead} {paint('why', '1')}: {e.why}")
|
|
237
|
+
if e.help:
|
|
238
|
+
out.append(f"{lead} {paint('help', '1;32')}: {e.help}")
|
|
239
|
+
out.append(f"{lead} {paint('docs', '1')}: {DOCS}#{e.code.lower()}")
|
|
240
|
+
return "\n".join(out)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
_warned = set()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def warn_once(code: str, key: Any, data: Dict[str, Any]) -> None:
|
|
247
|
+
"""A warning, once per place in the script and key."""
|
|
248
|
+
w = ScriptError(code, data)
|
|
249
|
+
at = (code, key, w.where.filename if w.where else None, w.where.lineno if w.where else None)
|
|
250
|
+
if at in _warned:
|
|
251
|
+
return
|
|
252
|
+
_warned.add(at)
|
|
253
|
+
print(render(w), file=sys.stderr, flush=True)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _hook(kind, value, tb):
|
|
257
|
+
if isinstance(value, ScriptError) and not os.environ.get("KOEBELT_DASH_TRACEBACK"):
|
|
258
|
+
# The call chain in the script, without the library's frames.
|
|
259
|
+
chain = [f for f in traceback.extract_tb(tb) if not str(pathlib.Path(f.filename).resolve()).startswith(_PACKAGE)]
|
|
260
|
+
if value.where is None and chain:
|
|
261
|
+
value.where = chain[-1]
|
|
262
|
+
print(render(value), file=sys.stderr, flush=True)
|
|
263
|
+
if len(chain) > 1:
|
|
264
|
+
print("\n called from: " + " ← ".join(f"{os.path.basename(f.filename)}:{f.lineno} {f.name}()" for f in reversed(chain[:-1])), file=sys.stderr)
|
|
265
|
+
return
|
|
266
|
+
_previous(kind, value, tb)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _thread_hook(args):
|
|
270
|
+
if isinstance(args.exc_value, ScriptError):
|
|
271
|
+
_hook(args.exc_type, args.exc_value, args.exc_traceback)
|
|
272
|
+
else:
|
|
273
|
+
_previous_thread(args)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_previous = sys.excepthook
|
|
277
|
+
_previous_thread = threading.excepthook
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def install() -> None:
|
|
281
|
+
"""Prints this library's errors Rust style when the script does not catch them."""
|
|
282
|
+
if sys.excepthook is not _hook:
|
|
283
|
+
sys.excepthook = _hook
|
|
284
|
+
threading.excepthook = _thread_hook
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def report(exc: BaseException) -> None:
|
|
288
|
+
"""For handlers: print an exception the library caught, readably."""
|
|
289
|
+
if isinstance(exc, ScriptError):
|
|
290
|
+
print(render(exc), file=sys.stderr, flush=True)
|
|
291
|
+
else:
|
|
292
|
+
tb = [f for f in traceback.extract_tb(exc.__traceback__) if not str(pathlib.Path(f.filename).resolve()).startswith(_PACKAGE)]
|
|
293
|
+
print("".join(traceback.format_list(tb)) + "".join(traceback.format_exception_only(type(exc), exc)), file=sys.stderr, end="", flush=True)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def codes() -> List[str]:
|
|
297
|
+
return sorted(CATALOG) + sorted(WARNINGS)
|
{koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: koebelt-dash-scripts
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.0b2
|
|
4
4
|
Summary: Control drones, robots and test rigs connected in the koebelt/dash app from Python.
|
|
5
5
|
Author-email: Thomas Koebel <contact@koebelt.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -128,6 +128,26 @@ dash stays in charge, whatever the script does:
|
|
|
128
128
|
| `every(seconds)`, `wait(seconds)` | Steady loops; dash shows the real rate. |
|
|
129
129
|
| `pid`, `lowpass`, `rate_limit`, `clamp`, `distance`, `bearing`, `angle_diff`, `geo_distance`, `geo_bearing` | Helpers. |
|
|
130
130
|
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
When something goes wrong, the error says what, points at the line of your
|
|
134
|
+
script, says why and how to fix it, and suggests the name you meant:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
error[E0201]: “pitch” is not a stick of a rover
|
|
138
|
+
--> follow_me.py:21
|
|
139
|
+
|
|
|
140
|
+
21 | dev.sticks(throttle=0.4, pitch=steer)
|
|
141
|
+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
142
|
+
|
|
|
143
|
+
= why: sticks are named for the kind of device
|
|
144
|
+
= help: a rover has: throttle, steer, lx, ly, rx, ry
|
|
145
|
+
= docs: https://github.com/koebelt/koebelt_dash/blob/main/libs/python-dash-scripts/ERRORS.md#e0201
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Every code is explained in [ERRORS.md](ERRORS.md). Catch them as `ScriptError`
|
|
149
|
+
(`e.code`, `e.why`, `e.help`).
|
|
150
|
+
|
|
131
151
|
The messages between scripts and dash are documented in
|
|
132
152
|
[protocol/SCRIPTS.md](https://github.com/koebelt/koebelt_dash/blob/main/protocol/SCRIPTS.md).
|
|
133
153
|
|
|
@@ -5,6 +5,7 @@ koebelt_dash_scripts/__init__.py
|
|
|
5
5
|
koebelt_dash_scripts/__main__.py
|
|
6
6
|
koebelt_dash_scripts/camera.py
|
|
7
7
|
koebelt_dash_scripts/client.py
|
|
8
|
+
koebelt_dash_scripts/errors.py
|
|
8
9
|
koebelt_dash_scripts/host.py
|
|
9
10
|
koebelt_dash_scripts/py.typed
|
|
10
11
|
koebelt_dash_scripts/tools.py
|
|
@@ -16,5 +17,6 @@ koebelt_dash_scripts.egg-info/requires.txt
|
|
|
16
17
|
koebelt_dash_scripts.egg-info/top_level.txt
|
|
17
18
|
tests/test_camera.py
|
|
18
19
|
tests/test_client.py
|
|
20
|
+
tests/test_errors.py
|
|
19
21
|
tests/test_host.py
|
|
20
22
|
tests/test_tools.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "koebelt-dash-scripts"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.0b2"
|
|
8
8
|
description = "Control drones, robots and test rigs connected in the koebelt/dash app from Python."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -57,8 +57,9 @@ def test_reads_multipart_frames_whatever_their_type(stream):
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def test_a_camera_without_a_url():
|
|
60
|
-
with pytest.raises(RuntimeError
|
|
60
|
+
with pytest.raises(RuntimeError) as e:
|
|
61
61
|
Camera({"id": "cam", "url": None}).jpeg(timeout=0.1)
|
|
62
|
+
assert e.value.code == "E0602" and "address" in e.value.why
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
class _T:
|
|
@@ -54,9 +54,12 @@ class FakeDash:
|
|
|
54
54
|
elif m["t"] == "setting":
|
|
55
55
|
res["value"] = 0.7 if m["name"] == "kept" else m["value"]
|
|
56
56
|
elif m["t"] == "sticks" and self.refuse_sticks:
|
|
57
|
-
res = {"t": "res", "id": m["id"], "ok": False, "err": self.refuse_sticks}
|
|
57
|
+
res = {"t": "res", "id": m["id"], "ok": False, "err": self.refuse_sticks, "code": "E0102", "data": {"why": "Mule left the 50 m geofence: it holds."}}
|
|
58
58
|
elif m["t"] == "cmd" and m["c"] == "arm":
|
|
59
|
-
res = {"t": "res", "id": m["id"], "ok": False, "err": "Arming is not allowed for this script (Limits in dash)."}
|
|
59
|
+
res = {"t": "res", "id": m["id"], "ok": False, "err": "Arming is not allowed for this script (Limits in dash).", "code": "E0301"}
|
|
60
|
+
elif m["t"] == "sticks" and "pitch" in m["a"]:
|
|
61
|
+
res = {"t": "res", "id": m["id"], "ok": False, "err": "not a stick", "code": "E0201",
|
|
62
|
+
"data": {"name": "pitch", "names": ["throttle", "steer", "lx", "ly", "rx", "ry"], "kind": "rover"}}
|
|
60
63
|
elif m["t"] == "cmd" and m["c"] == "params":
|
|
61
64
|
res["value"] = {"MAX_SPD": 2.0}
|
|
62
65
|
ws.send(json.dumps(res))
|
|
@@ -92,16 +95,21 @@ def test_finds_dash_and_uses_the_selected_device(dash):
|
|
|
92
95
|
assert dash.hello["name"] == "test.py" and dash.hello["v"] == 1
|
|
93
96
|
assert (dev.name, dev.kind, dev.simulated) == ("Mule", "rover", True)
|
|
94
97
|
assert dev.tlm.alt == 3 and dev.tlm.mode == "AUTO" and dev.tlm.battery == 80
|
|
95
|
-
with pytest.raises(AttributeError
|
|
98
|
+
with pytest.raises(AttributeError) as e:
|
|
96
99
|
dev.tlm.lidar
|
|
100
|
+
assert e.value.code == "E0501" and "lidar" in e.value.title
|
|
101
|
+
with pytest.raises(AttributeError) as e:
|
|
102
|
+
dev.tlm.batery
|
|
103
|
+
assert "did you mean “battery”" in e.value.help
|
|
97
104
|
dev.close()
|
|
98
105
|
|
|
99
106
|
|
|
100
107
|
def test_environment_wins_over_the_discovery_file(dash, monkeypatch):
|
|
101
108
|
monkeypatch.setenv("KOEBELT_DASH_URL", dash.url)
|
|
102
109
|
monkeypatch.setenv("KOEBELT_DASH_TOKEN", "wrong")
|
|
103
|
-
with pytest.raises(k.ScriptError
|
|
110
|
+
with pytest.raises(k.ScriptError) as e:
|
|
104
111
|
k.connect()
|
|
112
|
+
assert e.value.code == "E0012" and "token" in e.value.why
|
|
105
113
|
|
|
106
114
|
|
|
107
115
|
def test_no_dash_running(tmp_path, monkeypatch):
|
|
@@ -190,8 +198,9 @@ def test_released_and_takeover(dash):
|
|
|
190
198
|
dash.send({"t": "released", "why": "Mule left the 50 m geofence: it holds."})
|
|
191
199
|
assert wait_for(lambda: stopped and dev.released)
|
|
192
200
|
dash.refuse_sticks = "Mule left the 50 m geofence: it holds. The operator must resume the script in dash."
|
|
193
|
-
with pytest.raises(k.Released
|
|
201
|
+
with pytest.raises(k.Released) as e:
|
|
194
202
|
dev.sticks(throttle=1)
|
|
203
|
+
assert "Resume" in e.value.help
|
|
195
204
|
dash.send({"t": "resumed"})
|
|
196
205
|
assert wait_for(lambda: dev.released is None)
|
|
197
206
|
dev.close()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Errors read like Rust's: what, where in the script, why, help, docs."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import textwrap
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import koebelt_dash_scripts as k
|
|
11
|
+
from koebelt_dash_scripts.errors import CATALOG, WARNINGS, ScriptError, render
|
|
12
|
+
from test_client import FakeDash, dash # noqa: F401 (fixture)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_points_at_the_scripts_line_with_why_help_and_docs(dash): # noqa: F811
|
|
16
|
+
dev = k.connect()
|
|
17
|
+
with pytest.raises(k.ScriptError) as e:
|
|
18
|
+
dev.arm()
|
|
19
|
+
text = render(e.value, color=False)
|
|
20
|
+
lines = text.splitlines()
|
|
21
|
+
assert lines[0] == "error[E0301]: arming and take-off are not allowed for this script"
|
|
22
|
+
assert lines[1].strip().startswith("--> test_errors.py:")
|
|
23
|
+
assert "dev.arm()" in text and "^^^^^^^^^" in text
|
|
24
|
+
assert "= why: the script's limits in koebelt dash do not allow arming or take-off" in text
|
|
25
|
+
assert "= help: in dash, open the script, then Limits > Allow arming and take-off" in text
|
|
26
|
+
assert text.endswith("ERRORS.md#e0301")
|
|
27
|
+
dev.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_suggests_the_name_you_meant(dash): # noqa: F811
|
|
31
|
+
dev = k.connect()
|
|
32
|
+
with pytest.raises(k.ScriptError) as e:
|
|
33
|
+
dev.sticks(throttle=0.2, pitch=1)
|
|
34
|
+
assert e.value.code == "E0201"
|
|
35
|
+
assert e.value.title == "“pitch” is not a stick of a rover"
|
|
36
|
+
assert e.value.help.startswith("a rover has: throttle, steer")
|
|
37
|
+
with pytest.raises(k.ScriptError) as e:
|
|
38
|
+
dev.sticks(0.5, 0.2)
|
|
39
|
+
assert e.value.code == "E0202"
|
|
40
|
+
dev.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_warns_once_when_a_stick_is_out_of_range(dash, capsys): # noqa: F811
|
|
44
|
+
dev = k.connect()
|
|
45
|
+
for _ in range(3):
|
|
46
|
+
dev.sticks(throttle=1.7)
|
|
47
|
+
err = capsys.readouterr().err
|
|
48
|
+
assert err.count("warning[W0201]: throttle=1.7 is outside −1…1: dash uses 1.0") == 1
|
|
49
|
+
assert dash.got[-1]["a"] == {"throttle": 1.0}
|
|
50
|
+
dev.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_mistakes_caught_before_dash(dash): # noqa: F811
|
|
54
|
+
dev = k.connect()
|
|
55
|
+
with pytest.raises(k.ScriptError) as e:
|
|
56
|
+
dev.setting("speed", 0.4, min=1, max=0)
|
|
57
|
+
assert e.value.code == "E0701" and "min (1) must be below max (0)" in e.value.title
|
|
58
|
+
with pytest.raises(k.ScriptError) as e:
|
|
59
|
+
dev.trigger("Go", kind="lever")
|
|
60
|
+
assert e.value.code == "E0702"
|
|
61
|
+
with pytest.raises(k.ScriptError) as e:
|
|
62
|
+
next(k.every(0))
|
|
63
|
+
assert e.value.code == "E0703"
|
|
64
|
+
dev.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_every_code_is_documented():
|
|
68
|
+
with open(os.path.join(os.path.dirname(__file__), "..", "ERRORS.md"), encoding="utf-8") as f:
|
|
69
|
+
doc = f.read()
|
|
70
|
+
for code in [*CATALOG, *WARNINGS]:
|
|
71
|
+
assert f"## {code}" in doc, f"ERRORS.md has no section for {code}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_an_uncaught_error_prints_rust_style_without_the_librarys_traceback(tmp_path):
|
|
75
|
+
script = tmp_path / "my_script.py"
|
|
76
|
+
script.write_text(textwrap.dedent('''\
|
|
77
|
+
import koebelt_dash_scripts as k
|
|
78
|
+
|
|
79
|
+
def go():
|
|
80
|
+
k.connect()
|
|
81
|
+
|
|
82
|
+
go()
|
|
83
|
+
'''))
|
|
84
|
+
env = dict(os.environ, HOME=str(tmp_path), NO_COLOR="1")
|
|
85
|
+
env.pop("KOEBELT_DASH_URL", None)
|
|
86
|
+
r = subprocess.run([sys.executable, str(script)], capture_output=True, text=True, env=env)
|
|
87
|
+
assert r.returncode == 1
|
|
88
|
+
assert r.stderr.startswith("error[E0010]: koebelt dash is not running on this computer")
|
|
89
|
+
assert "--> my_script.py:4" in r.stderr and "k.connect()" in r.stderr
|
|
90
|
+
assert "called from: my_script.py:6 <module>()" in r.stderr
|
|
91
|
+
assert "Traceback" not in r.stderr and "client.py" not in r.stderr
|
|
File without changes
|
{koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|