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.
Files changed (24) hide show
  1. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/PKG-INFO +21 -1
  2. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/README.md +20 -0
  3. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/__init__.py +6 -2
  4. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/camera.py +11 -8
  5. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/client.py +35 -26
  6. koebelt_dash_scripts-1.3.0b2/koebelt_dash_scripts/errors.py +297 -0
  7. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/PKG-INFO +21 -1
  8. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/SOURCES.txt +2 -0
  9. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/pyproject.toml +1 -1
  10. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/tests/test_camera.py +2 -1
  11. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/tests/test_client.py +14 -5
  12. koebelt_dash_scripts-1.3.0b2/tests/test_errors.py +91 -0
  13. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/LICENSE +0 -0
  14. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/__main__.py +0 -0
  15. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/host.py +0 -0
  16. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/py.typed +0 -0
  17. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts/tools.py +0 -0
  18. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/dependency_links.txt +0 -0
  19. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/entry_points.txt +0 -0
  20. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/requires.txt +0 -0
  21. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/koebelt_dash_scripts.egg-info/top_level.txt +0 -0
  22. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/setup.cfg +0 -0
  23. {koebelt_dash_scripts-1.3.0b1 → koebelt_dash_scripts-1.3.0b2}/tests/test_host.py +0 -0
  24. {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.0b1
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
 
@@ -6,12 +6,16 @@
6
6
  dev.sticks(throttle=0.3)
7
7
  """
8
8
 
9
- __version__ = "1.3.0b1"
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()
@@ -13,8 +13,11 @@ import urllib.request
13
13
  from typing import Any, Dict, Iterator, Optional
14
14
 
15
15
 
16
- class CameraError(RuntimeError):
17
- pass
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("could not decode a camera frame")
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("camera frames need numpy and OpenCV (pip install opencv-python) or Pillow (pip install pillow)") from None
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(f"camera {self.label} is not reachable from this computer")
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(f"no picture from camera {self.label} within {timeout} s" + (f": {self.error}" if self.error else ""))
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(f"no picture from camera {self.label} within {timeout} s" + (f": {self.error}" if self.error else ""))
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 without frame boundaries")
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]:
@@ -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 AttributeError(f"this device sends no '{name}' (it sends: {', '.join(sorted(self.raw))})") from None
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("koebelt dash is not running on this computer (no ~/.koebelt/dash-api.json). "
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("the connection to koebelt dash is closed")
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(f"koebelt dash did not answer '{t}' within {timeout} s") from None
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("the connection to koebelt dash closed")
199
+ raise ScriptError("E0013")
207
200
  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)
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
- traceback.print_exc()
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("no telemetry from the device yet")
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(f"no telemetry for {timeout} s")
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(f"{self.name} has no camera")
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(f"cannot reach koebelt dash at {url}: {e}") from None
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 "koebelt dash refused the connection")
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: koebelt-dash-scripts
3
- Version: 1.3.0b1
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.0b1"
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, match="not reachable"):
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, match="sends no 'lidar'"):
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, match="token"):
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, match="resume"):
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