erdilrf-lrf 0.2.0__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,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,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,119 @@
1
+ # Python host driver
2
+
3
+ Host-side UART driver for **ERDI 905 nm laser rangefinder modules** — the LR1000E2 protocol family.
4
+
5
+ **Zero core dependencies** — framing and decoding are pure standard library, so the whole driver
6
+ runs and is testable on any machine, including one with no hardware attached. `pyserial` is optional
7
+ and only needed to actually open a port.
8
+
9
+ ```bash
10
+ pip install -e . # from this directory (python/)
11
+ pip install -e python/ # from the repository root — same thing
12
+ pip install pyserial # only needed to open a real port
13
+ ```
14
+
15
+ ## Command line
16
+
17
+ Every command below runs with **no hardware and no serial port** except the last three:
18
+
19
+ ```bash
20
+ erdilrf frames # print the whole command table
21
+ erdilrf decode "55 AA 88 01 FF 27 10 BE" # decode a captured frame
22
+ erdilrf ports
23
+ erdilrf measure --port COM3 --count 5
24
+ erdilrf stream --port COM3
25
+ erdilrf selftest --port COM3 # run immediately after power-on
26
+ ```
27
+
28
+ ## Library
29
+
30
+ ```python
31
+ from erdilrf_lrf import Rangefinder, SerialTransport
32
+
33
+ with Rangefinder(SerialTransport("COM3")) as rf:
34
+ print(rf.single_shot()) # 123.4 metres, or None if the shot failed
35
+ for distance in rf.stream(count=10): # 3-10 Hz adaptive continuous ranging
36
+ print(distance)
37
+ ```
38
+
39
+ ## Protocol in one table
40
+
41
+ Fixed 8-byte frames. The checksum is the low byte of a sum — and **the two directions use different
42
+ ranges**, which is the single most common integration mistake.
43
+
44
+ ```text
45
+ offset 0 1 2 3 4 5 6 7
46
+ 0x55 0xAA FUNC D1 D2 D3 D4 SUM
47
+
48
+ host -> module SUM = (FUNC + D1 + D2 + D3 + D4) & 0xFF
49
+ module -> host SUM = (0x55 + 0xAA + FUNC + D1 + D2 + D3 + D4) & 0xFF
50
+ ```
51
+
52
+ | Command | Transmit | Response |
53
+ |---|---|---|
54
+ | Single-shot ranging | `55 AA 88 FF FF FF FF 84` | `55 AA 88 STA FF DIS_H DIS_L SUM` |
55
+ | Continuous ranging | `55 AA 89 FF FF FF FF 85` | `55 AA 89 STA FF DIS_H DIS_L SUM` |
56
+ | Stop ranging | `55 AA 8E FF FF FF FF 8A` | `55 AA 8E STA FF FF FF SUM` |
57
+ | Angle measurement † | `55 AA 8A FF FF FF FF 86` | `55 AA 8A STA FF ANG_H ANG_L SUM` |
58
+ | LD constant-on on / off | `55 AA 86 FF FF FF 01 84` / `… 00 83` | see known gap below |
59
+ | Set baud rate | `55 AA TYPE FF FF FF FF SUM` | `55 AA TYPE STA FF FF FF SUM` |
60
+
61
+ Distance and angle are both scaled by 10 — the 16-bit register holds tenths, so
62
+ `((DIS_H << 8) | DIS_L) / 10` gives metres.
63
+
64
+ † Angle measurement is available **only on modules fitted with an angle sensor**.
65
+
66
+ Default link: **115200 bps, 8 data bits, 8-byte frame**. The manufacturer documents 115200 and 8 data
67
+ bits; it is **silent on parity and stop bits**, so this driver assumes 8N1 and says so. If a module
68
+ stays silent, try `--parity E` or `--parity O` before assuming a wiring fault.
69
+
70
+ ## Tests
71
+
72
+ 50 tests, no hardware and no serial port required — serial I/O is injected as a two-method
73
+ transport, so the whole driver runs against a scripted fake link.
74
+
75
+ ```bash
76
+ python -m pytest -q
77
+ ```
78
+
79
+ The suite has already caught real defects in this repository: a `FrameReader` that validated
80
+ *transmitted* frames with the *response* checksum rule, and a pre-command flush that silently ate
81
+ the answer to the command it had just sent (a wrong distance with no error — the worst failure this
82
+ driver can produce).
83
+
84
+ Golden vectors assert that all six transmit frames printed in the manufacturer's manual are
85
+ reproduced byte for byte, and that each printed checksum is reproducible from the stated rule.
86
+
87
+ ## What the tests do not cover
88
+
89
+ No test in this repository has ever run against a real module. The framing is verified against the
90
+ manual and against itself, **not against silicon**. A capture from real hardware outranks every
91
+ table above — if your module disagrees, open an issue with the raw bytes.
92
+
93
+ ## Known gaps we did not paper over
94
+
95
+ * **The LD constant-on response frame is incomplete in the published manual** — section 8 prints
96
+ only `55` for that row. This driver sends the command and does not validate a reply. It does not
97
+ guess the missing bytes.
98
+ * **Cable wire colours are not documented.** The manual gives pin numbers only:
99
+ 1 GND, 2 VCC, 3 I/O (reserved), 4 TXD, 5 RXD, 6 SW-SHOT.
100
+ * **SW-SHOT polarity is configuration-dependent.** The manual says "function enable (active high)"
101
+ and in the same row "compatible with active-low control requirements". Confirm per unit.
102
+ * **UART logic-level tolerance is not specified.** Only the 3.3–5 V supply figure is given.
103
+
104
+ ---
105
+
106
+ ## About this file
107
+
108
+ This is the **PyPI long description** for the `erdilrf-lrf` distribution. The packaging toolchain
109
+ cannot read a file outside the project directory, so this file lives here rather than reusing the
110
+ repository root `README.md`. It mirrors the Python-relevant sections of that root README; the root
111
+ README remains authoritative for the repository as a whole (protocol reference, Arduino driver,
112
+ wiring notes).
113
+
114
+ Installable package name: **`erdilrf-lrf`** · import name: **`erdilrf_lrf`** · CLI: **`erdilrf`**.
115
+ Note that the package name differs from the import name and from the CLI name — see
116
+ [`pyproject.toml`](pyproject.toml) for the single source of truth.
117
+
118
+ Part of [erdilrf/erdilrf-drivers](https://github.com/erdilrf/erdilrf-drivers) · MIT ·
119
+ modules documented at [erdilrf.com](https://erdilrf.com)
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "erdilrf-lrf"
7
+ version = "0.2.0"
8
+ description = "Host-side UART driver for ERDI 905 nm laser rangefinder modules (LR1000E2 protocol family)"
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = [
14
+ "laser-rangefinder", "lidar", "dtof", "905nm", "uart",
15
+ "embedded", "robotics", "drone", "industrial-automation",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: Manufacturing",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator",
24
+ "Topic :: System :: Hardware :: Hardware Drivers",
25
+ ]
26
+
27
+ # Core framing and decoding are pure standard library on purpose: this code is meant to be
28
+ # readable and testable on any machine, including one with no hardware attached.
29
+ dependencies = []
30
+
31
+ [project.optional-dependencies]
32
+ serial = ["pyserial>=3.5"]
33
+ dev = ["pytest>=7"]
34
+
35
+ [project.scripts]
36
+ erdilrf = "erdilrf_lrf.cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://erdilrf.com"
40
+ Repository = "https://github.com/erdilrf/erdilrf-drivers"
41
+ Documentation = "https://github.com/erdilrf/erdilrf-drivers/blob/main/protocol/PROTOCOL.md"
42
+
43
+ [tool.setuptools]
44
+ package-dir = { "" = "src" }
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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())