erdilrf-lrf 0.2.0__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.
- erdilrf_lrf/__init__.py +63 -0
- erdilrf_lrf/cli.py +216 -0
- erdilrf_lrf/device.py +274 -0
- erdilrf_lrf/frames.py +340 -0
- erdilrf_lrf-0.2.0.dist-info/METADATA +144 -0
- erdilrf_lrf-0.2.0.dist-info/RECORD +10 -0
- erdilrf_lrf-0.2.0.dist-info/WHEEL +5 -0
- erdilrf_lrf-0.2.0.dist-info/entry_points.txt +2 -0
- erdilrf_lrf-0.2.0.dist-info/licenses/LICENSE +21 -0
- erdilrf_lrf-0.2.0.dist-info/top_level.txt +1 -0
erdilrf_lrf/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
erdilrf_lrf — host-side driver for ERDI 905 nm laser rangefinder modules.
|
|
3
|
+
|
|
4
|
+
Framing and protocol: [`frames`][erdilrf_lrf.frames].
|
|
5
|
+
High-level device access: [`device`][erdilrf_lrf.device].
|
|
6
|
+
|
|
7
|
+
The protocol implemented here is documented in `protocol/PROTOCOL.md` and was derived from
|
|
8
|
+
the manufacturer's LR1000E2 user manual v1.2, with every published frame recomputed from the
|
|
9
|
+
stated checksum rule before release.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .frames import (
|
|
15
|
+
BAUD_BY_CODE,
|
|
16
|
+
BAUD_CODES,
|
|
17
|
+
CMD_ANGLE,
|
|
18
|
+
CMD_CONTINUOUS,
|
|
19
|
+
CMD_LD_OFF,
|
|
20
|
+
CMD_LD_ON,
|
|
21
|
+
CMD_SINGLE_SHOT,
|
|
22
|
+
CMD_STOP,
|
|
23
|
+
FRAME_LEN,
|
|
24
|
+
Frame,
|
|
25
|
+
FrameReader,
|
|
26
|
+
ProtocolError,
|
|
27
|
+
build_command,
|
|
28
|
+
checksum_response,
|
|
29
|
+
checksum_send,
|
|
30
|
+
decode_angle,
|
|
31
|
+
decode_distance,
|
|
32
|
+
decode_distance_or_none,
|
|
33
|
+
parse_frame,
|
|
34
|
+
set_baud_command,
|
|
35
|
+
summarise,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__version__ = "0.2.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"BAUD_BY_CODE",
|
|
42
|
+
"BAUD_CODES",
|
|
43
|
+
"CMD_ANGLE",
|
|
44
|
+
"CMD_CONTINUOUS",
|
|
45
|
+
"CMD_LD_OFF",
|
|
46
|
+
"CMD_LD_ON",
|
|
47
|
+
"CMD_SINGLE_SHOT",
|
|
48
|
+
"CMD_STOP",
|
|
49
|
+
"FRAME_LEN",
|
|
50
|
+
"Frame",
|
|
51
|
+
"FrameReader",
|
|
52
|
+
"ProtocolError",
|
|
53
|
+
"build_command",
|
|
54
|
+
"checksum_response",
|
|
55
|
+
"checksum_send",
|
|
56
|
+
"decode_angle",
|
|
57
|
+
"decode_distance",
|
|
58
|
+
"decode_distance_or_none",
|
|
59
|
+
"parse_frame",
|
|
60
|
+
"set_baud_command",
|
|
61
|
+
"summarise",
|
|
62
|
+
"__version__",
|
|
63
|
+
]
|
erdilrf_lrf/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
erdilrf_lrf.cli — command-line access to an ERDI 905 nm rangefinder module.
|
|
3
|
+
|
|
4
|
+
Two of these commands need no hardware at all, which is deliberate:
|
|
5
|
+
|
|
6
|
+
erdilrf frames print the documented command table
|
|
7
|
+
erdilrf decode <hex> decode one captured frame
|
|
8
|
+
|
|
9
|
+
Support engineers spend a lot of time asking customers "paste me the bytes you saw". `decode`
|
|
10
|
+
answers that without a module, a driver install, or a working serial port.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Sequence
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from . import frames as F
|
|
21
|
+
from .device import DeviceTimeout, Rangefinder, SerialTransport
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hex(b: bytes) -> str:
|
|
25
|
+
return b.hex(" ").upper()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------- offline commands
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_frames(_args: argparse.Namespace) -> int:
|
|
32
|
+
rows = [
|
|
33
|
+
("single-shot ranging", F.CMD_SINGLE_SHOT, "55 AA 88 STA FF DIS_H DIS_L SUM"),
|
|
34
|
+
("continuous ranging", F.CMD_CONTINUOUS, "55 AA 89 STA FF DIS_H DIS_L SUM"),
|
|
35
|
+
("stop ranging", F.CMD_STOP, "55 AA 8E STA FF FF FF SUM"),
|
|
36
|
+
("angle measurement", F.CMD_ANGLE, "55 AA 8A STA FF ANG_H ANG_L SUM"),
|
|
37
|
+
("LD constant-on: enable", F.CMD_LD_ON, "(response frame not published)"),
|
|
38
|
+
("LD constant-on: disable", F.CMD_LD_OFF, "(response frame not published)"),
|
|
39
|
+
]
|
|
40
|
+
print(f"ERDI 905 nm rangefinder protocol - erdilrf-lrf {__version__}\n")
|
|
41
|
+
print(f"{'command':26s} {'transmit (8 bytes)':26s} response")
|
|
42
|
+
print("-" * 92)
|
|
43
|
+
for name, tx, rx in rows:
|
|
44
|
+
print(f"{name:26s} {_hex(tx):26s} {rx}")
|
|
45
|
+
print()
|
|
46
|
+
print("checksum, host -> module : (FUNC + D1 + D2 + D3 + D4) & 0xFF")
|
|
47
|
+
print("checksum, module -> host : (55 + AA + FUNC + D1 + D2 + D3 + D4) & 0xFF")
|
|
48
|
+
print("distance : ((DIS_H << 8) | DIS_L) / 10 metres")
|
|
49
|
+
print()
|
|
50
|
+
print("set baud rate : 55 AA TYPE FF FF FF FF SUM")
|
|
51
|
+
print(" " + " ".join(f"{c:02X}={b}" for b, c in sorted(F.BAUD_CODES.items(), key=lambda kv: kv[1])))
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_decode(args: argparse.Namespace) -> int:
|
|
56
|
+
text = " ".join(args.hex).replace(",", " ").replace("0x", " ").replace("0X", " ")
|
|
57
|
+
try:
|
|
58
|
+
data = bytes(int(tok, 16) for tok in text.split())
|
|
59
|
+
except ValueError as exc:
|
|
60
|
+
print(f"error: could not read hex bytes: {exc}", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
direction = args.direction
|
|
63
|
+
try:
|
|
64
|
+
frame = F.parse_frame(data, direction=direction, verify_checksum=not args.no_checksum)
|
|
65
|
+
except F.ProtocolError as exc:
|
|
66
|
+
print(f"INVALID: {exc}", file=sys.stderr)
|
|
67
|
+
if len(data) == F.FRAME_LEN and not args.no_checksum:
|
|
68
|
+
alt = "command" if direction == "response" else "response"
|
|
69
|
+
try:
|
|
70
|
+
F.parse_frame(data, direction=alt)
|
|
71
|
+
except F.ProtocolError:
|
|
72
|
+
pass
|
|
73
|
+
else:
|
|
74
|
+
print(f"hint: this frame is valid as a {alt} frame. "
|
|
75
|
+
f"Re-run with --direction {alt}.", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
s = F.summarise(frame)
|
|
79
|
+
print(f"raw : {_hex(frame.raw)}")
|
|
80
|
+
print(f"direction : {frame.direction}")
|
|
81
|
+
print(f"func : 0x{frame.func:02X} ({s.name})")
|
|
82
|
+
print(f"D1..D4 : {frame.d1:02X} {frame.d2:02X} {frame.d3:02X} {frame.d4:02X}")
|
|
83
|
+
print(f"checksum : 0x{frame.checksum:02X} (recomputed 0x{frame.expected_checksum:02X}) "
|
|
84
|
+
f"{'OK' if frame.checksum_ok else 'MISMATCH'}")
|
|
85
|
+
print(f"STA : 0x{s.sta:02X} {s.sta_meaning}")
|
|
86
|
+
if s.detail:
|
|
87
|
+
print(f"detail : {s.detail}")
|
|
88
|
+
if frame.is_self_test and frame.sta != F.STA_SUCCESS:
|
|
89
|
+
print(f"error code : 0x{frame.error_code:02X}")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------- hardware commands
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _open(args: argparse.Namespace) -> Rangefinder:
|
|
97
|
+
return Rangefinder(SerialTransport(args.port, args.baud, parity=args.parity,
|
|
98
|
+
stopbits=args.stopbits),
|
|
99
|
+
timeout=args.timeout)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_ports(_args: argparse.Namespace) -> int:
|
|
103
|
+
ports = SerialTransport.list_ports()
|
|
104
|
+
if not ports:
|
|
105
|
+
print("no serial ports found (or pyserial is not installed: pip install pyserial)")
|
|
106
|
+
return 1
|
|
107
|
+
for p in ports:
|
|
108
|
+
print(p)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_measure(args: argparse.Namespace) -> int:
|
|
113
|
+
with _open(args) as rf:
|
|
114
|
+
for i in range(args.count):
|
|
115
|
+
try:
|
|
116
|
+
d = rf.single_shot()
|
|
117
|
+
except DeviceTimeout as exc:
|
|
118
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
print("no return (shot failed)" if d is None else f"{d:.1f} m")
|
|
121
|
+
if i + 1 < args.count:
|
|
122
|
+
import time
|
|
123
|
+
time.sleep(args.interval)
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_stream(args: argparse.Namespace) -> int:
|
|
128
|
+
with _open(args) as rf:
|
|
129
|
+
try:
|
|
130
|
+
for d in rf.stream(count=args.count):
|
|
131
|
+
print("--" if d is None else f"{d:.1f} m")
|
|
132
|
+
except (DeviceTimeout, KeyboardInterrupt):
|
|
133
|
+
print("\nstopped")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_selftest(args: argparse.Namespace) -> int:
|
|
138
|
+
"""
|
|
139
|
+
Read the unsolicited power-on self-test.
|
|
140
|
+
|
|
141
|
+
It is emitted once, at power-on, so this is only meaningful on a freshly powered module. If it
|
|
142
|
+
returns nothing, power-cycle the module and run this again — that is the documented cause, not
|
|
143
|
+
a defect in the tool.
|
|
144
|
+
"""
|
|
145
|
+
with _open(args) as rf:
|
|
146
|
+
frame = rf.read_self_test()
|
|
147
|
+
if frame is None:
|
|
148
|
+
print("no self-test frame seen. It is sent once at power-on: "
|
|
149
|
+
"power-cycle the module and retry immediately.", file=sys.stderr)
|
|
150
|
+
return 1
|
|
151
|
+
s = F.summarise(frame)
|
|
152
|
+
print(f"{s.name}: {s.sta_meaning} {s.detail}".rstrip())
|
|
153
|
+
return 0 if frame.sta == F.STA_SUCCESS else 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------- wiring
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _add_serial_opts(p: argparse.ArgumentParser) -> None:
|
|
160
|
+
p.add_argument("--port", required=True, help="serial port, e.g. COM3 or /dev/ttyUSB0")
|
|
161
|
+
p.add_argument("--baud", type=int, default=115200)
|
|
162
|
+
p.add_argument("--parity", default="N", choices=["N", "E", "O"],
|
|
163
|
+
help="the manual does not state parity; 8N1 is an assumption")
|
|
164
|
+
p.add_argument("--stopbits", type=float, default=1, choices=[1, 1.5, 2])
|
|
165
|
+
p.add_argument("--timeout", type=float, default=1.0)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
169
|
+
ap = argparse.ArgumentParser(
|
|
170
|
+
prog="erdilrf",
|
|
171
|
+
description="Host driver for ERDI 905 nm laser rangefinder modules.")
|
|
172
|
+
ap.add_argument("--version", action="version", version=f"erdilrf-lrf {__version__}")
|
|
173
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
174
|
+
|
|
175
|
+
sub.add_parser("frames", help="print the documented command table (no hardware)") \
|
|
176
|
+
.set_defaults(func=cmd_frames)
|
|
177
|
+
|
|
178
|
+
d = sub.add_parser("decode", help="decode captured hex bytes (no hardware)")
|
|
179
|
+
d.add_argument("hex", nargs="+", help="8 hex bytes, e.g. '55 AA 88 01 FF 00 64 8A'")
|
|
180
|
+
d.add_argument("--direction", default="response", choices=list(F.DIRECTIONS))
|
|
181
|
+
d.add_argument("--no-checksum", action="store_true", help="decode even if the checksum is bad")
|
|
182
|
+
d.set_defaults(func=cmd_decode)
|
|
183
|
+
|
|
184
|
+
sub.add_parser("ports", help="list serial ports").set_defaults(func=cmd_ports)
|
|
185
|
+
|
|
186
|
+
m = sub.add_parser("measure", help="trigger single-shot measurements")
|
|
187
|
+
_add_serial_opts(m)
|
|
188
|
+
m.add_argument("--count", type=int, default=1)
|
|
189
|
+
m.add_argument("--interval", type=float, default=0.3)
|
|
190
|
+
m.set_defaults(func=cmd_measure)
|
|
191
|
+
|
|
192
|
+
s = sub.add_parser("stream", help="continuous ranging until stopped")
|
|
193
|
+
_add_serial_opts(s)
|
|
194
|
+
s.add_argument("--count", type=int, default=None)
|
|
195
|
+
s.set_defaults(func=cmd_stream)
|
|
196
|
+
|
|
197
|
+
t = sub.add_parser("selftest", help="read the power-on self-test frame")
|
|
198
|
+
_add_serial_opts(t)
|
|
199
|
+
t.set_defaults(func=cmd_selftest)
|
|
200
|
+
|
|
201
|
+
return ap
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
205
|
+
args = build_parser().parse_args(argv)
|
|
206
|
+
try:
|
|
207
|
+
return args.func(args)
|
|
208
|
+
except ImportError as exc:
|
|
209
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
210
|
+
return 2
|
|
211
|
+
except KeyboardInterrupt:
|
|
212
|
+
return 130
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
sys.exit(main())
|
erdilrf_lrf/device.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
erdilrf_lrf.device — high-level host access to an ERDI 905 nm rangefinder module.
|
|
3
|
+
|
|
4
|
+
The module deliberately depends on nothing but the standard library. Serial I/O is injected as a
|
|
5
|
+
small duck-typed transport, which means:
|
|
6
|
+
|
|
7
|
+
* the whole device layer is testable without hardware (see `tests/test_device.py`);
|
|
8
|
+
* you can bring your own link — pyserial, a socket, an MCU bridge, a recorded capture.
|
|
9
|
+
|
|
10
|
+
`SerialTransport` is provided for the common case and imports pyserial lazily, so importing this
|
|
11
|
+
module never requires it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from collections import deque
|
|
18
|
+
from typing import Iterable, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from . import frames as F
|
|
21
|
+
|
|
22
|
+
__all__ = ["Transport", "SerialTransport", "Rangefinder", "DeviceTimeout"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DeviceTimeout(TimeoutError):
|
|
26
|
+
"""No matching frame arrived within the timeout."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class Transport(Protocol):
|
|
31
|
+
"""Minimal link the driver needs. Implement these two methods and nothing else."""
|
|
32
|
+
|
|
33
|
+
def write(self, data: bytes) -> int:
|
|
34
|
+
"""Send bytes to the module. Return the number accepted."""
|
|
35
|
+
|
|
36
|
+
def read(self, size: int) -> bytes:
|
|
37
|
+
"""Return up to `size` bytes, blocking only as long as your own timeout allows."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SerialTransport:
|
|
41
|
+
"""
|
|
42
|
+
pyserial-backed transport.
|
|
43
|
+
|
|
44
|
+
Defaults are 115200 8N1 — the manual documents 115200 and 8 data bits but is silent on parity
|
|
45
|
+
and stop bits, so 8N1 is an assumption recorded in PROTOCOL.md, not a documented fact. If a
|
|
46
|
+
module stays silent, try `parity="E"` or `parity="O"` before assuming a wiring fault.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, port: str, baudrate: int = 115200, *, parity: str = "N",
|
|
50
|
+
stopbits: float = 1, read_timeout: float = 0.2) -> None:
|
|
51
|
+
try:
|
|
52
|
+
import serial # type: ignore
|
|
53
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
54
|
+
raise ImportError(
|
|
55
|
+
"pyserial is required to open a serial port. "
|
|
56
|
+
"Install it with: pip install pyserial"
|
|
57
|
+
) from exc
|
|
58
|
+
self._serial_mod = serial
|
|
59
|
+
self.port = port
|
|
60
|
+
self.baudrate = baudrate
|
|
61
|
+
self._ser = serial.Serial(
|
|
62
|
+
port=port, baudrate=baudrate, bytesize=serial.EIGHTBITS,
|
|
63
|
+
parity={"N": serial.PARITY_NONE, "E": serial.PARITY_EVEN,
|
|
64
|
+
"O": serial.PARITY_ODD}[parity.upper()],
|
|
65
|
+
stopbits={1: serial.STOPBITS_ONE, 1.5: serial.STOPBITS_ONE_POINT_FIVE,
|
|
66
|
+
2: serial.STOPBITS_TWO}[float(stopbits)],
|
|
67
|
+
timeout=read_timeout,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def write(self, data: bytes) -> int:
|
|
71
|
+
return self._ser.write(data)
|
|
72
|
+
|
|
73
|
+
def read(self, size: int) -> bytes:
|
|
74
|
+
return self._ser.read(size)
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
try:
|
|
78
|
+
self._ser.close()
|
|
79
|
+
except Exception: # noqa: BLE001 - closing must never mask the real error
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> "SerialTransport":
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, *exc) -> None:
|
|
86
|
+
self.close()
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def list_ports() -> list[str]:
|
|
90
|
+
try:
|
|
91
|
+
from serial.tools import list_ports # type: ignore
|
|
92
|
+
except ImportError:
|
|
93
|
+
return []
|
|
94
|
+
return [p.device for p in list_ports.comports()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Rangefinder:
|
|
98
|
+
"""
|
|
99
|
+
Blocking, single-threaded host driver.
|
|
100
|
+
|
|
101
|
+
Every call reads from the transport until it sees the frame it is waiting for or the deadline
|
|
102
|
+
passes. Unrelated and self-test frames are recorded in `self.unsolicited` rather than
|
|
103
|
+
discarded, because on a real module the power-on self-test arrives when it feels like it and
|
|
104
|
+
swallowing it hides real faults.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, transport: Transport, *, timeout: float = 1.0, chunk: int = 64) -> None:
|
|
108
|
+
self.transport = transport
|
|
109
|
+
self.timeout = timeout
|
|
110
|
+
self.chunk = chunk
|
|
111
|
+
self.reader = F.FrameReader()
|
|
112
|
+
self.unsolicited: list[F.Frame] = []
|
|
113
|
+
self.self_test: F.Frame | None = None
|
|
114
|
+
# Frames already parsed but not yet consumed. This queue is why the driver cannot lose a
|
|
115
|
+
# response: an earlier draft flushed by *consuming* a frame and discarding the rest of the
|
|
116
|
+
# parsed batch, which silently ate the answer to the very command it had just sent.
|
|
117
|
+
self._pending: deque[F.Frame] = deque()
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------- plumbing
|
|
120
|
+
|
|
121
|
+
def _fill(self, deadline: float) -> None:
|
|
122
|
+
"""Block until at least one frame is pending, or the deadline passes."""
|
|
123
|
+
while not self._pending:
|
|
124
|
+
chunk = self.transport.read(self.chunk)
|
|
125
|
+
if chunk:
|
|
126
|
+
self._pending.extend(self.reader.feed(chunk))
|
|
127
|
+
if self._pending:
|
|
128
|
+
return
|
|
129
|
+
if time.monotonic() >= deadline:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
def _flush(self) -> list[F.Frame]:
|
|
133
|
+
"""
|
|
134
|
+
Drop everything already sitting in the link *before* a command is sent.
|
|
135
|
+
|
|
136
|
+
On a real port a previous command's answer, or a burst of unrelated frames, can still be
|
|
137
|
+
buffered. Accepting one of those as the answer to the next command yields a wrong distance
|
|
138
|
+
with no error — the worst outcome this driver can produce. So they are discarded, and
|
|
139
|
+
recorded in the returned list for anyone who wants to see them.
|
|
140
|
+
"""
|
|
141
|
+
dropped: list[F.Frame] = list(self._pending)
|
|
142
|
+
self._pending.clear()
|
|
143
|
+
while True:
|
|
144
|
+
chunk = self.transport.read(self.chunk)
|
|
145
|
+
if not chunk:
|
|
146
|
+
break
|
|
147
|
+
dropped.extend(self.reader.feed(chunk))
|
|
148
|
+
return dropped
|
|
149
|
+
|
|
150
|
+
def _pump(self, deadline: float, want: Iterable[int] | None) -> F.Frame | None:
|
|
151
|
+
wanted = set(want) if want is not None else None
|
|
152
|
+
while True:
|
|
153
|
+
self._fill(deadline)
|
|
154
|
+
if not self._pending:
|
|
155
|
+
return None
|
|
156
|
+
f = self._pending.popleft()
|
|
157
|
+
if f.is_self_test:
|
|
158
|
+
self.self_test = f
|
|
159
|
+
if wanted is None or F.FUNC_SELF_TEST in wanted:
|
|
160
|
+
return f
|
|
161
|
+
self.unsolicited.append(f)
|
|
162
|
+
continue
|
|
163
|
+
if wanted is None or f.func in wanted:
|
|
164
|
+
return f
|
|
165
|
+
# a frame for some other command: keep it visible, do not return it
|
|
166
|
+
self.unsolicited.append(f)
|
|
167
|
+
if time.monotonic() >= deadline:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def _exchange(self, command: bytes, expect: Iterable[int], timeout: float | None = None) -> F.Frame:
|
|
171
|
+
budget = self.timeout if timeout is None else timeout
|
|
172
|
+
self._flush()
|
|
173
|
+
self.transport.write(command)
|
|
174
|
+
frame = self._pump(time.monotonic() + budget, expect)
|
|
175
|
+
if frame is None:
|
|
176
|
+
raise DeviceTimeout(
|
|
177
|
+
f"no response to {command.hex(' ').upper()} within {budget}s "
|
|
178
|
+
f"(wanted func {[hex(x) for x in expect]})")
|
|
179
|
+
return frame
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------- measurements
|
|
182
|
+
|
|
183
|
+
def single_shot(self) -> float | None:
|
|
184
|
+
"""
|
|
185
|
+
Trigger one measurement and return metres.
|
|
186
|
+
|
|
187
|
+
Returns None when the module answers but reports a failed measurement (STA != 1), which is
|
|
188
|
+
a normal outcome against a poor target. Raises DeviceTimeout when it does not answer at all.
|
|
189
|
+
"""
|
|
190
|
+
frame = self._exchange(F.CMD_SINGLE_SHOT, (F.FUNC_SINGLE_SHOT,))
|
|
191
|
+
return F.decode_distance_or_none(frame)
|
|
192
|
+
|
|
193
|
+
def start_continuous(self) -> None:
|
|
194
|
+
self.transport.write(F.CMD_CONTINUOUS)
|
|
195
|
+
|
|
196
|
+
def stop_continuous(self, *, timeout: float | None = None) -> bool:
|
|
197
|
+
frame = self._exchange(F.CMD_STOP, (F.FUNC_STOP,), timeout)
|
|
198
|
+
return frame.sta == F.STA_SUCCESS
|
|
199
|
+
|
|
200
|
+
def stream(self, count: int | None = None, *, settle: float = 0.05) -> Iterable[float | None]:
|
|
201
|
+
"""
|
|
202
|
+
Start continuous ranging and yield distances in metres.
|
|
203
|
+
|
|
204
|
+
Yields `count` samples (or forever when count is None). Stops continuous mode on exit.
|
|
205
|
+
Frames arriving before the first valid range are yielded as None rather than dropped, so a
|
|
206
|
+
caller can see how long the module took to lock on.
|
|
207
|
+
"""
|
|
208
|
+
self.start_continuous()
|
|
209
|
+
time.sleep(settle)
|
|
210
|
+
produced = 0
|
|
211
|
+
try:
|
|
212
|
+
while count is None or produced < count:
|
|
213
|
+
frame = self._pump(time.monotonic() + self.timeout, (F.FUNC_CONTINUOUS,))
|
|
214
|
+
if frame is None:
|
|
215
|
+
raise DeviceTimeout("continuous ranging went quiet")
|
|
216
|
+
yield F.decode_distance_or_none(frame)
|
|
217
|
+
produced += 1
|
|
218
|
+
finally:
|
|
219
|
+
try:
|
|
220
|
+
self.stop_continuous(timeout=0.5)
|
|
221
|
+
except (DeviceTimeout, F.ProtocolError):
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
def angle(self) -> float | None:
|
|
225
|
+
"""
|
|
226
|
+
Angle in degrees. Only available on modules fitted with an angle sensor — the manual says so
|
|
227
|
+
explicitly. On a rangefinder-only module expect a timeout, not a wrong number.
|
|
228
|
+
"""
|
|
229
|
+
frame = self._exchange(F.CMD_ANGLE, (F.FUNC_ANGLE,))
|
|
230
|
+
if frame.sta != F.STA_SUCCESS:
|
|
231
|
+
return None
|
|
232
|
+
return F.decode_angle(frame)
|
|
233
|
+
|
|
234
|
+
def read_self_test(self, timeout: float | None = None) -> F.Frame | None:
|
|
235
|
+
"""
|
|
236
|
+
Wait for the unsolicited power-on self-test frame.
|
|
237
|
+
|
|
238
|
+
Best called immediately after opening the port, *before* anything else: the frame is emitted
|
|
239
|
+
once at power-on, so if you have already sent other commands you have probably missed it and
|
|
240
|
+
will get None. That is not a fault.
|
|
241
|
+
"""
|
|
242
|
+
return self._pump(time.monotonic() + (self.timeout if timeout is None else timeout),
|
|
243
|
+
(F.FUNC_SELF_TEST,))
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------- configuration
|
|
246
|
+
|
|
247
|
+
def set_baud(self, baud: int, *, timeout: float | None = None) -> bool:
|
|
248
|
+
"""
|
|
249
|
+
Request a new baud rate. Returns True when the module accepted it.
|
|
250
|
+
|
|
251
|
+
The change takes effect only after the module is restarted. This method does **not**
|
|
252
|
+
reconfigure the transport — do that yourself after the restart, otherwise the accepted
|
|
253
|
+
setting and the open port will disagree.
|
|
254
|
+
"""
|
|
255
|
+
frame = self._exchange(F.set_baud_command(baud), (F.BAUD_CODES[baud],), timeout)
|
|
256
|
+
return frame.sta == F.STA_SUCCESS
|
|
257
|
+
|
|
258
|
+
def ld_constant_on(self, enable: bool) -> None:
|
|
259
|
+
"""
|
|
260
|
+
Turn the laser diode constant-on mode on or off.
|
|
261
|
+
|
|
262
|
+
Fire-and-forget: the manual's response row for this command is incomplete (it prints only
|
|
263
|
+
`55`), so this library does not invent a frame layout to validate against. See
|
|
264
|
+
PROTOCOL.md section 3.6. The manufacturer warns against leaving it enabled for long periods.
|
|
265
|
+
"""
|
|
266
|
+
self.transport.write(F.CMD_LD_ON if enable else F.CMD_LD_OFF)
|
|
267
|
+
|
|
268
|
+
def __enter__(self) -> "Rangefinder":
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def __exit__(self, *exc) -> None:
|
|
272
|
+
close = getattr(self.transport, "close", None)
|
|
273
|
+
if callable(close):
|
|
274
|
+
close()
|
erdilrf_lrf/frames.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
erdilrf_lrf.frames — ERDI 905 nm laser rangefinder UART framing.
|
|
3
|
+
|
|
4
|
+
Pure standard library. No serial dependency, so this module is testable anywhere.
|
|
5
|
+
|
|
6
|
+
Frame layout (8 bytes, fixed):
|
|
7
|
+
|
|
8
|
+
offset 0 1 2 3 4 5 6 7
|
|
9
|
+
0x55 0xAA FUNC D1 D2 D3 D4 SUM
|
|
10
|
+
|
|
11
|
+
Checksum — and this is the part people get wrong:
|
|
12
|
+
|
|
13
|
+
host -> module SUM = (FUNC + D1 + D2 + D3 + D4) & 0xFF
|
|
14
|
+
module -> host SUM = (0x55 + 0xAA + FUNC + D1 + D2 + D3 + D4) & 0xFF
|
|
15
|
+
|
|
16
|
+
The two directions use different ranges. The manufacturer's manual states this in its own
|
|
17
|
+
notes; this module encodes it as two separate functions so it cannot be confused.
|
|
18
|
+
|
|
19
|
+
Source: LR1000E2 User Manual v1.2 (2025.10, ERDI TECH LTD) section 8.
|
|
20
|
+
See protocol/PROTOCOL.md for the full reference and protocol/verification.md for the
|
|
21
|
+
checksum arithmetic re-derived independently of the manual's printed values.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import NamedTuple
|
|
28
|
+
|
|
29
|
+
HEADER_H = 0x55
|
|
30
|
+
HEADER_L = 0xAA
|
|
31
|
+
FRAME_LEN = 8
|
|
32
|
+
PAD = 0xFF
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- function codes
|
|
35
|
+
|
|
36
|
+
FUNC_SELF_TEST = 0x80 # module -> host, unsolicited, at power-on
|
|
37
|
+
FUNC_LD_CONSTANT_ON = 0x86 # host -> module, D4 selects enable/disable
|
|
38
|
+
FUNC_SINGLE_SHOT = 0x88
|
|
39
|
+
FUNC_CONTINUOUS = 0x89
|
|
40
|
+
FUNC_ANGLE = 0x8A
|
|
41
|
+
FUNC_STOP = 0x8E
|
|
42
|
+
# NOTE: set-baud has no separate function code. The manual's `TYPE` selector occupies the
|
|
43
|
+
# FUNCTION position itself (frame is `55 AA TYPE FF FF FF FF SUM`), so its "func" byte is
|
|
44
|
+
# one of BAUD_BY_CODE's keys. Do not invent a distinct constant for it.
|
|
45
|
+
|
|
46
|
+
#: Baud-rate selector codes, from the manual's TYPE table.
|
|
47
|
+
BAUD_CODES: dict[int, int] = {
|
|
48
|
+
9600: 0x01,
|
|
49
|
+
14400: 0x02,
|
|
50
|
+
19200: 0x03,
|
|
51
|
+
38400: 0x04,
|
|
52
|
+
56000: 0x05,
|
|
53
|
+
57600: 0x06,
|
|
54
|
+
115200: 0x07,
|
|
55
|
+
128000: 0x08,
|
|
56
|
+
230400: 0x09,
|
|
57
|
+
}
|
|
58
|
+
BAUD_BY_CODE: dict[int, int] = {v: k for k, v in BAUD_CODES.items()}
|
|
59
|
+
|
|
60
|
+
#: LD constant-on selector (goes in D4).
|
|
61
|
+
LD_ON = 0x01
|
|
62
|
+
LD_OFF = 0x00
|
|
63
|
+
|
|
64
|
+
#: STA values are per-command, not global. See PROTOCOL.md section 6.
|
|
65
|
+
STA_SUCCESS = 0x01
|
|
66
|
+
STA_FAILURE = 0x00
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProtocolError(ValueError):
|
|
70
|
+
"""Raised when bytes cannot be interpreted as a valid ERDI frame."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------- checksums
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def checksum_send(func: int, d1: int = PAD, d2: int = PAD, d3: int = PAD, d4: int = PAD) -> int:
|
|
77
|
+
"""Checksum for a host -> module frame: low byte of FUNC + D1 + D2 + D3 + D4."""
|
|
78
|
+
return (func + d1 + d2 + d3 + d4) & 0xFF
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def checksum_response(frame: bytes) -> int:
|
|
82
|
+
"""
|
|
83
|
+
Checksum for a module -> host frame: low byte of
|
|
84
|
+
HEADER_H + HEADER_L + FUNC + D1 + D2 + D3 + D4.
|
|
85
|
+
|
|
86
|
+
Accepts the first 7 bytes, or a full 8-byte frame (byte 7 is ignored).
|
|
87
|
+
"""
|
|
88
|
+
if len(frame) < 7:
|
|
89
|
+
raise ProtocolError(f"need at least 7 bytes to checksum a response, got {len(frame)}")
|
|
90
|
+
return sum(frame[:7]) & 0xFF
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------- building
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_command(func: int, d1: int = PAD, d2: int = PAD, d3: int = PAD, d4: int = PAD) -> bytes:
|
|
97
|
+
"""Build an 8-byte host -> module frame with the correct send-side checksum."""
|
|
98
|
+
for name, val in (("func", func), ("d1", d1), ("d2", d2), ("d3", d3), ("d4", d4)):
|
|
99
|
+
if not 0 <= val <= 0xFF:
|
|
100
|
+
raise ProtocolError(f"{name}=0x{val:X} does not fit in one byte")
|
|
101
|
+
return bytes([HEADER_H, HEADER_L, func, d1, d2, d3, d4,
|
|
102
|
+
checksum_send(func, d1, d2, d3, d4)])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------- the command set
|
|
106
|
+
|
|
107
|
+
CMD_SINGLE_SHOT = build_command(FUNC_SINGLE_SHOT) # 55 AA 88 FF FF FF FF 84
|
|
108
|
+
CMD_CONTINUOUS = build_command(FUNC_CONTINUOUS) # 55 AA 89 FF FF FF FF 85
|
|
109
|
+
CMD_STOP = build_command(FUNC_STOP) # 55 AA 8E FF FF FF FF 8A
|
|
110
|
+
CMD_ANGLE = build_command(FUNC_ANGLE) # 55 AA 8A FF FF FF FF 86
|
|
111
|
+
CMD_LD_ON = build_command(FUNC_LD_CONSTANT_ON, PAD, PAD, PAD, LD_ON) # ... FF FF FF 01 84
|
|
112
|
+
CMD_LD_OFF = build_command(FUNC_LD_CONSTANT_ON, PAD, PAD, PAD, LD_OFF) # ... FF FF FF 00 83
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def set_baud_command(baud: int) -> bytes:
|
|
116
|
+
"""
|
|
117
|
+
Build the set-baud frame. The selector is carried in D1 (the manual calls this field TYPE).
|
|
118
|
+
|
|
119
|
+
Reminder: the new rate applies only after the module is restarted.
|
|
120
|
+
"""
|
|
121
|
+
if baud not in BAUD_CODES:
|
|
122
|
+
raise ProtocolError(
|
|
123
|
+
f"unsupported baud {baud}; supported: {sorted(BAUD_CODES)}")
|
|
124
|
+
return build_command(BAUD_CODES[baud])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------- parsing
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Frame(NamedTuple):
|
|
131
|
+
func: int
|
|
132
|
+
d1: int
|
|
133
|
+
d2: int
|
|
134
|
+
d3: int
|
|
135
|
+
d4: int
|
|
136
|
+
checksum: int
|
|
137
|
+
raw: bytes
|
|
138
|
+
#: which checksum rule this frame is subject to: "response" (module -> host) or
|
|
139
|
+
#: "command" (host -> module). The two rules genuinely differ, so a frame that does not
|
|
140
|
+
#: record its direction cannot be validated. This field exists because omitting it was a
|
|
141
|
+
#: real bug: FrameReader validated TX frames with the RX rule and rejected every one.
|
|
142
|
+
direction: str = "response"
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def expected_checksum(self) -> int:
|
|
146
|
+
if self.direction == "command":
|
|
147
|
+
return checksum_send(self.func, self.d1, self.d2, self.d3, self.d4)
|
|
148
|
+
return checksum_response(self.raw)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def checksum_ok(self) -> bool:
|
|
152
|
+
return self.checksum == self.expected_checksum
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def sta(self) -> int:
|
|
156
|
+
return self.d1
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def is_self_test(self) -> bool:
|
|
160
|
+
return self.func == FUNC_SELF_TEST
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def error_code(self) -> int | None:
|
|
164
|
+
"""Self-test error code, present in D4 of a `55 AA 80` frame."""
|
|
165
|
+
return self.d4 if self.is_self_test else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
DIRECTIONS = ("response", "command")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_frame(data: bytes, *, direction: str = "response", verify_checksum: bool = True) -> Frame:
|
|
172
|
+
"""
|
|
173
|
+
Parse exactly 8 bytes into a Frame.
|
|
174
|
+
|
|
175
|
+
`direction` selects the checksum rule: "response" for module -> host frames (the default,
|
|
176
|
+
matching what a host reads from the port) or "command" for host -> module frames.
|
|
177
|
+
|
|
178
|
+
Raises ProtocolError on wrong length, wrong header, or (unless disabled) a bad checksum.
|
|
179
|
+
"""
|
|
180
|
+
if direction not in DIRECTIONS:
|
|
181
|
+
raise ProtocolError(f"direction must be one of {DIRECTIONS}, got {direction!r}")
|
|
182
|
+
if len(data) != FRAME_LEN:
|
|
183
|
+
raise ProtocolError(f"expected {FRAME_LEN} bytes, got {len(data)}: {data.hex(' ')}")
|
|
184
|
+
if data[0] != HEADER_H or data[1] != HEADER_L:
|
|
185
|
+
raise ProtocolError(f"bad header {data[0]:02X} {data[1]:02X}, expected 55 AA")
|
|
186
|
+
f = Frame(data[2], data[3], data[4], data[5], data[6], data[7], bytes(data), direction)
|
|
187
|
+
if verify_checksum and not f.checksum_ok:
|
|
188
|
+
raise ProtocolError(
|
|
189
|
+
f"checksum mismatch: frame says 0x{f.checksum:02X}, "
|
|
190
|
+
f"{'SUM[3:7]' if direction == 'command' else 'SUM[1:7]'} sums to "
|
|
191
|
+
f"0x{f.expected_checksum:02X}")
|
|
192
|
+
return f
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class FrameReader:
|
|
196
|
+
"""
|
|
197
|
+
Byte-stream reassembler.
|
|
198
|
+
|
|
199
|
+
Feed it arbitrary chunks from a serial port; it yields complete, checksum-valid Frame
|
|
200
|
+
objects. Resynchronises on the 55 AA header, so a truncated or corrupted frame costs at
|
|
201
|
+
most one frame and never poisons the stream.
|
|
202
|
+
|
|
203
|
+
Use the default `direction="response"` for bytes read from the module. Switch to
|
|
204
|
+
"command" only when replaying frames you transmitted.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(self, *, direction: str = "response", verify_checksum: bool = True,
|
|
208
|
+
collect_errors: bool = True) -> None:
|
|
209
|
+
if direction not in DIRECTIONS:
|
|
210
|
+
raise ProtocolError(f"direction must be one of {DIRECTIONS}, got {direction!r}")
|
|
211
|
+
self._buf = bytearray()
|
|
212
|
+
self._direction = direction
|
|
213
|
+
self._verify = verify_checksum
|
|
214
|
+
self._collect_errors = collect_errors
|
|
215
|
+
self.errors: list[str] = []
|
|
216
|
+
|
|
217
|
+
def feed(self, chunk: bytes) -> list[Frame]:
|
|
218
|
+
if chunk:
|
|
219
|
+
self._buf.extend(chunk)
|
|
220
|
+
return list(self._drain())
|
|
221
|
+
|
|
222
|
+
def _drain(self):
|
|
223
|
+
while True:
|
|
224
|
+
# drop leading bytes until the header aligns
|
|
225
|
+
i = self._buf.find(bytes([HEADER_H, HEADER_L]))
|
|
226
|
+
if i < 0:
|
|
227
|
+
# keep only a possible half-header at the tail
|
|
228
|
+
if self._buf[-1:] == bytes([HEADER_H]):
|
|
229
|
+
del self._buf[:-1]
|
|
230
|
+
else:
|
|
231
|
+
self._buf.clear()
|
|
232
|
+
return
|
|
233
|
+
if i:
|
|
234
|
+
if self._collect_errors:
|
|
235
|
+
self.errors.append(f"discarded {i} non-header byte(s): {bytes(self._buf[:i]).hex(' ')}")
|
|
236
|
+
del self._buf[:i]
|
|
237
|
+
if len(self._buf) < FRAME_LEN:
|
|
238
|
+
return
|
|
239
|
+
candidate = bytes(self._buf[:FRAME_LEN])
|
|
240
|
+
try:
|
|
241
|
+
yield parse_frame(candidate, direction=self._direction,
|
|
242
|
+
verify_checksum=self._verify)
|
|
243
|
+
except ProtocolError as exc:
|
|
244
|
+
if self._collect_errors:
|
|
245
|
+
self.errors.append(str(exc))
|
|
246
|
+
# a bad frame can still hide the real header one byte in; step forward by 1
|
|
247
|
+
del self._buf[:1]
|
|
248
|
+
continue
|
|
249
|
+
del self._buf[:FRAME_LEN]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------- decoding
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def decode_distance(frame: Frame) -> float:
|
|
256
|
+
"""
|
|
257
|
+
Measured distance in **metres**.
|
|
258
|
+
|
|
259
|
+
The manual states: reported value = measured value x 10, so the 16-bit register holds
|
|
260
|
+
tenths of a metre. DIS_H is D3, DIS_L is D4.
|
|
261
|
+
"""
|
|
262
|
+
if frame.d2 != PAD:
|
|
263
|
+
raise ProtocolError(
|
|
264
|
+
f"expected 0xFF in D2 of a ranging response, got 0x{frame.d2:02X}")
|
|
265
|
+
if frame.sta != STA_SUCCESS:
|
|
266
|
+
raise ProtocolError(f"measurement failed (STA=0x{frame.sta:02X})")
|
|
267
|
+
raw = (frame.d3 << 8) | frame.d4
|
|
268
|
+
return raw / 10.0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def decode_angle(frame: Frame) -> float:
|
|
272
|
+
"""Measured angle in degrees, same x10 scaling. Only on modules with an angle sensor."""
|
|
273
|
+
if frame.sta != STA_SUCCESS:
|
|
274
|
+
raise ProtocolError(f"angle measurement failed (STA=0x{frame.sta:02X})")
|
|
275
|
+
raw = (frame.d3 << 8) | frame.d4
|
|
276
|
+
return raw / 10.0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def decode_distance_or_none(frame: Frame) -> float | None:
|
|
280
|
+
"""Convenience for polling loops: metres, or None when STA reports a failed measurement."""
|
|
281
|
+
try:
|
|
282
|
+
return decode_distance(frame)
|
|
283
|
+
except ProtocolError:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------- human-readable helpers
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(frozen=True)
|
|
291
|
+
class FrameSummary:
|
|
292
|
+
func: int
|
|
293
|
+
name: str
|
|
294
|
+
sta: int
|
|
295
|
+
sta_meaning: str
|
|
296
|
+
detail: str
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
_NAMES = {
|
|
300
|
+
FUNC_SELF_TEST: "power-on self-test",
|
|
301
|
+
FUNC_LD_CONSTANT_ON: "LD constant-on mode",
|
|
302
|
+
FUNC_SINGLE_SHOT: "single-shot ranging",
|
|
303
|
+
FUNC_CONTINUOUS: "continuous ranging",
|
|
304
|
+
FUNC_ANGLE: "angle measurement",
|
|
305
|
+
FUNC_STOP: "stop ranging",
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def summarise(frame: Frame) -> FrameSummary:
|
|
310
|
+
name = _NAMES.get(frame.func, f"unknown function 0x{frame.func:02X}")
|
|
311
|
+
sta, meaning, detail = frame.sta, "", ""
|
|
312
|
+
|
|
313
|
+
if frame.direction == "command":
|
|
314
|
+
# A transmitted frame's D1 is a parameter, not a status byte. Reporting "measurement
|
|
315
|
+
# failed" here would invent a fault out of a field that has no such meaning.
|
|
316
|
+
return FrameSummary(frame.func, name, sta,
|
|
317
|
+
"n/a (transmitted frame — D1 is a parameter, not STA)", "")
|
|
318
|
+
|
|
319
|
+
if frame.func in BAUD_BY_CODE:
|
|
320
|
+
name = f"set baud to {BAUD_BY_CODE[frame.func]} bps"
|
|
321
|
+
meaning = "setting successful" if sta == STA_SUCCESS else "setting failed"
|
|
322
|
+
detail = "takes effect after restart"
|
|
323
|
+
elif frame.func in (FUNC_SINGLE_SHOT, FUNC_CONTINUOUS):
|
|
324
|
+
meaning = "measurement successful" if sta == STA_SUCCESS else "measurement failed"
|
|
325
|
+
if sta == STA_SUCCESS:
|
|
326
|
+
detail = f"{(frame.d3 << 8 | frame.d4) / 10.0:.1f} m"
|
|
327
|
+
elif frame.func == FUNC_STOP:
|
|
328
|
+
meaning = "stopped" if sta == STA_SUCCESS else "failed to stop"
|
|
329
|
+
elif frame.func == FUNC_ANGLE:
|
|
330
|
+
meaning = "measurement successful" if sta == STA_SUCCESS else "measurement failed"
|
|
331
|
+
if sta == STA_SUCCESS:
|
|
332
|
+
detail = f"{(frame.d3 << 8 | frame.d4) / 10.0:.1f} deg"
|
|
333
|
+
elif frame.func == FUNC_SELF_TEST:
|
|
334
|
+
meaning = "init succeeded" if sta == STA_SUCCESS else "init FAILED"
|
|
335
|
+
if sta != STA_SUCCESS:
|
|
336
|
+
detail = f"ErrCode=0x{frame.d4:02X}"
|
|
337
|
+
else:
|
|
338
|
+
meaning = "n/a"
|
|
339
|
+
|
|
340
|
+
return FrameSummary(frame.func, name, sta, meaning, detail)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: erdilrf-lrf
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Host-side UART driver for ERDI 905 nm laser rangefinder modules (LR1000E2 protocol family)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://erdilrf.com
|
|
7
|
+
Project-URL: Repository, https://github.com/erdilrf/erdilrf-drivers
|
|
8
|
+
Project-URL: Documentation, https://github.com/erdilrf/erdilrf-drivers/blob/main/protocol/PROTOCOL.md
|
|
9
|
+
Keywords: laser-rangefinder,lidar,dtof,905nm,uart,embedded,robotics,drone,industrial-automation
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Manufacturing
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator
|
|
16
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: serial
|
|
21
|
+
Requires-Dist: pyserial>=3.5; extra == "serial"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# Python host driver
|
|
27
|
+
|
|
28
|
+
Host-side UART driver for **ERDI 905 nm laser rangefinder modules** — the LR1000E2 protocol family.
|
|
29
|
+
|
|
30
|
+
**Zero core dependencies** — framing and decoding are pure standard library, so the whole driver
|
|
31
|
+
runs and is testable on any machine, including one with no hardware attached. `pyserial` is optional
|
|
32
|
+
and only needed to actually open a port.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install -e . # from this directory (python/)
|
|
36
|
+
pip install -e python/ # from the repository root — same thing
|
|
37
|
+
pip install pyserial # only needed to open a real port
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Command line
|
|
41
|
+
|
|
42
|
+
Every command below runs with **no hardware and no serial port** except the last three:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
erdilrf frames # print the whole command table
|
|
46
|
+
erdilrf decode "55 AA 88 01 FF 27 10 BE" # decode a captured frame
|
|
47
|
+
erdilrf ports
|
|
48
|
+
erdilrf measure --port COM3 --count 5
|
|
49
|
+
erdilrf stream --port COM3
|
|
50
|
+
erdilrf selftest --port COM3 # run immediately after power-on
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Library
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from erdilrf_lrf import Rangefinder, SerialTransport
|
|
57
|
+
|
|
58
|
+
with Rangefinder(SerialTransport("COM3")) as rf:
|
|
59
|
+
print(rf.single_shot()) # 123.4 metres, or None if the shot failed
|
|
60
|
+
for distance in rf.stream(count=10): # 3-10 Hz adaptive continuous ranging
|
|
61
|
+
print(distance)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Protocol in one table
|
|
65
|
+
|
|
66
|
+
Fixed 8-byte frames. The checksum is the low byte of a sum — and **the two directions use different
|
|
67
|
+
ranges**, which is the single most common integration mistake.
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
offset 0 1 2 3 4 5 6 7
|
|
71
|
+
0x55 0xAA FUNC D1 D2 D3 D4 SUM
|
|
72
|
+
|
|
73
|
+
host -> module SUM = (FUNC + D1 + D2 + D3 + D4) & 0xFF
|
|
74
|
+
module -> host SUM = (0x55 + 0xAA + FUNC + D1 + D2 + D3 + D4) & 0xFF
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Command | Transmit | Response |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Single-shot ranging | `55 AA 88 FF FF FF FF 84` | `55 AA 88 STA FF DIS_H DIS_L SUM` |
|
|
80
|
+
| Continuous ranging | `55 AA 89 FF FF FF FF 85` | `55 AA 89 STA FF DIS_H DIS_L SUM` |
|
|
81
|
+
| Stop ranging | `55 AA 8E FF FF FF FF 8A` | `55 AA 8E STA FF FF FF SUM` |
|
|
82
|
+
| Angle measurement † | `55 AA 8A FF FF FF FF 86` | `55 AA 8A STA FF ANG_H ANG_L SUM` |
|
|
83
|
+
| LD constant-on on / off | `55 AA 86 FF FF FF 01 84` / `… 00 83` | see known gap below |
|
|
84
|
+
| Set baud rate | `55 AA TYPE FF FF FF FF SUM` | `55 AA TYPE STA FF FF FF SUM` |
|
|
85
|
+
|
|
86
|
+
Distance and angle are both scaled by 10 — the 16-bit register holds tenths, so
|
|
87
|
+
`((DIS_H << 8) | DIS_L) / 10` gives metres.
|
|
88
|
+
|
|
89
|
+
† Angle measurement is available **only on modules fitted with an angle sensor**.
|
|
90
|
+
|
|
91
|
+
Default link: **115200 bps, 8 data bits, 8-byte frame**. The manufacturer documents 115200 and 8 data
|
|
92
|
+
bits; it is **silent on parity and stop bits**, so this driver assumes 8N1 and says so. If a module
|
|
93
|
+
stays silent, try `--parity E` or `--parity O` before assuming a wiring fault.
|
|
94
|
+
|
|
95
|
+
## Tests
|
|
96
|
+
|
|
97
|
+
50 tests, no hardware and no serial port required — serial I/O is injected as a two-method
|
|
98
|
+
transport, so the whole driver runs against a scripted fake link.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python -m pytest -q
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The suite has already caught real defects in this repository: a `FrameReader` that validated
|
|
105
|
+
*transmitted* frames with the *response* checksum rule, and a pre-command flush that silently ate
|
|
106
|
+
the answer to the command it had just sent (a wrong distance with no error — the worst failure this
|
|
107
|
+
driver can produce).
|
|
108
|
+
|
|
109
|
+
Golden vectors assert that all six transmit frames printed in the manufacturer's manual are
|
|
110
|
+
reproduced byte for byte, and that each printed checksum is reproducible from the stated rule.
|
|
111
|
+
|
|
112
|
+
## What the tests do not cover
|
|
113
|
+
|
|
114
|
+
No test in this repository has ever run against a real module. The framing is verified against the
|
|
115
|
+
manual and against itself, **not against silicon**. A capture from real hardware outranks every
|
|
116
|
+
table above — if your module disagrees, open an issue with the raw bytes.
|
|
117
|
+
|
|
118
|
+
## Known gaps we did not paper over
|
|
119
|
+
|
|
120
|
+
* **The LD constant-on response frame is incomplete in the published manual** — section 8 prints
|
|
121
|
+
only `55` for that row. This driver sends the command and does not validate a reply. It does not
|
|
122
|
+
guess the missing bytes.
|
|
123
|
+
* **Cable wire colours are not documented.** The manual gives pin numbers only:
|
|
124
|
+
1 GND, 2 VCC, 3 I/O (reserved), 4 TXD, 5 RXD, 6 SW-SHOT.
|
|
125
|
+
* **SW-SHOT polarity is configuration-dependent.** The manual says "function enable (active high)"
|
|
126
|
+
and in the same row "compatible with active-low control requirements". Confirm per unit.
|
|
127
|
+
* **UART logic-level tolerance is not specified.** Only the 3.3–5 V supply figure is given.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## About this file
|
|
132
|
+
|
|
133
|
+
This is the **PyPI long description** for the `erdilrf-lrf` distribution. The packaging toolchain
|
|
134
|
+
cannot read a file outside the project directory, so this file lives here rather than reusing the
|
|
135
|
+
repository root `README.md`. It mirrors the Python-relevant sections of that root README; the root
|
|
136
|
+
README remains authoritative for the repository as a whole (protocol reference, Arduino driver,
|
|
137
|
+
wiring notes).
|
|
138
|
+
|
|
139
|
+
Installable package name: **`erdilrf-lrf`** · import name: **`erdilrf_lrf`** · CLI: **`erdilrf`**.
|
|
140
|
+
Note that the package name differs from the import name and from the CLI name — see
|
|
141
|
+
[`pyproject.toml`](pyproject.toml) for the single source of truth.
|
|
142
|
+
|
|
143
|
+
Part of [erdilrf/erdilrf-drivers](https://github.com/erdilrf/erdilrf-drivers) · MIT ·
|
|
144
|
+
modules documented at [erdilrf.com](https://erdilrf.com)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
erdilrf_lrf/__init__.py,sha256=Z7lbPjr9ZeVRNwsCetW1_aflgQnzLzQxZqQa0-01HMA,1402
|
|
2
|
+
erdilrf_lrf/cli.py,sha256=sryxrtVVVCIVgAGvhpGXA_n2pUK3Ms6fqrZiPIZxHEI,8249
|
|
3
|
+
erdilrf_lrf/device.py,sha256=jjoFy0EiNfH-opCgWNtnhRLLlgV3jI7gYHWc3J7YHHw,11037
|
|
4
|
+
erdilrf_lrf/frames.py,sha256=PYzSbF06rQJnCT-LpMTvghc7O7YDxV40LtcGrJqWFH8,12513
|
|
5
|
+
erdilrf_lrf-0.2.0.dist-info/licenses/LICENSE,sha256=-FIBvvm0m06snuARfXI8GKBfblyjFcpnf9Uw3fkg16A,1064
|
|
6
|
+
erdilrf_lrf-0.2.0.dist-info/METADATA,sha256=yU25yzTHsgVtW3E-gpFMjutINJFtjO4gkzBDdESygGA,6587
|
|
7
|
+
erdilrf_lrf-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
erdilrf_lrf-0.2.0.dist-info/entry_points.txt,sha256=BVUC2zEsTORISCInQ9hIvrdzHNxmA1aN1EaIu6ilWcA,49
|
|
9
|
+
erdilrf_lrf-0.2.0.dist-info/top_level.txt,sha256=pjWQrigX0HrBNc0wc6JMekl7x3k6nVi2Ksjul-hW9hg,12
|
|
10
|
+
erdilrf_lrf-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ERDILRF
|
|
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
|
+
erdilrf_lrf
|