erdi-lrf 0.1.1__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.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: erdi-lrf
3
+ Version: 0.1.1
4
+ Summary: Open-source drivers for ERDI 905 nm laser rangefinder modules (UART, both published protocol families)
5
+ Author: ERDI Technology (Chengdu)
6
+ License: MIT
7
+ Project-URL: Homepage, https://erdilrf.com
8
+ Project-URL: Hardware, https://erdilrf.com/models/
9
+ Project-URL: Source, https://github.com/ErdiTech/erdi-lrf-drivers
10
+ Keywords: laser-rangefinder,lrf,905nm,lidar,uav,gimbal,erdi,serial
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Manufacturing
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Topic :: System :: Hardware :: Hardware Drivers
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: pyserial>=3.4
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest>=7; extra == "test"
28
+
29
+ # erdi-lrf (Python)
30
+
31
+ Python driver and `erdi-lrf` CLI for ERDI 905 nm laser rangefinder modules.
32
+
33
+ ```bash
34
+ pip install -e .
35
+ erdi-lrf models
36
+ erdi-lrf --port COM3 --model LRF3000A1 measure
37
+ ```
38
+
39
+ Full documentation, protocol tables and the hardware catalogue are in the
40
+ repository root: <https://github.com/ErdiTech/erdi-lrf-drivers>.
@@ -0,0 +1,12 @@
1
+ # erdi-lrf (Python)
2
+
3
+ Python driver and `erdi-lrf` CLI for ERDI 905 nm laser rangefinder modules.
4
+
5
+ ```bash
6
+ pip install -e .
7
+ erdi-lrf models
8
+ erdi-lrf --port COM3 --model LRF3000A1 measure
9
+ ```
10
+
11
+ Full documentation, protocol tables and the hardware catalogue are in the
12
+ repository root: <https://github.com/ErdiTech/erdi-lrf-drivers>.
@@ -0,0 +1,47 @@
1
+ """Open-source drivers for ERDI 905 nm laser rangefinder modules.
2
+
3
+ Two published protocol families cover the whole catalogue:
4
+
5
+ * **Family A** -- 8-byte ``55 AA`` command/response frames, 115200 bps default.
6
+ * **Family B** -- free-running ``5C`` data frames plus ``5A`` command frames,
7
+ 460800 bps default.
8
+
9
+ Every constant is transcribed from the model's own manual. Nothing is
10
+ extrapolated from a sibling model: the VB series reports millimetres while the H
11
+ series reports centimetres, and LRF1500H uses a 5-byte frame where everything
12
+ else uses 4.
13
+
14
+ Hardware: https://erdilrf.com/models/
15
+ """
16
+
17
+ from .device import ErdiLRF
18
+ from .errors import (
19
+ ChecksumError,
20
+ ErdiLrfError,
21
+ FrameError,
22
+ MeasurementFailed,
23
+ NoReplyError,
24
+ ProtocolHoldError,
25
+ UndocumentedError,
26
+ UnknownModelError,
27
+ )
28
+ from .models import MODELS, UNPUBLISHED_PROTOCOL, ModelSpec, get_model
29
+
30
+ __version__ = "0.1.1"
31
+
32
+ __all__ = [
33
+ "ErdiLRF",
34
+ "MODELS",
35
+ "UNPUBLISHED_PROTOCOL",
36
+ "ModelSpec",
37
+ "get_model",
38
+ "ErdiLrfError",
39
+ "ChecksumError",
40
+ "FrameError",
41
+ "MeasurementFailed",
42
+ "NoReplyError",
43
+ "ProtocolHoldError",
44
+ "UndocumentedError",
45
+ "UnknownModelError",
46
+ "__version__",
47
+ ]
@@ -0,0 +1,209 @@
1
+ """``erdi-lrf`` command-line interface.
2
+
3
+ ::
4
+
5
+ erdi-lrf models
6
+ erdi-lrf info --model LRF3000A1
7
+ erdi-lrf --port COM3 --model LRF3000A1 measure
8
+ erdi-lrf --port /dev/ttyUSB0 --model LRF100H stream --count 20
9
+ erdi-lrf --port COM3 --model LRF22VB info-device
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from typing import Optional
18
+
19
+ from . import __version__
20
+ from .errors import ErdiLrfError
21
+ from .models import MODELS, UNPUBLISHED_PROTOCOL, get_model
22
+
23
+
24
+ def _fmt_measurement(m) -> str:
25
+ if getattr(m, "metres", None) is None:
26
+ return f"raw={m.raw} (no distance-scaling statement in this model's manual)"
27
+ extra = ""
28
+ if getattr(m, "out_of_range", False):
29
+ extra = " [OUT OF RANGE marker]"
30
+ return f"{m.metres:.3f} m (raw={m.raw}){extra}"
31
+
32
+
33
+ def cmd_models(args) -> int:
34
+ rows = []
35
+ for name in sorted(MODELS):
36
+ s = MODELS[name]
37
+ rows.append({
38
+ "model": name,
39
+ "family": s.family,
40
+ "default_baud": s.default_baud,
41
+ "frame_len": s.frame_len,
42
+ "unit": s.unit,
43
+ "min_range_m": s.min_range_m,
44
+ "max_range_m": s.max_range_m,
45
+ })
46
+ if args.json:
47
+ print(json.dumps({"published": rows, "unpublished": UNPUBLISHED_PROTOCOL},
48
+ indent=2))
49
+ return 0
50
+ print(f"{'MODEL':<12} {'FAM':<4} {'BAUD':>7} {'FRAME':>6} {'UNIT':<5} RANGE")
51
+ for r in rows:
52
+ baud = r["default_baud"] if r["default_baud"] is not None else "-"
53
+ unit = r["unit"] or "-"
54
+ rng = (f"{r['min_range_m']}-{r['max_range_m']} m"
55
+ if r["max_range_m"] is not None else "-")
56
+ print(f"{r['model']:<12} {r['family']:<4} {str(baud):>7} "
57
+ f"{r['frame_len']:>6} {unit:<5} {rng}")
58
+ print()
59
+ for name, why in sorted(UNPUBLISHED_PROTOCOL.items()):
60
+ print(f"{name:<12} (no published protocol) {why}")
61
+ return 0
62
+
63
+
64
+ def cmd_info(args) -> int:
65
+ s = get_model(args.model)
66
+ print(f"model {s.model}")
67
+ print(f"protocol family {s.family}")
68
+ print(f"default baud {s.default_baud if s.default_baud is not None else 'NONE - must be given explicitly'}")
69
+ print(f"frame length {s.frame_len} bytes")
70
+ print(f"distance bytes {s.distance_bytes}")
71
+ print(f"unit {s.unit or 'undocumented'}")
72
+ print(f"out of range {s.out_of_range_raw if s.out_of_range_raw is not None else 'undocumented'}")
73
+ print(f"range {s.min_range_m}-{s.max_range_m} m")
74
+ print(f"capabilities {', '.join(sorted(s.capabilities))}")
75
+ if s.notes:
76
+ print("\nnotes:")
77
+ for n in s.notes:
78
+ print(f" - {n}")
79
+ if s.sources:
80
+ print("\nmanual sources:")
81
+ for k, v in s.sources.items():
82
+ print(f" {k}: {v}")
83
+ return 0
84
+
85
+
86
+ def _open(args):
87
+ from .device import ErdiLRF
88
+
89
+ if not args.port:
90
+ raise SystemExit("--port is required for this command")
91
+ return ErdiLRF(args.port, model=args.model, baud=args.baud, timeout=args.timeout)
92
+
93
+
94
+ def cmd_measure(args) -> int:
95
+ with _open(args) as lrf:
96
+ print(_fmt_measurement(lrf.measure()))
97
+ return 0
98
+
99
+
100
+ def cmd_stream(args) -> int:
101
+ with _open(args) as lrf:
102
+ it = lrf.stream(acknowledge_protocol_hold=args.acknowledge_protocol_hold)
103
+ n = 0
104
+ for m in it:
105
+ print(_fmt_measurement(m))
106
+ n += 1
107
+ if args.count and n >= args.count:
108
+ break
109
+ it.close()
110
+ return 0
111
+
112
+
113
+ def cmd_info_device(args) -> int:
114
+ with _open(args) as lrf:
115
+ try:
116
+ print(f"serial number {lrf.read_serial_number()}")
117
+ except ErdiLrfError as exc:
118
+ print(f"serial number unavailable: {exc}")
119
+ try:
120
+ print(f"software version {lrf.read_software_version()}")
121
+ except ErdiLrfError as exc:
122
+ print(f"software version unavailable: {exc}")
123
+ try:
124
+ print(f"output frequency {lrf.read_frequency():.1f} Hz")
125
+ except ErdiLrfError as exc:
126
+ print(f"output frequency unavailable: {exc}")
127
+ return 0
128
+
129
+
130
+ def cmd_set_baud(args) -> int:
131
+ with _open(args) as lrf:
132
+ lrf.set_baud(args.bps)
133
+ print(f"requested {args.bps} bps "
134
+ "(family A applies the change only after the module reboots)")
135
+ return 0
136
+
137
+
138
+ def _add_connection_args(parser: argparse.ArgumentParser, subcommand: bool) -> None:
139
+ """Connection options, accepted both before and after the subcommand.
140
+
141
+ On the subparsers the defaults are suppressed so that a value given before
142
+ the subcommand is not silently overwritten by ``None``.
143
+ """
144
+ kw = {"default": argparse.SUPPRESS} if subcommand else {}
145
+ parser.add_argument("--port", help="serial port, e.g. COM3 or /dev/ttyUSB0", **kw)
146
+ parser.add_argument("--model", help="model name, e.g. LRF3000A1", **kw)
147
+ parser.add_argument("--baud", type=int,
148
+ help="override the model's documented default baud rate",
149
+ **({"default": argparse.SUPPRESS} if subcommand else {"default": None}))
150
+ parser.add_argument("--timeout", type=float, help="serial timeout, seconds",
151
+ **({"default": argparse.SUPPRESS} if subcommand else {"default": 1.0}))
152
+
153
+
154
+ def build_parser() -> argparse.ArgumentParser:
155
+ p = argparse.ArgumentParser(
156
+ prog="erdi-lrf",
157
+ description="Driver CLI for ERDI 905 nm laser rangefinder modules. "
158
+ "Hardware: https://erdilrf.com/models/",
159
+ )
160
+ p.add_argument("--version", action="version", version=f"erdi-lrf {__version__}")
161
+ _add_connection_args(p, subcommand=False)
162
+
163
+ conn = argparse.ArgumentParser(add_help=False)
164
+ _add_connection_args(conn, subcommand=True)
165
+
166
+ sub = p.add_subparsers(dest="command", required=True)
167
+
168
+ sp = sub.add_parser("models", help="list every model and its wire facts")
169
+ sp.add_argument("--json", action="store_true")
170
+ sp.set_defaults(func=cmd_models)
171
+
172
+ sp = sub.add_parser("info", parents=[conn], help="show one model's documented protocol facts")
173
+ sp.set_defaults(func=cmd_info)
174
+
175
+ sp = sub.add_parser("measure", parents=[conn], help="take a single measurement")
176
+ sp.set_defaults(func=cmd_measure)
177
+
178
+ sp = sub.add_parser("stream", parents=[conn], help="print measurements continuously")
179
+ sp.add_argument("--count", type=int, default=0, help="stop after N readings")
180
+ sp.add_argument("--acknowledge-protocol-hold", action="store_true",
181
+ help="proceed with continuous ranging on models whose manual "
182
+ "leaves the reply function byte unresolved")
183
+ sp.set_defaults(func=cmd_stream)
184
+
185
+ sp = sub.add_parser("info-device", parents=[conn], help="read serial number / version / rate")
186
+ sp.set_defaults(func=cmd_info_device)
187
+
188
+ sp = sub.add_parser("set-baud", parents=[conn], help="set the module's UART baud rate")
189
+ sp.add_argument("bps", type=int)
190
+ sp.set_defaults(func=cmd_set_baud)
191
+
192
+ return p
193
+
194
+
195
+ def main(argv: Optional[list] = None) -> int:
196
+ args = build_parser().parse_args(argv)
197
+ if args.command in ("info", "measure", "stream", "info-device", "set-baud") \
198
+ and not args.model:
199
+ print("--model is required for this command", file=sys.stderr)
200
+ return 2
201
+ try:
202
+ return args.func(args)
203
+ except ErdiLrfError as exc:
204
+ print(f"error: {exc}", file=sys.stderr)
205
+ return 1
206
+
207
+
208
+ if __name__ == "__main__": # pragma: no cover
209
+ raise SystemExit(main())
@@ -0,0 +1,353 @@
1
+ """Serial transport for ERDI 905 nm rangefinder modules.
2
+
3
+ The codec modules (:mod:`erdi_lrf.protocol_a`, :mod:`erdi_lrf.protocol_b`) are
4
+ pure and hardware-free; this module is the only place that touches a serial
5
+ port. ``pyserial`` is imported lazily so the codecs and the model table can be
6
+ used, and tested, without it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Iterator, Optional
13
+
14
+ from . import protocol_a as pa
15
+ from . import protocol_b as pb
16
+ from .errors import (
17
+ ErdiLrfError,
18
+ MeasurementFailed,
19
+ NoReplyError,
20
+ ProtocolHoldError,
21
+ UndocumentedError,
22
+ )
23
+ from .models import (
24
+ CAP_ANGLE,
25
+ CAP_CONTINUOUS,
26
+ CAP_EXTERNAL_CIRCUIT,
27
+ CAP_LD_ALWAYS_ON,
28
+ CAP_READ_FREQUENCY,
29
+ CAP_READ_SERIAL,
30
+ CAP_READ_VERSION,
31
+ CAP_SET_BAUD,
32
+ CAP_SET_FREQUENCY,
33
+ CAP_START_STOP,
34
+ CAP_SWITCH_TO_IIC,
35
+ ModelSpec,
36
+ get_model,
37
+ )
38
+
39
+
40
+ def _require_serial():
41
+ try:
42
+ import serial # noqa: F401
43
+ except ImportError as exc: # pragma: no cover - environment dependent
44
+ raise ErdiLrfError(
45
+ "pyserial is required to talk to a module: pip install pyserial"
46
+ ) from exc
47
+ return serial
48
+
49
+
50
+ class ErdiLRF:
51
+ """A connected ERDI rangefinder module.
52
+
53
+ ::
54
+
55
+ from erdi_lrf import ErdiLRF
56
+
57
+ with ErdiLRF("COM3", model="LRF3000A1") as lrf:
58
+ print(lrf.measure().metres)
59
+
60
+ ``baud`` defaults to the model's documented default. ``SPD1200D03`` has no
61
+ usable default -- its own manual contradicts itself -- so passing ``baud``
62
+ explicitly is mandatory for that model.
63
+ """
64
+
65
+ def __init__(self, port: str, model: str, baud: Optional[int] = None,
66
+ timeout: float = 1.0, serial_factory=None) -> None:
67
+ self.spec: ModelSpec = get_model(model)
68
+ if baud is None:
69
+ if self.spec.default_baud is None:
70
+ raise UndocumentedError(
71
+ f"{self.spec.model}: no default baud rate can be chosen for you.\n"
72
+ + "\n".join(self.spec.notes)
73
+ + "\nPass baud=... explicitly once the delivered unit's "
74
+ "configuration is confirmed."
75
+ )
76
+ baud = self.spec.default_baud
77
+ self.baud = baud
78
+ self.timeout = timeout
79
+ if serial_factory is None:
80
+ serial = _require_serial()
81
+ self.ser = serial.Serial(port, baud, timeout=timeout)
82
+ else:
83
+ self.ser = serial_factory(port, baud, timeout)
84
+ self._streaming = False
85
+
86
+ # -- context manager ---------------------------------------------------
87
+ def __enter__(self) -> "ErdiLRF":
88
+ return self
89
+
90
+ def __exit__(self, *exc) -> None:
91
+ self.close()
92
+
93
+ def close(self) -> None:
94
+ try:
95
+ if self._streaming:
96
+ self.stop()
97
+ except Exception: # noqa: BLE001 - closing must not mask the original error
98
+ pass
99
+ finally:
100
+ self.ser.close()
101
+
102
+ # -- helpers -----------------------------------------------------------
103
+ def _require(self, capability: str, what: str) -> None:
104
+ if not self.spec.supports(capability):
105
+ raise UndocumentedError(
106
+ f"{self.spec.model}: {what} is not documented in this model's "
107
+ f"manual. Documented operations: "
108
+ f"{', '.join(sorted(self.spec.capabilities))}."
109
+ )
110
+
111
+ def _read_exact(self, n: int) -> bytes:
112
+ data = self.ser.read(n)
113
+ if len(data) != n:
114
+ raise NoReplyError(
115
+ f"{self.spec.model}: expected {n} bytes, got {len(data)} "
116
+ f"within {self.timeout}s at {self.baud} bps"
117
+ )
118
+ return data
119
+
120
+ def _read_frame_a(self, expected_function: Optional[int] = None,
121
+ deadline: Optional[float] = None) -> bytes:
122
+ """Resynchronise on 55 AA and return one complete 8-byte frame."""
123
+ buf = bytearray()
124
+ while True:
125
+ if deadline is not None and time.monotonic() > deadline:
126
+ raise NoReplyError(f"{self.spec.model}: no valid 55 AA frame before deadline")
127
+ byte = self.ser.read(1)
128
+ if not byte:
129
+ raise NoReplyError(
130
+ f"{self.spec.model}: serial read timed out at {self.baud} bps"
131
+ )
132
+ buf += byte
133
+ if len(buf) > 2 * pa.FRAME_LEN:
134
+ del buf[:-2 * pa.FRAME_LEN]
135
+ frames = pa.find_frames(bytes(buf))
136
+ for frame in frames:
137
+ if expected_function is None or frame[2] == expected_function:
138
+ return frame
139
+
140
+ def _read_frame_b(self, deadline: Optional[float] = None) -> bytes:
141
+ n = self.spec.frame_len
142
+ buf = bytearray()
143
+ while True:
144
+ if deadline is not None and time.monotonic() > deadline:
145
+ raise NoReplyError(f"{self.spec.model}: no valid 5C frame before deadline")
146
+ byte = self.ser.read(1)
147
+ if not byte:
148
+ raise NoReplyError(
149
+ f"{self.spec.model}: serial read timed out at {self.baud} bps"
150
+ )
151
+ buf += byte
152
+ if len(buf) > 2 * n:
153
+ del buf[:-2 * n]
154
+ for _, frame in pb.iter_data_frames(bytes(buf), self.spec):
155
+ return frame
156
+
157
+ def _command_b(self, request: bytes, expected_command: int) -> pb.ReplyB:
158
+ self.ser.write(request)
159
+ buf = bytearray()
160
+ deadline = time.monotonic() + max(self.timeout, 0.2)
161
+ while time.monotonic() < deadline:
162
+ byte = self.ser.read(1)
163
+ if not byte:
164
+ continue
165
+ buf += byte
166
+ # Command replies are short; scan for a 5A frame that verifies.
167
+ for start in range(len(buf)):
168
+ if buf[start] != pb.CMD_HEADER:
169
+ continue
170
+ for end in range(start + 4, len(buf) + 1):
171
+ chunk = bytes(buf[start:end])
172
+ if pb.verify(chunk):
173
+ return pb.parse_command_reply(chunk, expected_command)
174
+ raise NoReplyError(
175
+ f"{self.spec.model}: no reply to command 0x{expected_command:02X}"
176
+ )
177
+
178
+ # -- ranging -----------------------------------------------------------
179
+ def measure(self, raise_on_failure: bool = True):
180
+ """Take a single measurement.
181
+
182
+ Family A sends ``55 AA 88 ...`` and waits for the reply. Family B
183
+ modules free-run, so this starts ranging where the model documents a
184
+ start command and returns the first valid data frame.
185
+ """
186
+ if self.spec.family == "A":
187
+ self.ser.reset_input_buffer()
188
+ self.ser.write(pa.single_shot())
189
+ frame = self._read_frame_a(
190
+ pa.FN_SINGLE, deadline=time.monotonic() + max(self.timeout, 0.2)
191
+ )
192
+ m = pa.parse_measurement(frame, self.spec, expected_function=pa.FN_SINGLE)
193
+ if raise_on_failure and self.spec.sources.get("status") and not m.ok:
194
+ raise MeasurementFailed(m.status)
195
+ return m
196
+ # family B
197
+ started = False
198
+ if self.spec.supports(CAP_START_STOP) and not self._streaming:
199
+ self.ser.write(pb.start_ranging())
200
+ started = True
201
+ try:
202
+ return pb.parse_data_frame(
203
+ self._read_frame_b(deadline=time.monotonic() + max(self.timeout, 0.5)),
204
+ self.spec,
205
+ )
206
+ finally:
207
+ if started:
208
+ self.ser.write(pb.stop_ranging())
209
+
210
+ def stream(self, acknowledge_protocol_hold: bool = False) -> Iterator:
211
+ """Yield measurements continuously until the generator is closed."""
212
+ if self.spec.family == "A":
213
+ self._require(CAP_CONTINUOUS, "continuous ranging")
214
+ if self.spec.continuous_hold and not acknowledge_protocol_hold:
215
+ raise ProtocolHoldError(
216
+ f"{self.spec.model}: {self.spec.continuous_hold}\n"
217
+ "Pass acknowledge_protocol_hold=True to proceed on your own "
218
+ "engineering judgement, or confirm the reply function byte "
219
+ "with ERDI first (https://erdilrf.com/contact/)."
220
+ )
221
+ self.ser.reset_input_buffer()
222
+ self.ser.write(pa.continuous())
223
+ self._streaming = True
224
+ try:
225
+ while True:
226
+ frame = self._read_frame_a()
227
+ if frame[2] not in (pa.FN_SINGLE, pa.FN_CONTINUOUS):
228
+ continue
229
+ yield pa.parse_measurement(frame, self.spec)
230
+ finally:
231
+ self.stop()
232
+ else:
233
+ if self.spec.supports(CAP_START_STOP):
234
+ self.ser.write(pb.start_ranging())
235
+ self._streaming = True
236
+ try:
237
+ while True:
238
+ yield pb.parse_data_frame(self._read_frame_b(), self.spec)
239
+ finally:
240
+ self.stop()
241
+
242
+ def stop(self) -> None:
243
+ """Stop continuous ranging, where the model documents a stop command."""
244
+ try:
245
+ if self.spec.family == "A":
246
+ self.ser.write(pa.stop())
247
+ elif self.spec.supports(CAP_START_STOP):
248
+ self.ser.write(pb.stop_ranging())
249
+ finally:
250
+ self._streaming = False
251
+
252
+ def measure_angle(self):
253
+ """Angle measurement (family A ``0x8A``), for units with an angle sensor."""
254
+ self._require(CAP_ANGLE, "angle measurement")
255
+ self.ser.reset_input_buffer()
256
+ self.ser.write(pa.angle())
257
+ frame = self._read_frame_a(
258
+ pa.FN_ANGLE, deadline=time.monotonic() + max(self.timeout, 0.2)
259
+ )
260
+ return pa.parse_measurement(frame, self.spec, expected_function=pa.FN_ANGLE)
261
+
262
+ # -- parameters --------------------------------------------------------
263
+ def set_baud(self, bps: int) -> None:
264
+ """Set the module's UART baud rate.
265
+
266
+ Family A: takes effect only after a reboot ("The baud rate change does
267
+ not take effect immediately"). Family B: the module answers at the old
268
+ rate, then switches.
269
+ """
270
+ self._require(CAP_SET_BAUD, "baud-rate setting")
271
+ if self.spec.family == "A":
272
+ table = self.spec.baud_types or {}
273
+ codes = [c for c, v in table.items() if v == bps]
274
+ if not codes:
275
+ raise UndocumentedError(
276
+ f"{self.spec.model}: {bps} bps is not in this model's TYPE "
277
+ f"table {dict(sorted(table.items()))}"
278
+ )
279
+ frame = (pa.set_baud_spd1200d03(codes[0])
280
+ if self.spec.model == "SPD1200D03"
281
+ else pa.set_baud(codes[0]))
282
+ self.ser.write(frame)
283
+ return
284
+ self._command_b(pb.set_baud(bps), pb.CMD_SET_BAUD)
285
+
286
+ def read_serial_number(self) -> int:
287
+ """Family B: read the module serial number (little-endian)."""
288
+ self._require(CAP_READ_SERIAL, "serial-number read")
289
+ four_byte = self.spec.model in ("LRF22VB", "LRF50VB")
290
+ request = pb.read_serial_number_4() if four_byte else pb.read_serial_number()
291
+ reply = self._command_b(request, pb.CMD_READ_SERIAL)
292
+ return int.from_bytes(reply.data, "little")
293
+
294
+ def read_software_version(self) -> str:
295
+ """Family B: read the software version, e.g. ``"2.3"``.
296
+
297
+ Source: "03 02 means the software version no. is V2.3: little-endian, 02
298
+ represents 2, 03 represents 3, Add a point (.) in the middle."
299
+ """
300
+ self._require(CAP_READ_VERSION, "software-version read")
301
+ reply = self._command_b(pb.read_software_version(), pb.CMD_READ_SOFTWARE)
302
+ if len(reply.data) < 2:
303
+ raise NoReplyError(f"{self.spec.model}: short version reply {reply.frame.hex(' ')}")
304
+ return f"{reply.data[1]}.{reply.data[0]}"
305
+
306
+ def set_frequency(self, hz: float) -> float:
307
+ """Family B: set the output frequency; returns the achievable frequency.
308
+
309
+ ``f = base / (divisor + 1)`` where the base is model specific.
310
+ """
311
+ self._require(CAP_SET_FREQUENCY, "output-frequency setting")
312
+ base = self.spec.frequency_base_hz
313
+ if base is None:
314
+ raise UndocumentedError(
315
+ f"{self.spec.model}: the manual prints no frequency divisor formula."
316
+ )
317
+ divisor = pb.hz_to_divisor(hz, base)
318
+ self._command_b(pb.set_frequency_divisor(divisor), pb.CMD_SET_FREQUENCY)
319
+ return pb.divisor_to_hz(divisor, base)
320
+
321
+ def read_frequency(self) -> float:
322
+ """Family B: read the current output frequency in Hz."""
323
+ self._require(CAP_READ_FREQUENCY, "output-frequency read")
324
+ base = self.spec.frequency_base_hz
325
+ if base is None:
326
+ raise UndocumentedError(
327
+ f"{self.spec.model}: the manual prints no frequency divisor formula."
328
+ )
329
+ reply = self._command_b(pb.read_frequency(), pb.CMD_READ_FREQUENCY)
330
+ divisor = int.from_bytes(reply.data[:2], "little")
331
+ return pb.divisor_to_hz(divisor, base)
332
+
333
+ def switch_to_iic(self) -> None:
334
+ """Family B (VB series): switch the module from UART to I2C.
335
+
336
+ There is no documented UART command to switch back -- see the codec
337
+ docstring.
338
+ """
339
+ self._require(CAP_SWITCH_TO_IIC, "switching to I2C")
340
+ self._command_b(pb.switch_to_iic(), pb.CMD_SWITCH_TO_IIC)
341
+
342
+ def set_ld_always_on(self, enable: bool) -> None:
343
+ """Family A ``0x86``. Manual caution: do not leave this on for long."""
344
+ self._require(CAP_LD_ALWAYS_ON, "LD constant-ON mode")
345
+ self.ser.write(pa.ld_always_on(enable))
346
+
347
+ def set_external_circuit(self, enable: bool) -> None:
348
+ """Family A ``0x70`` / ``0x71`` (LRF1200A1, LRF3000A1).
349
+
350
+ Disabling puts the module into the manual's secondary low-power mode.
351
+ """
352
+ self._require(CAP_EXTERNAL_CIRCUIT, "external-circuit enable/disable")
353
+ self.ser.write(pa.external_circuit(enable))