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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]