erdilrf-lrf 0.2.0__py3-none-any.whl

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,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
+ ]
erdilrf_lrf/cli.py ADDED
@@ -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())
erdilrf_lrf/device.py ADDED
@@ -0,0 +1,274 @@
1
+ """
2
+ erdilrf_lrf.device — high-level host access to an ERDI 905 nm rangefinder module.
3
+
4
+ The module deliberately depends on nothing but the standard library. Serial I/O is injected as a
5
+ small duck-typed transport, which means:
6
+
7
+ * the whole device layer is testable without hardware (see `tests/test_device.py`);
8
+ * you can bring your own link — pyserial, a socket, an MCU bridge, a recorded capture.
9
+
10
+ `SerialTransport` is provided for the common case and imports pyserial lazily, so importing this
11
+ module never requires it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from collections import deque
18
+ from typing import Iterable, Protocol, runtime_checkable
19
+
20
+ from . import frames as F
21
+
22
+ __all__ = ["Transport", "SerialTransport", "Rangefinder", "DeviceTimeout"]
23
+
24
+
25
+ class DeviceTimeout(TimeoutError):
26
+ """No matching frame arrived within the timeout."""
27
+
28
+
29
+ @runtime_checkable
30
+ class Transport(Protocol):
31
+ """Minimal link the driver needs. Implement these two methods and nothing else."""
32
+
33
+ def write(self, data: bytes) -> int:
34
+ """Send bytes to the module. Return the number accepted."""
35
+
36
+ def read(self, size: int) -> bytes:
37
+ """Return up to `size` bytes, blocking only as long as your own timeout allows."""
38
+
39
+
40
+ class SerialTransport:
41
+ """
42
+ pyserial-backed transport.
43
+
44
+ Defaults are 115200 8N1 — the manual documents 115200 and 8 data bits but is silent on parity
45
+ and stop bits, so 8N1 is an assumption recorded in PROTOCOL.md, not a documented fact. If a
46
+ module stays silent, try `parity="E"` or `parity="O"` before assuming a wiring fault.
47
+ """
48
+
49
+ def __init__(self, port: str, baudrate: int = 115200, *, parity: str = "N",
50
+ stopbits: float = 1, read_timeout: float = 0.2) -> None:
51
+ try:
52
+ import serial # type: ignore
53
+ except ImportError as exc: # pragma: no cover - depends on environment
54
+ raise ImportError(
55
+ "pyserial is required to open a serial port. "
56
+ "Install it with: pip install pyserial"
57
+ ) from exc
58
+ self._serial_mod = serial
59
+ self.port = port
60
+ self.baudrate = baudrate
61
+ self._ser = serial.Serial(
62
+ port=port, baudrate=baudrate, bytesize=serial.EIGHTBITS,
63
+ parity={"N": serial.PARITY_NONE, "E": serial.PARITY_EVEN,
64
+ "O": serial.PARITY_ODD}[parity.upper()],
65
+ stopbits={1: serial.STOPBITS_ONE, 1.5: serial.STOPBITS_ONE_POINT_FIVE,
66
+ 2: serial.STOPBITS_TWO}[float(stopbits)],
67
+ timeout=read_timeout,
68
+ )
69
+
70
+ def write(self, data: bytes) -> int:
71
+ return self._ser.write(data)
72
+
73
+ def read(self, size: int) -> bytes:
74
+ return self._ser.read(size)
75
+
76
+ def close(self) -> None:
77
+ try:
78
+ self._ser.close()
79
+ except Exception: # noqa: BLE001 - closing must never mask the real error
80
+ pass
81
+
82
+ def __enter__(self) -> "SerialTransport":
83
+ return self
84
+
85
+ def __exit__(self, *exc) -> None:
86
+ self.close()
87
+
88
+ @staticmethod
89
+ def list_ports() -> list[str]:
90
+ try:
91
+ from serial.tools import list_ports # type: ignore
92
+ except ImportError:
93
+ return []
94
+ return [p.device for p in list_ports.comports()]
95
+
96
+
97
+ class Rangefinder:
98
+ """
99
+ Blocking, single-threaded host driver.
100
+
101
+ Every call reads from the transport until it sees the frame it is waiting for or the deadline
102
+ passes. Unrelated and self-test frames are recorded in `self.unsolicited` rather than
103
+ discarded, because on a real module the power-on self-test arrives when it feels like it and
104
+ swallowing it hides real faults.
105
+ """
106
+
107
+ def __init__(self, transport: Transport, *, timeout: float = 1.0, chunk: int = 64) -> None:
108
+ self.transport = transport
109
+ self.timeout = timeout
110
+ self.chunk = chunk
111
+ self.reader = F.FrameReader()
112
+ self.unsolicited: list[F.Frame] = []
113
+ self.self_test: F.Frame | None = None
114
+ # Frames already parsed but not yet consumed. This queue is why the driver cannot lose a
115
+ # response: an earlier draft flushed by *consuming* a frame and discarding the rest of the
116
+ # parsed batch, which silently ate the answer to the very command it had just sent.
117
+ self._pending: deque[F.Frame] = deque()
118
+
119
+ # ---------------------------------------------------------------- plumbing
120
+
121
+ def _fill(self, deadline: float) -> None:
122
+ """Block until at least one frame is pending, or the deadline passes."""
123
+ while not self._pending:
124
+ chunk = self.transport.read(self.chunk)
125
+ if chunk:
126
+ self._pending.extend(self.reader.feed(chunk))
127
+ if self._pending:
128
+ return
129
+ if time.monotonic() >= deadline:
130
+ return
131
+
132
+ def _flush(self) -> list[F.Frame]:
133
+ """
134
+ Drop everything already sitting in the link *before* a command is sent.
135
+
136
+ On a real port a previous command's answer, or a burst of unrelated frames, can still be
137
+ buffered. Accepting one of those as the answer to the next command yields a wrong distance
138
+ with no error — the worst outcome this driver can produce. So they are discarded, and
139
+ recorded in the returned list for anyone who wants to see them.
140
+ """
141
+ dropped: list[F.Frame] = list(self._pending)
142
+ self._pending.clear()
143
+ while True:
144
+ chunk = self.transport.read(self.chunk)
145
+ if not chunk:
146
+ break
147
+ dropped.extend(self.reader.feed(chunk))
148
+ return dropped
149
+
150
+ def _pump(self, deadline: float, want: Iterable[int] | None) -> F.Frame | None:
151
+ wanted = set(want) if want is not None else None
152
+ while True:
153
+ self._fill(deadline)
154
+ if not self._pending:
155
+ return None
156
+ f = self._pending.popleft()
157
+ if f.is_self_test:
158
+ self.self_test = f
159
+ if wanted is None or F.FUNC_SELF_TEST in wanted:
160
+ return f
161
+ self.unsolicited.append(f)
162
+ continue
163
+ if wanted is None or f.func in wanted:
164
+ return f
165
+ # a frame for some other command: keep it visible, do not return it
166
+ self.unsolicited.append(f)
167
+ if time.monotonic() >= deadline:
168
+ return None
169
+
170
+ def _exchange(self, command: bytes, expect: Iterable[int], timeout: float | None = None) -> F.Frame:
171
+ budget = self.timeout if timeout is None else timeout
172
+ self._flush()
173
+ self.transport.write(command)
174
+ frame = self._pump(time.monotonic() + budget, expect)
175
+ if frame is None:
176
+ raise DeviceTimeout(
177
+ f"no response to {command.hex(' ').upper()} within {budget}s "
178
+ f"(wanted func {[hex(x) for x in expect]})")
179
+ return frame
180
+
181
+ # ---------------------------------------------------------------- measurements
182
+
183
+ def single_shot(self) -> float | None:
184
+ """
185
+ Trigger one measurement and return metres.
186
+
187
+ Returns None when the module answers but reports a failed measurement (STA != 1), which is
188
+ a normal outcome against a poor target. Raises DeviceTimeout when it does not answer at all.
189
+ """
190
+ frame = self._exchange(F.CMD_SINGLE_SHOT, (F.FUNC_SINGLE_SHOT,))
191
+ return F.decode_distance_or_none(frame)
192
+
193
+ def start_continuous(self) -> None:
194
+ self.transport.write(F.CMD_CONTINUOUS)
195
+
196
+ def stop_continuous(self, *, timeout: float | None = None) -> bool:
197
+ frame = self._exchange(F.CMD_STOP, (F.FUNC_STOP,), timeout)
198
+ return frame.sta == F.STA_SUCCESS
199
+
200
+ def stream(self, count: int | None = None, *, settle: float = 0.05) -> Iterable[float | None]:
201
+ """
202
+ Start continuous ranging and yield distances in metres.
203
+
204
+ Yields `count` samples (or forever when count is None). Stops continuous mode on exit.
205
+ Frames arriving before the first valid range are yielded as None rather than dropped, so a
206
+ caller can see how long the module took to lock on.
207
+ """
208
+ self.start_continuous()
209
+ time.sleep(settle)
210
+ produced = 0
211
+ try:
212
+ while count is None or produced < count:
213
+ frame = self._pump(time.monotonic() + self.timeout, (F.FUNC_CONTINUOUS,))
214
+ if frame is None:
215
+ raise DeviceTimeout("continuous ranging went quiet")
216
+ yield F.decode_distance_or_none(frame)
217
+ produced += 1
218
+ finally:
219
+ try:
220
+ self.stop_continuous(timeout=0.5)
221
+ except (DeviceTimeout, F.ProtocolError):
222
+ pass
223
+
224
+ def angle(self) -> float | None:
225
+ """
226
+ Angle in degrees. Only available on modules fitted with an angle sensor — the manual says so
227
+ explicitly. On a rangefinder-only module expect a timeout, not a wrong number.
228
+ """
229
+ frame = self._exchange(F.CMD_ANGLE, (F.FUNC_ANGLE,))
230
+ if frame.sta != F.STA_SUCCESS:
231
+ return None
232
+ return F.decode_angle(frame)
233
+
234
+ def read_self_test(self, timeout: float | None = None) -> F.Frame | None:
235
+ """
236
+ Wait for the unsolicited power-on self-test frame.
237
+
238
+ Best called immediately after opening the port, *before* anything else: the frame is emitted
239
+ once at power-on, so if you have already sent other commands you have probably missed it and
240
+ will get None. That is not a fault.
241
+ """
242
+ return self._pump(time.monotonic() + (self.timeout if timeout is None else timeout),
243
+ (F.FUNC_SELF_TEST,))
244
+
245
+ # ---------------------------------------------------------------- configuration
246
+
247
+ def set_baud(self, baud: int, *, timeout: float | None = None) -> bool:
248
+ """
249
+ Request a new baud rate. Returns True when the module accepted it.
250
+
251
+ The change takes effect only after the module is restarted. This method does **not**
252
+ reconfigure the transport — do that yourself after the restart, otherwise the accepted
253
+ setting and the open port will disagree.
254
+ """
255
+ frame = self._exchange(F.set_baud_command(baud), (F.BAUD_CODES[baud],), timeout)
256
+ return frame.sta == F.STA_SUCCESS
257
+
258
+ def ld_constant_on(self, enable: bool) -> None:
259
+ """
260
+ Turn the laser diode constant-on mode on or off.
261
+
262
+ Fire-and-forget: the manual's response row for this command is incomplete (it prints only
263
+ `55`), so this library does not invent a frame layout to validate against. See
264
+ PROTOCOL.md section 3.6. The manufacturer warns against leaving it enabled for long periods.
265
+ """
266
+ self.transport.write(F.CMD_LD_ON if enable else F.CMD_LD_OFF)
267
+
268
+ def __enter__(self) -> "Rangefinder":
269
+ return self
270
+
271
+ def __exit__(self, *exc) -> None:
272
+ close = getattr(self.transport, "close", None)
273
+ if callable(close):
274
+ close()
erdilrf_lrf/frames.py ADDED
@@ -0,0 +1,340 @@
1
+ """
2
+ erdilrf_lrf.frames — ERDI 905 nm laser rangefinder UART framing.
3
+
4
+ Pure standard library. No serial dependency, so this module is testable anywhere.
5
+
6
+ Frame layout (8 bytes, fixed):
7
+
8
+ offset 0 1 2 3 4 5 6 7
9
+ 0x55 0xAA FUNC D1 D2 D3 D4 SUM
10
+
11
+ Checksum — and this is the part people get wrong:
12
+
13
+ host -> module SUM = (FUNC + D1 + D2 + D3 + D4) & 0xFF
14
+ module -> host SUM = (0x55 + 0xAA + FUNC + D1 + D2 + D3 + D4) & 0xFF
15
+
16
+ The two directions use different ranges. The manufacturer's manual states this in its own
17
+ notes; this module encodes it as two separate functions so it cannot be confused.
18
+
19
+ Source: LR1000E2 User Manual v1.2 (2025.10, ERDI TECH LTD) section 8.
20
+ See protocol/PROTOCOL.md for the full reference and protocol/verification.md for the
21
+ checksum arithmetic re-derived independently of the manual's printed values.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import NamedTuple
28
+
29
+ HEADER_H = 0x55
30
+ HEADER_L = 0xAA
31
+ FRAME_LEN = 8
32
+ PAD = 0xFF
33
+
34
+ # ---------------------------------------------------------------- function codes
35
+
36
+ FUNC_SELF_TEST = 0x80 # module -> host, unsolicited, at power-on
37
+ FUNC_LD_CONSTANT_ON = 0x86 # host -> module, D4 selects enable/disable
38
+ FUNC_SINGLE_SHOT = 0x88
39
+ FUNC_CONTINUOUS = 0x89
40
+ FUNC_ANGLE = 0x8A
41
+ FUNC_STOP = 0x8E
42
+ # NOTE: set-baud has no separate function code. The manual's `TYPE` selector occupies the
43
+ # FUNCTION position itself (frame is `55 AA TYPE FF FF FF FF SUM`), so its "func" byte is
44
+ # one of BAUD_BY_CODE's keys. Do not invent a distinct constant for it.
45
+
46
+ #: Baud-rate selector codes, from the manual's TYPE table.
47
+ BAUD_CODES: dict[int, int] = {
48
+ 9600: 0x01,
49
+ 14400: 0x02,
50
+ 19200: 0x03,
51
+ 38400: 0x04,
52
+ 56000: 0x05,
53
+ 57600: 0x06,
54
+ 115200: 0x07,
55
+ 128000: 0x08,
56
+ 230400: 0x09,
57
+ }
58
+ BAUD_BY_CODE: dict[int, int] = {v: k for k, v in BAUD_CODES.items()}
59
+
60
+ #: LD constant-on selector (goes in D4).
61
+ LD_ON = 0x01
62
+ LD_OFF = 0x00
63
+
64
+ #: STA values are per-command, not global. See PROTOCOL.md section 6.
65
+ STA_SUCCESS = 0x01
66
+ STA_FAILURE = 0x00
67
+
68
+
69
+ class ProtocolError(ValueError):
70
+ """Raised when bytes cannot be interpreted as a valid ERDI frame."""
71
+
72
+
73
+ # ---------------------------------------------------------------- checksums
74
+
75
+
76
+ def checksum_send(func: int, d1: int = PAD, d2: int = PAD, d3: int = PAD, d4: int = PAD) -> int:
77
+ """Checksum for a host -> module frame: low byte of FUNC + D1 + D2 + D3 + D4."""
78
+ return (func + d1 + d2 + d3 + d4) & 0xFF
79
+
80
+
81
+ def checksum_response(frame: bytes) -> int:
82
+ """
83
+ Checksum for a module -> host frame: low byte of
84
+ HEADER_H + HEADER_L + FUNC + D1 + D2 + D3 + D4.
85
+
86
+ Accepts the first 7 bytes, or a full 8-byte frame (byte 7 is ignored).
87
+ """
88
+ if len(frame) < 7:
89
+ raise ProtocolError(f"need at least 7 bytes to checksum a response, got {len(frame)}")
90
+ return sum(frame[:7]) & 0xFF
91
+
92
+
93
+ # ---------------------------------------------------------------- building
94
+
95
+
96
+ def build_command(func: int, d1: int = PAD, d2: int = PAD, d3: int = PAD, d4: int = PAD) -> bytes:
97
+ """Build an 8-byte host -> module frame with the correct send-side checksum."""
98
+ for name, val in (("func", func), ("d1", d1), ("d2", d2), ("d3", d3), ("d4", d4)):
99
+ if not 0 <= val <= 0xFF:
100
+ raise ProtocolError(f"{name}=0x{val:X} does not fit in one byte")
101
+ return bytes([HEADER_H, HEADER_L, func, d1, d2, d3, d4,
102
+ checksum_send(func, d1, d2, d3, d4)])
103
+
104
+
105
+ # ---------------------------------------------------------------- the command set
106
+
107
+ CMD_SINGLE_SHOT = build_command(FUNC_SINGLE_SHOT) # 55 AA 88 FF FF FF FF 84
108
+ CMD_CONTINUOUS = build_command(FUNC_CONTINUOUS) # 55 AA 89 FF FF FF FF 85
109
+ CMD_STOP = build_command(FUNC_STOP) # 55 AA 8E FF FF FF FF 8A
110
+ CMD_ANGLE = build_command(FUNC_ANGLE) # 55 AA 8A FF FF FF FF 86
111
+ CMD_LD_ON = build_command(FUNC_LD_CONSTANT_ON, PAD, PAD, PAD, LD_ON) # ... FF FF FF 01 84
112
+ CMD_LD_OFF = build_command(FUNC_LD_CONSTANT_ON, PAD, PAD, PAD, LD_OFF) # ... FF FF FF 00 83
113
+
114
+
115
+ def set_baud_command(baud: int) -> bytes:
116
+ """
117
+ Build the set-baud frame. The selector is carried in D1 (the manual calls this field TYPE).
118
+
119
+ Reminder: the new rate applies only after the module is restarted.
120
+ """
121
+ if baud not in BAUD_CODES:
122
+ raise ProtocolError(
123
+ f"unsupported baud {baud}; supported: {sorted(BAUD_CODES)}")
124
+ return build_command(BAUD_CODES[baud])
125
+
126
+
127
+ # ---------------------------------------------------------------- parsing
128
+
129
+
130
+ class Frame(NamedTuple):
131
+ func: int
132
+ d1: int
133
+ d2: int
134
+ d3: int
135
+ d4: int
136
+ checksum: int
137
+ raw: bytes
138
+ #: which checksum rule this frame is subject to: "response" (module -> host) or
139
+ #: "command" (host -> module). The two rules genuinely differ, so a frame that does not
140
+ #: record its direction cannot be validated. This field exists because omitting it was a
141
+ #: real bug: FrameReader validated TX frames with the RX rule and rejected every one.
142
+ direction: str = "response"
143
+
144
+ @property
145
+ def expected_checksum(self) -> int:
146
+ if self.direction == "command":
147
+ return checksum_send(self.func, self.d1, self.d2, self.d3, self.d4)
148
+ return checksum_response(self.raw)
149
+
150
+ @property
151
+ def checksum_ok(self) -> bool:
152
+ return self.checksum == self.expected_checksum
153
+
154
+ @property
155
+ def sta(self) -> int:
156
+ return self.d1
157
+
158
+ @property
159
+ def is_self_test(self) -> bool:
160
+ return self.func == FUNC_SELF_TEST
161
+
162
+ @property
163
+ def error_code(self) -> int | None:
164
+ """Self-test error code, present in D4 of a `55 AA 80` frame."""
165
+ return self.d4 if self.is_self_test else None
166
+
167
+
168
+ DIRECTIONS = ("response", "command")
169
+
170
+
171
+ def parse_frame(data: bytes, *, direction: str = "response", verify_checksum: bool = True) -> Frame:
172
+ """
173
+ Parse exactly 8 bytes into a Frame.
174
+
175
+ `direction` selects the checksum rule: "response" for module -> host frames (the default,
176
+ matching what a host reads from the port) or "command" for host -> module frames.
177
+
178
+ Raises ProtocolError on wrong length, wrong header, or (unless disabled) a bad checksum.
179
+ """
180
+ if direction not in DIRECTIONS:
181
+ raise ProtocolError(f"direction must be one of {DIRECTIONS}, got {direction!r}")
182
+ if len(data) != FRAME_LEN:
183
+ raise ProtocolError(f"expected {FRAME_LEN} bytes, got {len(data)}: {data.hex(' ')}")
184
+ if data[0] != HEADER_H or data[1] != HEADER_L:
185
+ raise ProtocolError(f"bad header {data[0]:02X} {data[1]:02X}, expected 55 AA")
186
+ f = Frame(data[2], data[3], data[4], data[5], data[6], data[7], bytes(data), direction)
187
+ if verify_checksum and not f.checksum_ok:
188
+ raise ProtocolError(
189
+ f"checksum mismatch: frame says 0x{f.checksum:02X}, "
190
+ f"{'SUM[3:7]' if direction == 'command' else 'SUM[1:7]'} sums to "
191
+ f"0x{f.expected_checksum:02X}")
192
+ return f
193
+
194
+
195
+ class FrameReader:
196
+ """
197
+ Byte-stream reassembler.
198
+
199
+ Feed it arbitrary chunks from a serial port; it yields complete, checksum-valid Frame
200
+ objects. Resynchronises on the 55 AA header, so a truncated or corrupted frame costs at
201
+ most one frame and never poisons the stream.
202
+
203
+ Use the default `direction="response"` for bytes read from the module. Switch to
204
+ "command" only when replaying frames you transmitted.
205
+ """
206
+
207
+ def __init__(self, *, direction: str = "response", verify_checksum: bool = True,
208
+ collect_errors: bool = True) -> None:
209
+ if direction not in DIRECTIONS:
210
+ raise ProtocolError(f"direction must be one of {DIRECTIONS}, got {direction!r}")
211
+ self._buf = bytearray()
212
+ self._direction = direction
213
+ self._verify = verify_checksum
214
+ self._collect_errors = collect_errors
215
+ self.errors: list[str] = []
216
+
217
+ def feed(self, chunk: bytes) -> list[Frame]:
218
+ if chunk:
219
+ self._buf.extend(chunk)
220
+ return list(self._drain())
221
+
222
+ def _drain(self):
223
+ while True:
224
+ # drop leading bytes until the header aligns
225
+ i = self._buf.find(bytes([HEADER_H, HEADER_L]))
226
+ if i < 0:
227
+ # keep only a possible half-header at the tail
228
+ if self._buf[-1:] == bytes([HEADER_H]):
229
+ del self._buf[:-1]
230
+ else:
231
+ self._buf.clear()
232
+ return
233
+ if i:
234
+ if self._collect_errors:
235
+ self.errors.append(f"discarded {i} non-header byte(s): {bytes(self._buf[:i]).hex(' ')}")
236
+ del self._buf[:i]
237
+ if len(self._buf) < FRAME_LEN:
238
+ return
239
+ candidate = bytes(self._buf[:FRAME_LEN])
240
+ try:
241
+ yield parse_frame(candidate, direction=self._direction,
242
+ verify_checksum=self._verify)
243
+ except ProtocolError as exc:
244
+ if self._collect_errors:
245
+ self.errors.append(str(exc))
246
+ # a bad frame can still hide the real header one byte in; step forward by 1
247
+ del self._buf[:1]
248
+ continue
249
+ del self._buf[:FRAME_LEN]
250
+
251
+
252
+ # ---------------------------------------------------------------- decoding
253
+
254
+
255
+ def decode_distance(frame: Frame) -> float:
256
+ """
257
+ Measured distance in **metres**.
258
+
259
+ The manual states: reported value = measured value x 10, so the 16-bit register holds
260
+ tenths of a metre. DIS_H is D3, DIS_L is D4.
261
+ """
262
+ if frame.d2 != PAD:
263
+ raise ProtocolError(
264
+ f"expected 0xFF in D2 of a ranging response, got 0x{frame.d2:02X}")
265
+ if frame.sta != STA_SUCCESS:
266
+ raise ProtocolError(f"measurement failed (STA=0x{frame.sta:02X})")
267
+ raw = (frame.d3 << 8) | frame.d4
268
+ return raw / 10.0
269
+
270
+
271
+ def decode_angle(frame: Frame) -> float:
272
+ """Measured angle in degrees, same x10 scaling. Only on modules with an angle sensor."""
273
+ if frame.sta != STA_SUCCESS:
274
+ raise ProtocolError(f"angle measurement failed (STA=0x{frame.sta:02X})")
275
+ raw = (frame.d3 << 8) | frame.d4
276
+ return raw / 10.0
277
+
278
+
279
+ def decode_distance_or_none(frame: Frame) -> float | None:
280
+ """Convenience for polling loops: metres, or None when STA reports a failed measurement."""
281
+ try:
282
+ return decode_distance(frame)
283
+ except ProtocolError:
284
+ return None
285
+
286
+
287
+ # ---------------------------------------------------------------- human-readable helpers
288
+
289
+
290
+ @dataclass(frozen=True)
291
+ class FrameSummary:
292
+ func: int
293
+ name: str
294
+ sta: int
295
+ sta_meaning: str
296
+ detail: str
297
+
298
+
299
+ _NAMES = {
300
+ FUNC_SELF_TEST: "power-on self-test",
301
+ FUNC_LD_CONSTANT_ON: "LD constant-on mode",
302
+ FUNC_SINGLE_SHOT: "single-shot ranging",
303
+ FUNC_CONTINUOUS: "continuous ranging",
304
+ FUNC_ANGLE: "angle measurement",
305
+ FUNC_STOP: "stop ranging",
306
+ }
307
+
308
+
309
+ def summarise(frame: Frame) -> FrameSummary:
310
+ name = _NAMES.get(frame.func, f"unknown function 0x{frame.func:02X}")
311
+ sta, meaning, detail = frame.sta, "", ""
312
+
313
+ if frame.direction == "command":
314
+ # A transmitted frame's D1 is a parameter, not a status byte. Reporting "measurement
315
+ # failed" here would invent a fault out of a field that has no such meaning.
316
+ return FrameSummary(frame.func, name, sta,
317
+ "n/a (transmitted frame — D1 is a parameter, not STA)", "")
318
+
319
+ if frame.func in BAUD_BY_CODE:
320
+ name = f"set baud to {BAUD_BY_CODE[frame.func]} bps"
321
+ meaning = "setting successful" if sta == STA_SUCCESS else "setting failed"
322
+ detail = "takes effect after restart"
323
+ elif frame.func in (FUNC_SINGLE_SHOT, FUNC_CONTINUOUS):
324
+ meaning = "measurement successful" if sta == STA_SUCCESS else "measurement failed"
325
+ if sta == STA_SUCCESS:
326
+ detail = f"{(frame.d3 << 8 | frame.d4) / 10.0:.1f} m"
327
+ elif frame.func == FUNC_STOP:
328
+ meaning = "stopped" if sta == STA_SUCCESS else "failed to stop"
329
+ elif frame.func == FUNC_ANGLE:
330
+ meaning = "measurement successful" if sta == STA_SUCCESS else "measurement failed"
331
+ if sta == STA_SUCCESS:
332
+ detail = f"{(frame.d3 << 8 | frame.d4) / 10.0:.1f} deg"
333
+ elif frame.func == FUNC_SELF_TEST:
334
+ meaning = "init succeeded" if sta == STA_SUCCESS else "init FAILED"
335
+ if sta != STA_SUCCESS:
336
+ detail = f"ErrCode=0x{frame.d4:02X}"
337
+ else:
338
+ meaning = "n/a"
339
+
340
+ return FrameSummary(frame.func, name, sta, meaning, detail)
@@ -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,10 @@
1
+ erdilrf_lrf/__init__.py,sha256=Z7lbPjr9ZeVRNwsCetW1_aflgQnzLzQxZqQa0-01HMA,1402
2
+ erdilrf_lrf/cli.py,sha256=sryxrtVVVCIVgAGvhpGXA_n2pUK3Ms6fqrZiPIZxHEI,8249
3
+ erdilrf_lrf/device.py,sha256=jjoFy0EiNfH-opCgWNtnhRLLlgV3jI7gYHWc3J7YHHw,11037
4
+ erdilrf_lrf/frames.py,sha256=PYzSbF06rQJnCT-LpMTvghc7O7YDxV40LtcGrJqWFH8,12513
5
+ erdilrf_lrf-0.2.0.dist-info/licenses/LICENSE,sha256=-FIBvvm0m06snuARfXI8GKBfblyjFcpnf9Uw3fkg16A,1064
6
+ erdilrf_lrf-0.2.0.dist-info/METADATA,sha256=yU25yzTHsgVtW3E-gpFMjutINJFtjO4gkzBDdESygGA,6587
7
+ erdilrf_lrf-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ erdilrf_lrf-0.2.0.dist-info/entry_points.txt,sha256=BVUC2zEsTORISCInQ9hIvrdzHNxmA1aN1EaIu6ilWcA,49
9
+ erdilrf_lrf-0.2.0.dist-info/top_level.txt,sha256=pjWQrigX0HrBNc0wc6JMekl7x3k6nVi2Ksjul-hW9hg,12
10
+ erdilrf_lrf-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ erdilrf = erdilrf_lrf.cli:main
@@ -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 @@
1
+ erdilrf_lrf