leuze-rsl 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.
- leuze_rsl-0.2.0/LICENSE +21 -0
- leuze_rsl-0.2.0/PKG-INFO +279 -0
- leuze_rsl-0.2.0/README.md +259 -0
- leuze_rsl-0.2.0/pyproject.toml +31 -0
- leuze_rsl-0.2.0/setup.cfg +4 -0
- leuze_rsl-0.2.0/src/leuze_rsl/__init__.py +83 -0
- leuze_rsl-0.2.0/src/leuze_rsl/driver.py +786 -0
- leuze_rsl-0.2.0/src/leuze_rsl/errors.py +25 -0
- leuze_rsl-0.2.0/src/leuze_rsl/parser.py +257 -0
- leuze_rsl-0.2.0/src/leuze_rsl/protocol.py +341 -0
- leuze_rsl-0.2.0/src/leuze_rsl/py.typed +0 -0
- leuze_rsl-0.2.0/src/leuze_rsl/simulator.py +635 -0
- leuze_rsl-0.2.0/src/leuze_rsl/udt.py +621 -0
- leuze_rsl-0.2.0/src/leuze_rsl.egg-info/PKG-INFO +279 -0
- leuze_rsl-0.2.0/src/leuze_rsl.egg-info/SOURCES.txt +19 -0
- leuze_rsl-0.2.0/src/leuze_rsl.egg-info/dependency_links.txt +1 -0
- leuze_rsl-0.2.0/src/leuze_rsl.egg-info/top_level.txt +1 -0
- leuze_rsl-0.2.0/tests/test_driver_sim.py +258 -0
- leuze_rsl-0.2.0/tests/test_parser.py +180 -0
- leuze_rsl-0.2.0/tests/test_protocol.py +155 -0
- leuze_rsl-0.2.0/tests/test_udt.py +331 -0
leuze_rsl-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arnau Montplet
|
|
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.
|
leuze_rsl-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: leuze-rsl
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python driver for the measurement data output (UDP data telegrams) of Leuze RSL 235 safety laser scanners
|
|
5
|
+
Author: 0xarnio
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/0xarn-io/leuze-rsl
|
|
8
|
+
Project-URL: Repository, https://github.com/0xarn-io/leuze-rsl
|
|
9
|
+
Project-URL: Issues, https://github.com/0xarn-io/leuze-rsl/issues
|
|
10
|
+
Keywords: leuze,rsl235,rsl200,rod300,rod500,laser scanner,lidar,agv,udp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# leuze-rsl
|
|
22
|
+
|
|
23
|
+
Pure-Python driver for the **measurement data output** of the Leuze **RSL 235**
|
|
24
|
+
safety laser scanner — the **UDP data telegrams ("UDT")** used for AGV/AMR
|
|
25
|
+
navigation.
|
|
26
|
+
|
|
27
|
+
- No dependencies (Python ≥ 3.9, stdlib only), thread-based, type annotated
|
|
28
|
+
- Receives and reassembles the scanner's UDP push stream: distances [mm],
|
|
29
|
+
signal strengths, beam angles, plus the safety **status profile** (OSSD
|
|
30
|
+
states, field violations, contamination, …)
|
|
31
|
+
- Robust against lost datagrams and fragmented scans (never emits silently
|
|
32
|
+
truncated scans), wrap-aware scan numbering
|
|
33
|
+
- Ships **protocol simulators** so everything works without hardware
|
|
34
|
+
- Bonus: a full client for the ROD-300-500 protocol (Leuze ROD x08
|
|
35
|
+
navigation scanners), see [below](#the-rod-300-500-protocol-rod-x08)
|
|
36
|
+
|
|
37
|
+
**Verified against real RSL 235 hardware:** the device *pushes* UDP data
|
|
38
|
+
telegrams in the format of the Leuze *RSL 400 UDP specification* (document
|
|
39
|
+
50130122) to a destination configured in Sensor Studio — there is no TCP
|
|
40
|
+
command channel. The wire format is documented [below](#protocol-reference).
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install leuze-rsl
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or from a checkout of this repository:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install . # from the repository root
|
|
52
|
+
# offline machine? avoid the PyPI round-trip:
|
|
53
|
+
pip install . --no-build-isolation
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
No install needed either: the package is pure Python — copy `src/leuze_rsl/`
|
|
57
|
+
next to your script (as `leuze_rsl/`), or run the examples straight from the
|
|
58
|
+
checkout.
|
|
59
|
+
|
|
60
|
+
## Scanner setup (Sensor Studio)
|
|
61
|
+
|
|
62
|
+
1. Open the scanner's configuration project, go to **SETTINGS → Data
|
|
63
|
+
telegrams**.
|
|
64
|
+
2. **Activate the UDP telegram** and set the **destination**: the IP of the
|
|
65
|
+
machine running this driver and a port (e.g. 3050).
|
|
66
|
+
3. Under **Measurement values in UDP telegram**, enable **measurement value
|
|
67
|
+
transmission**; choose start/stop index, index interval and the data
|
|
68
|
+
type — *Distance (ID 6)* or *Distance + signal strength (ID 3)*.
|
|
69
|
+
4. Transfer the configuration. The scanner starts pushing immediately.
|
|
70
|
+
5. Allow **inbound UDP** on that port in your firewall.
|
|
71
|
+
|
|
72
|
+
## Quickstart
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from leuze_rsl import RSL235Udt
|
|
76
|
+
|
|
77
|
+
with RSL235Udt(port=3050) as receiver: # the port configured above
|
|
78
|
+
scan = receiver.get_scan(timeout=5.0) # one assembled scan cycle
|
|
79
|
+
print(scan.scan_number,
|
|
80
|
+
scan.distances_mm[:5], # [mm], 0 = no echo
|
|
81
|
+
scan.signal_strengths[:5], # None for data type ID 6
|
|
82
|
+
scan.angles_deg()[:5]) # beam angles
|
|
83
|
+
|
|
84
|
+
status = receiver.latest_status # safety status profile
|
|
85
|
+
print(status.ossd_a, status.a_protective_violated, status.error)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Every `UdtScan` exposes:
|
|
89
|
+
|
|
90
|
+
| attribute | meaning |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `distances_mm` / `distances_m` | distance per beam (0 = no valid echo) |
|
|
93
|
+
| `signal_strengths` | signal strength per beam [digits], or `None` |
|
|
94
|
+
| `beam_indices()` / `angles_deg()` | beam geometry from the measurement contour |
|
|
95
|
+
| `to_cartesian()` | `(x, y)` points in meters, invalid beams skipped |
|
|
96
|
+
| `scan_number`, `num_blocks`, `contour`, `status` | telegram metadata |
|
|
97
|
+
|
|
98
|
+
Constructor knobs: `bind_address`, `source_ip` (ignore datagrams from other
|
|
99
|
+
devices), `strict` (drop incomplete scans — default — or emit them short),
|
|
100
|
+
`scan_queue_size`, `on_scan` / `on_status` callbacks, `data_timeout`
|
|
101
|
+
(drives `receiver.is_receiving`). Diagnostics: `receiver.stats`.
|
|
102
|
+
|
|
103
|
+
### Examples
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python3 examples/read_udt.py --port 3050 --count 10 # print scans + status
|
|
107
|
+
python3 examples/dump_udt.py --port 3050 # raw datagram decoder/hexdump
|
|
108
|
+
python3 examples/live_plot.py --port 3050 # polar live view (matplotlib)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
All accept `--simulate` to run against the built-in simulator instead of
|
|
112
|
+
hardware; the simulator also runs standalone:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
python3 -m leuze_rsl.simulator --port 3050 # push UDT to 127.0.0.1:3050
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Angle convention
|
|
119
|
+
|
|
120
|
+
Beam *indices* step 0.1°; angles default to a 275° field of view centered on
|
|
121
|
+
the device front (`angle = index * 0.1° − 137.5°`; the RSL 400 spans 270°,
|
|
122
|
+
so use `angle_at_index0=-135.0` there). Verify against a known target and
|
|
123
|
+
pass your own offset to `angles_deg()` / `to_cartesian()` if needed.
|
|
124
|
+
|
|
125
|
+
## Protocol reference
|
|
126
|
+
|
|
127
|
+
As specified in the Leuze *RSL 400 UDP specification* (50130122) and
|
|
128
|
+
verified against RSL 235 hardware. Every datagram starts with a 20-byte
|
|
129
|
+
frame, **all fields little-endian**:
|
|
130
|
+
|
|
131
|
+
| offset | size | field | description |
|
|
132
|
+
|---|---|---|---|
|
|
133
|
+
| 0 | 4 | `total_length` | length of the whole datagram in bytes |
|
|
134
|
+
| 4 | 1 | `h1_size` | 8 |
|
|
135
|
+
| 5 | 1 | `follow_flag` | |
|
|
136
|
+
| 6 | 2 | `request_id` | |
|
|
137
|
+
| 8 | 4 | `header2` | internal |
|
|
138
|
+
| 12 | 2 | `telegram_id` | 1 = extended status profile, 6 = distance, 3 = distance + signal strength |
|
|
139
|
+
| 14 | 2 | `block` | fragment number within the scan (0…65535) |
|
|
140
|
+
| 16 | 4 | `scan_number` | scan cycle counter (wraps at 2³²) |
|
|
141
|
+
|
|
142
|
+
- **ID 1 — extended status profile** (sent once per scan cycle): 20-byte
|
|
143
|
+
status profile (operating mode; ERROR/ALARM/contamination flags; OSSD A/B;
|
|
144
|
+
protective/warning field violations and field-pair selections for
|
|
145
|
+
functions A and B; embedded scan number) followed by the 8-byte
|
|
146
|
+
**measurement contour description**: `start_index u16`, `stop_index u16`,
|
|
147
|
+
`index_interval u16`, `reserved u16` → beams per scan
|
|
148
|
+
`n = 1 + ceil((stop − start) / interval)`.
|
|
149
|
+
*RSL 200 note:* the datagram is 56 bytes — 8 more than the RSL 400
|
|
150
|
+
document describes — so the driver locates the contour adaptively and
|
|
151
|
+
verifies it against the observed beam count.
|
|
152
|
+
- **ID 6 — distance**: n × `u16` distance [mm].
|
|
153
|
+
- **ID 3 — distance + signal strength**: n × (`u16` distance [mm] +
|
|
154
|
+
`u16` signal strength [digits]).
|
|
155
|
+
- Large scans are split across datagrams with consecutive `block` numbers;
|
|
156
|
+
all datagrams of one cycle share the `scan_number`.
|
|
157
|
+
- **No-echo sentinel:** real RSL 235 hardware reports **32767 mm** (0x7FFF)
|
|
158
|
+
for beams without a valid echo (0 also counts as invalid). Use
|
|
159
|
+
`scan.valid_mask()`; `to_cartesian()` skips these automatically. The
|
|
160
|
+
RSL 235 measures 0.08…25 m.
|
|
161
|
+
|
|
162
|
+
A capture from real hardware (56-byte status + single 1104-byte
|
|
163
|
+
distance+signal datagram = 271 beams per cycle):
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
38 00 00 00 08 00 06 00 04 0b 01 32 01 00 00 00 ... ID 1, 56 B
|
|
167
|
+
50 04 00 00 08 00 06 00 04 0b 01 32 03 00 00 00 ... ID 3, 1104 B
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## The ROD-300-500 protocol (ROD x08)
|
|
173
|
+
|
|
174
|
+
The package also contains a complete client for the protocol used by Leuze
|
|
175
|
+
**ROD 308 / ROD 508** navigation scanners (reverse engineered from Leuze's
|
|
176
|
+
official [RODx00 ROS2 driver](https://github.com/thesensorpeople/leuze_rod_ros2_drivers)):
|
|
177
|
+
an STX/ETX-framed **TCP command interface** (`cWN SendMDI`, `cRN GetRange`, …
|
|
178
|
+
default port 3050) plus big-endian `LEUZ`-sync **MDI packets** over UDP or
|
|
179
|
+
TCP.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from leuze_rsl import RSL235
|
|
183
|
+
|
|
184
|
+
with RSL235("192.168.60.101") as scanner: # TCP connect + config read
|
|
185
|
+
scanner.start_measurement() # sends "SendMDI"
|
|
186
|
+
scan = scanner.get_scan(timeout=5.0)
|
|
187
|
+
print(scanner.get_temperature(), scanner.get_status())
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
python3 examples/read_rod.py 192.168.60.101 --count 10 # scans (--simulate works)
|
|
192
|
+
python3 examples/read_status.py 192.168.60.101 # full status dump
|
|
193
|
+
python3 -m leuze_rsl.simulator --rod --port 3050 # ROD-protocol simulator
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
<details>
|
|
197
|
+
<summary>ROD-300-500 protocol reference (click to expand)</summary>
|
|
198
|
+
|
|
199
|
+
### TCP command interface
|
|
200
|
+
|
|
201
|
+
ASCII commands framed by `STX` (0x02) and `ETX` (0x03), e.g.
|
|
202
|
+
`<STX>cWN SendMDI<ETX>`. Replies echo the command name:
|
|
203
|
+
`<STX>cWA SendMDI<ETX>` resp. `<STX>cRA GetRange -13760 13760<ETX>`
|
|
204
|
+
(space-separated decimal values).
|
|
205
|
+
|
|
206
|
+
| Command | Reply values | Meaning |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `cWN SendMDI` | – | start streaming measurement data |
|
|
209
|
+
| `cWN StopMDI` | – | stop streaming |
|
|
210
|
+
| `cRN GetProto` | 1 | MDI transport: 0 = UDP, 1 = TCP |
|
|
211
|
+
| `cRN GetPType` | 1 | 0 = distances only, 1 = distances + intensities |
|
|
212
|
+
| `cRN GetResol` | 1 | 0: 0.2°@80 Hz, 1: 0.1°@40 Hz, 2: 0.05°@20 Hz, 3: 0.025°@10 Hz, 4: 0.2°@50 Hz |
|
|
213
|
+
| `cRN GetDir` | 1 | data output direction: 0 = CW, 1 = CCW |
|
|
214
|
+
| `cRN GetRange` | 2 | scan angle min, max in 0.01° (−13760…13760) |
|
|
215
|
+
| `cRN GetSkip` | 1 | spots skipped between output measurements |
|
|
216
|
+
| `cRN GetCont` | 2 | contamination warning / error threshold [%] |
|
|
217
|
+
| `cRN GetWinStat` | 9 | contamination per window segment [%] |
|
|
218
|
+
| `cRN GetVer` | 7 | part number, HW version, SW version, 4 × undocumented |
|
|
219
|
+
| `cRN GetTem` | 1 | internal temperature [0.01 °C] |
|
|
220
|
+
| `cRN GetELog` | 21 | 1 undocumented value, then 10 × (error code, date) |
|
|
221
|
+
| `cRN GetHours` | 1 | operating hours |
|
|
222
|
+
| `cRN GetWCalib` | 1 | window calibration: 0 processing, 1 done, 3 failed |
|
|
223
|
+
| `cRN GetFilter` | 3 | filter type (0 median, 1 average, 2 max, 3 combo), historical spots, neighboring spots |
|
|
224
|
+
| `cRN GetECode` | 1 | currently active error code (0 = none) |
|
|
225
|
+
| `cRN GetTxMDI` | 1 | MDI stream running: 0/1 |
|
|
226
|
+
|
|
227
|
+
Startup sequence: TCP connect → `StopMDI` (handshake) → read settings →
|
|
228
|
+
bind the local UDP port (same number as the TCP port) → `SendMDI`. With
|
|
229
|
+
`GetProto == TCP` the MDI packets arrive interleaved with command replies
|
|
230
|
+
on the same TCP socket; the driver demultiplexes them.
|
|
231
|
+
|
|
232
|
+
### MDI measurement packets
|
|
233
|
+
|
|
234
|
+
One scan is split into `total_number` packets, each with a 31-byte header.
|
|
235
|
+
**All multi-byte fields big-endian.**
|
|
236
|
+
|
|
237
|
+
| offset | size | field | description |
|
|
238
|
+
|---|---|---|---|
|
|
239
|
+
| 0 | 4 | `sync` | `4C 45 55 5A` = `"LEUZ"` |
|
|
240
|
+
| 4 | 1 | `packet_type` | 0 = distances only, 1 = distances + intensities |
|
|
241
|
+
| 5 | 2 | `packet_size` | total packet size in bytes, incl. header |
|
|
242
|
+
| 7 | 6 | `reserve_a/b/c` | 3 × u16, reserved |
|
|
243
|
+
| 13 | 2 | `packet_number` | sequence number since power-up (wraps at 2¹⁶) |
|
|
244
|
+
| 15 | 1 | `total_number` | packets per full scan |
|
|
245
|
+
| 16 | 1 | `sub_number` | 1-based index of this packet within the scan |
|
|
246
|
+
| 17 | 2 | `scan_freq` | scan frequency [Hz] |
|
|
247
|
+
| 19 | 2 | `scan_spots` | number of spots in *this* packet |
|
|
248
|
+
| 21 | 4 | `first_angle` | i32, absolute angle of the first spot [1/1000°] |
|
|
249
|
+
| 25 | 4 | `delta_angle` | i32, angle between consecutive spots [1/1000°] |
|
|
250
|
+
| 29 | 2 | `timestamp` | [ms], wraps at 2¹⁶ |
|
|
251
|
+
|
|
252
|
+
Payload: `scan_spots` × u16 distances [mm], then `scan_spots` × u16
|
|
253
|
+
intensities (valid 32…4095) when `packet_type == 1`. Angles are scanner
|
|
254
|
+
native; Leuze's ROS driver negates them for REP-103. The reference C++
|
|
255
|
+
driver's weaknesses (infinite loop on `packet_size == 0`, command replies
|
|
256
|
+
lost across TCP segment boundaries, partial-scan emission, UDP bind race)
|
|
257
|
+
are fixed in this implementation.
|
|
258
|
+
|
|
259
|
+
</details>
|
|
260
|
+
|
|
261
|
+
## Tests
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
pip install -e . # the src layout needs an install first
|
|
265
|
+
python3 -m unittest discover -s tests -v
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
62 tests cover both wire formats, the scan assemblers (corruption, loss,
|
|
269
|
+
resync, fragmentation, wraparound) and the full receivers/drivers against
|
|
270
|
+
the simulators over real sockets — including frames replayed byte-for-byte
|
|
271
|
+
from an RSL 235 hardware capture.
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT — see [LICENSE](LICENSE). The ROD-300-500 protocol was reverse
|
|
276
|
+
engineered from Leuze's Apache-2.0 licensed ROS2 driver (© 2025 Leuze
|
|
277
|
+
electronic GmbH + Co. KG); the UDT format follows Leuze document 50130122.
|
|
278
|
+
No Leuze code is included. This project is not affiliated with or endorsed
|
|
279
|
+
by Leuze.
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# leuze-rsl
|
|
2
|
+
|
|
3
|
+
Pure-Python driver for the **measurement data output** of the Leuze **RSL 235**
|
|
4
|
+
safety laser scanner — the **UDP data telegrams ("UDT")** used for AGV/AMR
|
|
5
|
+
navigation.
|
|
6
|
+
|
|
7
|
+
- No dependencies (Python ≥ 3.9, stdlib only), thread-based, type annotated
|
|
8
|
+
- Receives and reassembles the scanner's UDP push stream: distances [mm],
|
|
9
|
+
signal strengths, beam angles, plus the safety **status profile** (OSSD
|
|
10
|
+
states, field violations, contamination, …)
|
|
11
|
+
- Robust against lost datagrams and fragmented scans (never emits silently
|
|
12
|
+
truncated scans), wrap-aware scan numbering
|
|
13
|
+
- Ships **protocol simulators** so everything works without hardware
|
|
14
|
+
- Bonus: a full client for the ROD-300-500 protocol (Leuze ROD x08
|
|
15
|
+
navigation scanners), see [below](#the-rod-300-500-protocol-rod-x08)
|
|
16
|
+
|
|
17
|
+
**Verified against real RSL 235 hardware:** the device *pushes* UDP data
|
|
18
|
+
telegrams in the format of the Leuze *RSL 400 UDP specification* (document
|
|
19
|
+
50130122) to a destination configured in Sensor Studio — there is no TCP
|
|
20
|
+
command channel. The wire format is documented [below](#protocol-reference).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install leuze-rsl
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or from a checkout of this repository:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install . # from the repository root
|
|
32
|
+
# offline machine? avoid the PyPI round-trip:
|
|
33
|
+
pip install . --no-build-isolation
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
No install needed either: the package is pure Python — copy `src/leuze_rsl/`
|
|
37
|
+
next to your script (as `leuze_rsl/`), or run the examples straight from the
|
|
38
|
+
checkout.
|
|
39
|
+
|
|
40
|
+
## Scanner setup (Sensor Studio)
|
|
41
|
+
|
|
42
|
+
1. Open the scanner's configuration project, go to **SETTINGS → Data
|
|
43
|
+
telegrams**.
|
|
44
|
+
2. **Activate the UDP telegram** and set the **destination**: the IP of the
|
|
45
|
+
machine running this driver and a port (e.g. 3050).
|
|
46
|
+
3. Under **Measurement values in UDP telegram**, enable **measurement value
|
|
47
|
+
transmission**; choose start/stop index, index interval and the data
|
|
48
|
+
type — *Distance (ID 6)* or *Distance + signal strength (ID 3)*.
|
|
49
|
+
4. Transfer the configuration. The scanner starts pushing immediately.
|
|
50
|
+
5. Allow **inbound UDP** on that port in your firewall.
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from leuze_rsl import RSL235Udt
|
|
56
|
+
|
|
57
|
+
with RSL235Udt(port=3050) as receiver: # the port configured above
|
|
58
|
+
scan = receiver.get_scan(timeout=5.0) # one assembled scan cycle
|
|
59
|
+
print(scan.scan_number,
|
|
60
|
+
scan.distances_mm[:5], # [mm], 0 = no echo
|
|
61
|
+
scan.signal_strengths[:5], # None for data type ID 6
|
|
62
|
+
scan.angles_deg()[:5]) # beam angles
|
|
63
|
+
|
|
64
|
+
status = receiver.latest_status # safety status profile
|
|
65
|
+
print(status.ossd_a, status.a_protective_violated, status.error)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Every `UdtScan` exposes:
|
|
69
|
+
|
|
70
|
+
| attribute | meaning |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `distances_mm` / `distances_m` | distance per beam (0 = no valid echo) |
|
|
73
|
+
| `signal_strengths` | signal strength per beam [digits], or `None` |
|
|
74
|
+
| `beam_indices()` / `angles_deg()` | beam geometry from the measurement contour |
|
|
75
|
+
| `to_cartesian()` | `(x, y)` points in meters, invalid beams skipped |
|
|
76
|
+
| `scan_number`, `num_blocks`, `contour`, `status` | telegram metadata |
|
|
77
|
+
|
|
78
|
+
Constructor knobs: `bind_address`, `source_ip` (ignore datagrams from other
|
|
79
|
+
devices), `strict` (drop incomplete scans — default — or emit them short),
|
|
80
|
+
`scan_queue_size`, `on_scan` / `on_status` callbacks, `data_timeout`
|
|
81
|
+
(drives `receiver.is_receiving`). Diagnostics: `receiver.stats`.
|
|
82
|
+
|
|
83
|
+
### Examples
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
python3 examples/read_udt.py --port 3050 --count 10 # print scans + status
|
|
87
|
+
python3 examples/dump_udt.py --port 3050 # raw datagram decoder/hexdump
|
|
88
|
+
python3 examples/live_plot.py --port 3050 # polar live view (matplotlib)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
All accept `--simulate` to run against the built-in simulator instead of
|
|
92
|
+
hardware; the simulator also runs standalone:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
python3 -m leuze_rsl.simulator --port 3050 # push UDT to 127.0.0.1:3050
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Angle convention
|
|
99
|
+
|
|
100
|
+
Beam *indices* step 0.1°; angles default to a 275° field of view centered on
|
|
101
|
+
the device front (`angle = index * 0.1° − 137.5°`; the RSL 400 spans 270°,
|
|
102
|
+
so use `angle_at_index0=-135.0` there). Verify against a known target and
|
|
103
|
+
pass your own offset to `angles_deg()` / `to_cartesian()` if needed.
|
|
104
|
+
|
|
105
|
+
## Protocol reference
|
|
106
|
+
|
|
107
|
+
As specified in the Leuze *RSL 400 UDP specification* (50130122) and
|
|
108
|
+
verified against RSL 235 hardware. Every datagram starts with a 20-byte
|
|
109
|
+
frame, **all fields little-endian**:
|
|
110
|
+
|
|
111
|
+
| offset | size | field | description |
|
|
112
|
+
|---|---|---|---|
|
|
113
|
+
| 0 | 4 | `total_length` | length of the whole datagram in bytes |
|
|
114
|
+
| 4 | 1 | `h1_size` | 8 |
|
|
115
|
+
| 5 | 1 | `follow_flag` | |
|
|
116
|
+
| 6 | 2 | `request_id` | |
|
|
117
|
+
| 8 | 4 | `header2` | internal |
|
|
118
|
+
| 12 | 2 | `telegram_id` | 1 = extended status profile, 6 = distance, 3 = distance + signal strength |
|
|
119
|
+
| 14 | 2 | `block` | fragment number within the scan (0…65535) |
|
|
120
|
+
| 16 | 4 | `scan_number` | scan cycle counter (wraps at 2³²) |
|
|
121
|
+
|
|
122
|
+
- **ID 1 — extended status profile** (sent once per scan cycle): 20-byte
|
|
123
|
+
status profile (operating mode; ERROR/ALARM/contamination flags; OSSD A/B;
|
|
124
|
+
protective/warning field violations and field-pair selections for
|
|
125
|
+
functions A and B; embedded scan number) followed by the 8-byte
|
|
126
|
+
**measurement contour description**: `start_index u16`, `stop_index u16`,
|
|
127
|
+
`index_interval u16`, `reserved u16` → beams per scan
|
|
128
|
+
`n = 1 + ceil((stop − start) / interval)`.
|
|
129
|
+
*RSL 200 note:* the datagram is 56 bytes — 8 more than the RSL 400
|
|
130
|
+
document describes — so the driver locates the contour adaptively and
|
|
131
|
+
verifies it against the observed beam count.
|
|
132
|
+
- **ID 6 — distance**: n × `u16` distance [mm].
|
|
133
|
+
- **ID 3 — distance + signal strength**: n × (`u16` distance [mm] +
|
|
134
|
+
`u16` signal strength [digits]).
|
|
135
|
+
- Large scans are split across datagrams with consecutive `block` numbers;
|
|
136
|
+
all datagrams of one cycle share the `scan_number`.
|
|
137
|
+
- **No-echo sentinel:** real RSL 235 hardware reports **32767 mm** (0x7FFF)
|
|
138
|
+
for beams without a valid echo (0 also counts as invalid). Use
|
|
139
|
+
`scan.valid_mask()`; `to_cartesian()` skips these automatically. The
|
|
140
|
+
RSL 235 measures 0.08…25 m.
|
|
141
|
+
|
|
142
|
+
A capture from real hardware (56-byte status + single 1104-byte
|
|
143
|
+
distance+signal datagram = 271 beams per cycle):
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
38 00 00 00 08 00 06 00 04 0b 01 32 01 00 00 00 ... ID 1, 56 B
|
|
147
|
+
50 04 00 00 08 00 06 00 04 0b 01 32 03 00 00 00 ... ID 3, 1104 B
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## The ROD-300-500 protocol (ROD x08)
|
|
153
|
+
|
|
154
|
+
The package also contains a complete client for the protocol used by Leuze
|
|
155
|
+
**ROD 308 / ROD 508** navigation scanners (reverse engineered from Leuze's
|
|
156
|
+
official [RODx00 ROS2 driver](https://github.com/thesensorpeople/leuze_rod_ros2_drivers)):
|
|
157
|
+
an STX/ETX-framed **TCP command interface** (`cWN SendMDI`, `cRN GetRange`, …
|
|
158
|
+
default port 3050) plus big-endian `LEUZ`-sync **MDI packets** over UDP or
|
|
159
|
+
TCP.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from leuze_rsl import RSL235
|
|
163
|
+
|
|
164
|
+
with RSL235("192.168.60.101") as scanner: # TCP connect + config read
|
|
165
|
+
scanner.start_measurement() # sends "SendMDI"
|
|
166
|
+
scan = scanner.get_scan(timeout=5.0)
|
|
167
|
+
print(scanner.get_temperature(), scanner.get_status())
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
python3 examples/read_rod.py 192.168.60.101 --count 10 # scans (--simulate works)
|
|
172
|
+
python3 examples/read_status.py 192.168.60.101 # full status dump
|
|
173
|
+
python3 -m leuze_rsl.simulator --rod --port 3050 # ROD-protocol simulator
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
<details>
|
|
177
|
+
<summary>ROD-300-500 protocol reference (click to expand)</summary>
|
|
178
|
+
|
|
179
|
+
### TCP command interface
|
|
180
|
+
|
|
181
|
+
ASCII commands framed by `STX` (0x02) and `ETX` (0x03), e.g.
|
|
182
|
+
`<STX>cWN SendMDI<ETX>`. Replies echo the command name:
|
|
183
|
+
`<STX>cWA SendMDI<ETX>` resp. `<STX>cRA GetRange -13760 13760<ETX>`
|
|
184
|
+
(space-separated decimal values).
|
|
185
|
+
|
|
186
|
+
| Command | Reply values | Meaning |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `cWN SendMDI` | – | start streaming measurement data |
|
|
189
|
+
| `cWN StopMDI` | – | stop streaming |
|
|
190
|
+
| `cRN GetProto` | 1 | MDI transport: 0 = UDP, 1 = TCP |
|
|
191
|
+
| `cRN GetPType` | 1 | 0 = distances only, 1 = distances + intensities |
|
|
192
|
+
| `cRN GetResol` | 1 | 0: 0.2°@80 Hz, 1: 0.1°@40 Hz, 2: 0.05°@20 Hz, 3: 0.025°@10 Hz, 4: 0.2°@50 Hz |
|
|
193
|
+
| `cRN GetDir` | 1 | data output direction: 0 = CW, 1 = CCW |
|
|
194
|
+
| `cRN GetRange` | 2 | scan angle min, max in 0.01° (−13760…13760) |
|
|
195
|
+
| `cRN GetSkip` | 1 | spots skipped between output measurements |
|
|
196
|
+
| `cRN GetCont` | 2 | contamination warning / error threshold [%] |
|
|
197
|
+
| `cRN GetWinStat` | 9 | contamination per window segment [%] |
|
|
198
|
+
| `cRN GetVer` | 7 | part number, HW version, SW version, 4 × undocumented |
|
|
199
|
+
| `cRN GetTem` | 1 | internal temperature [0.01 °C] |
|
|
200
|
+
| `cRN GetELog` | 21 | 1 undocumented value, then 10 × (error code, date) |
|
|
201
|
+
| `cRN GetHours` | 1 | operating hours |
|
|
202
|
+
| `cRN GetWCalib` | 1 | window calibration: 0 processing, 1 done, 3 failed |
|
|
203
|
+
| `cRN GetFilter` | 3 | filter type (0 median, 1 average, 2 max, 3 combo), historical spots, neighboring spots |
|
|
204
|
+
| `cRN GetECode` | 1 | currently active error code (0 = none) |
|
|
205
|
+
| `cRN GetTxMDI` | 1 | MDI stream running: 0/1 |
|
|
206
|
+
|
|
207
|
+
Startup sequence: TCP connect → `StopMDI` (handshake) → read settings →
|
|
208
|
+
bind the local UDP port (same number as the TCP port) → `SendMDI`. With
|
|
209
|
+
`GetProto == TCP` the MDI packets arrive interleaved with command replies
|
|
210
|
+
on the same TCP socket; the driver demultiplexes them.
|
|
211
|
+
|
|
212
|
+
### MDI measurement packets
|
|
213
|
+
|
|
214
|
+
One scan is split into `total_number` packets, each with a 31-byte header.
|
|
215
|
+
**All multi-byte fields big-endian.**
|
|
216
|
+
|
|
217
|
+
| offset | size | field | description |
|
|
218
|
+
|---|---|---|---|
|
|
219
|
+
| 0 | 4 | `sync` | `4C 45 55 5A` = `"LEUZ"` |
|
|
220
|
+
| 4 | 1 | `packet_type` | 0 = distances only, 1 = distances + intensities |
|
|
221
|
+
| 5 | 2 | `packet_size` | total packet size in bytes, incl. header |
|
|
222
|
+
| 7 | 6 | `reserve_a/b/c` | 3 × u16, reserved |
|
|
223
|
+
| 13 | 2 | `packet_number` | sequence number since power-up (wraps at 2¹⁶) |
|
|
224
|
+
| 15 | 1 | `total_number` | packets per full scan |
|
|
225
|
+
| 16 | 1 | `sub_number` | 1-based index of this packet within the scan |
|
|
226
|
+
| 17 | 2 | `scan_freq` | scan frequency [Hz] |
|
|
227
|
+
| 19 | 2 | `scan_spots` | number of spots in *this* packet |
|
|
228
|
+
| 21 | 4 | `first_angle` | i32, absolute angle of the first spot [1/1000°] |
|
|
229
|
+
| 25 | 4 | `delta_angle` | i32, angle between consecutive spots [1/1000°] |
|
|
230
|
+
| 29 | 2 | `timestamp` | [ms], wraps at 2¹⁶ |
|
|
231
|
+
|
|
232
|
+
Payload: `scan_spots` × u16 distances [mm], then `scan_spots` × u16
|
|
233
|
+
intensities (valid 32…4095) when `packet_type == 1`. Angles are scanner
|
|
234
|
+
native; Leuze's ROS driver negates them for REP-103. The reference C++
|
|
235
|
+
driver's weaknesses (infinite loop on `packet_size == 0`, command replies
|
|
236
|
+
lost across TCP segment boundaries, partial-scan emission, UDP bind race)
|
|
237
|
+
are fixed in this implementation.
|
|
238
|
+
|
|
239
|
+
</details>
|
|
240
|
+
|
|
241
|
+
## Tests
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
pip install -e . # the src layout needs an install first
|
|
245
|
+
python3 -m unittest discover -s tests -v
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
62 tests cover both wire formats, the scan assemblers (corruption, loss,
|
|
249
|
+
resync, fragmentation, wraparound) and the full receivers/drivers against
|
|
250
|
+
the simulators over real sockets — including frames replayed byte-for-byte
|
|
251
|
+
from an RSL 235 hardware capture.
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT — see [LICENSE](LICENSE). The ROD-300-500 protocol was reverse
|
|
256
|
+
engineered from Leuze's Apache-2.0 licensed ROS2 driver (© 2025 Leuze
|
|
257
|
+
electronic GmbH + Co. KG); the UDT format follows Leuze document 50130122.
|
|
258
|
+
No Leuze code is included. This project is not affiliated with or endorsed
|
|
259
|
+
by Leuze.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "leuze-rsl"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "0xarnio" },
|
|
10
|
+
]
|
|
11
|
+
description = "Python driver for the measurement data output (UDP data telegrams) of Leuze RSL 235 safety laser scanners"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
keywords = ["leuze", "rsl235", "rsl200", "rod300", "rod500", "laser scanner", "lidar", "agv", "udp"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: System :: Hardware :: Hardware Drivers",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/0xarn-io/leuze-rsl"
|
|
27
|
+
Repository = "https://github.com/0xarn-io/leuze-rsl"
|
|
28
|
+
Issues = "https://github.com/0xarn-io/leuze-rsl/issues"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
leuze_rsl = ["py.typed"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""leuze_rsl -- Python driver for the Leuze RSL 235 measurement data output.
|
|
2
|
+
|
|
3
|
+
Reads the scanner's UDP data telegrams ("UDT" / MDI packets) and exposes
|
|
4
|
+
them as :class:`Scan` objects, plus the full TCP command interface for
|
|
5
|
+
configuration and status. Protocol reverse engineered from Leuze's official
|
|
6
|
+
ROD x08 ROS2 driver (the RSL 235 data output speaks the same
|
|
7
|
+
"ROD-300-500 communication protocol").
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .driver import RSL235, ErrorLogEntry, ScannerConfig, ScannerStatus
|
|
11
|
+
from .errors import (
|
|
12
|
+
CommandError,
|
|
13
|
+
CommandTimeoutError,
|
|
14
|
+
NotConnectedError,
|
|
15
|
+
ProtocolError,
|
|
16
|
+
RSL235Error,
|
|
17
|
+
ScannerConnectionError,
|
|
18
|
+
)
|
|
19
|
+
from .parser import AssemblerStats, Scan, ScanAssembler
|
|
20
|
+
from .protocol import (
|
|
21
|
+
DEFAULT_PORT,
|
|
22
|
+
INTENSITY_MIN_VALID,
|
|
23
|
+
FilterType,
|
|
24
|
+
MdiHeader,
|
|
25
|
+
MdiPacket,
|
|
26
|
+
MdiPacketType,
|
|
27
|
+
MdiProtocol,
|
|
28
|
+
Resolution,
|
|
29
|
+
ScanDirection,
|
|
30
|
+
WindowCalibration,
|
|
31
|
+
)
|
|
32
|
+
from .udt import (
|
|
33
|
+
DEFAULT_UDT_PORT,
|
|
34
|
+
NO_ECHO_DISTANCE_MM,
|
|
35
|
+
MeasurementContour,
|
|
36
|
+
RSL235Udt,
|
|
37
|
+
StatusProfile,
|
|
38
|
+
UdtAssembler,
|
|
39
|
+
UdtFrame,
|
|
40
|
+
UdtScan,
|
|
41
|
+
UdtStats,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__version__ = "0.2.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# UDP data telegrams ("UDT") -- what RSL 235 hardware pushes
|
|
48
|
+
"RSL235Udt",
|
|
49
|
+
"UdtScan",
|
|
50
|
+
"UdtAssembler",
|
|
51
|
+
"UdtFrame",
|
|
52
|
+
"UdtStats",
|
|
53
|
+
"StatusProfile",
|
|
54
|
+
"MeasurementContour",
|
|
55
|
+
"DEFAULT_UDT_PORT",
|
|
56
|
+
"NO_ECHO_DISTANCE_MM",
|
|
57
|
+
# ROD-300-500 protocol (TCP command interface + LEUZ MDI packets)
|
|
58
|
+
"RSL235",
|
|
59
|
+
"Scan",
|
|
60
|
+
"ScanAssembler",
|
|
61
|
+
"AssemblerStats",
|
|
62
|
+
"ScannerConfig",
|
|
63
|
+
"ScannerStatus",
|
|
64
|
+
"ErrorLogEntry",
|
|
65
|
+
"MdiHeader",
|
|
66
|
+
"MdiPacket",
|
|
67
|
+
"MdiProtocol",
|
|
68
|
+
"MdiPacketType",
|
|
69
|
+
"Resolution",
|
|
70
|
+
"ScanDirection",
|
|
71
|
+
"FilterType",
|
|
72
|
+
"WindowCalibration",
|
|
73
|
+
"DEFAULT_PORT",
|
|
74
|
+
"INTENSITY_MIN_VALID",
|
|
75
|
+
# errors
|
|
76
|
+
"RSL235Error",
|
|
77
|
+
"ScannerConnectionError",
|
|
78
|
+
"NotConnectedError",
|
|
79
|
+
"CommandTimeoutError",
|
|
80
|
+
"CommandError",
|
|
81
|
+
"ProtocolError",
|
|
82
|
+
"__version__",
|
|
83
|
+
]
|