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.
- erdilrf_lrf-0.2.0/LICENSE +21 -0
- erdilrf_lrf-0.2.0/PKG-INFO +144 -0
- erdilrf_lrf-0.2.0/README.md +119 -0
- erdilrf_lrf-0.2.0/pyproject.toml +50 -0
- erdilrf_lrf-0.2.0/setup.cfg +4 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf/__init__.py +63 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf/cli.py +216 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf/device.py +274 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf/frames.py +340 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf.egg-info/PKG-INFO +144 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf.egg-info/SOURCES.txt +16 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf.egg-info/dependency_links.txt +1 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf.egg-info/entry_points.txt +2 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf.egg-info/requires.txt +6 -0
- erdilrf_lrf-0.2.0/src/erdilrf_lrf.egg-info/top_level.txt +1 -0
- erdilrf_lrf-0.2.0/tests/test_arduino_parity.py +158 -0
- erdilrf_lrf-0.2.0/tests/test_device.py +221 -0
- erdilrf_lrf-0.2.0/tests/test_vectors.py +270 -0
|
@@ -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,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())
|