erdilrf commited on
Commit
1cf5f4b
·
verified ·
1 Parent(s): e1710e5

Add static WebSerial decoder

Browse files
Files changed (3) hide show
  1. README.md +17 -10
  2. decoder.js +104 -0
  3. index.html +90 -19
README.md CHANGED
@@ -1,10 +1,17 @@
1
- ---
2
- title: Lrf Uart Protocol Decoder
3
- emoji: 💻
4
- colorFrom: yellow
5
- colorTo: gray
6
- sdk: static
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Laser rangefinder UART protocol decoder
3
+ emoji: "📏"
4
+ colorFrom: blue
5
+ colorTo: gray
6
+ sdk: static
7
+ pinned: false
8
+ license: mit
9
+ short_description: Decode laser rangefinder UART frames in the browser
10
+ tags:
11
+ - laser-rangefinder
12
+ - lidar
13
+ - uart
14
+ - webserial
15
+ ---
16
+
17
+ Decode laser rangefinder module UART frames in the browser: paste hex or connect a module over WebSerial (Chrome / Edge). Profiles for ERDI 905 nm modules and Benewake TFmini / TF-Luna style frames. Source: [ErdiTech/lrf-uart-protocol-decoder](https://github.com/ErdiTech/lrf-uart-protocol-decoder). By ERDI TECH LTD, [erdilrf.com](https://erdilrf.com/models/).
decoder.js ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lrf-uart-protocol-decoder: stream decoder for laser rangefinder UART frames.
2
+ // Works in browsers (WebSerial page) and Node (tests). No dependencies.
3
+
4
+ const sum8 = (bytes) => bytes.reduce((a, b) => (a + b) & 0xff, 0);
5
+ const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(" ").toUpperCase();
6
+
7
+ // Each profile: find a frame at buf[i], return {len, frame} | null (need more) | false (no frame here).
8
+ export const PROFILES = {
9
+ // ERDI family B: 5C <lo> <hi> <chk>, chk = ~(lo+hi). LRF1500H: 5C lo mid hi chk.
10
+ "erdi-b-4-cm": { label: "ERDI H series (5C, 4-byte, centimetres)", baud: 460800, len: 4, unit: 0.01, oor: 65535, kind: "erdiB" },
11
+ "erdi-b-4-mm": { label: "ERDI VB series (5C, 4-byte, millimetres)", baud: 460800, len: 4, unit: 0.001, oor: null, kind: "erdiB" },
12
+ "erdi-b-5-cm": { label: "ERDI LRF1500H (5C, 5-byte, centimetres)", baud: 460800, len: 5, unit: 0.01, oor: 16777215, kind: "erdiB" },
13
+ // ERDI family A: 55 AA fn STA FF DH DL SUM, reply sum covers the header; value x10 metres.
14
+ "erdi-a": { label: "ERDI LR / LRF A / SPD (55 AA reply, metres x10)", baud: 115200, len: 8, unit: 0.1, kind: "erdiA" },
15
+ // Benewake TF-series 9-byte: 59 59 Dist_L Dist_H Str_L Str_H Temp_L Temp_H chk(sum of first 8)
16
+ "benewake-tf-9": { label: "Benewake TFmini / TF-Luna style (59 59, 9-byte, cm)", baud: 115200, len: 9, unit: 0.01, kind: "tf9" },
17
+ };
18
+
19
+ function inv8(bytes) {
20
+ return ~sum8(bytes) & 0xff;
21
+ }
22
+
23
+ function tryFrame(profile, buf, i) {
24
+ const p = PROFILES[profile];
25
+ if (buf.length - i < 1) return null;
26
+ if (p.kind === "erdiB") {
27
+ if (buf[i] !== 0x5c) return false;
28
+ if (buf.length - i < p.len) return null;
29
+ const f = buf.slice(i, i + p.len);
30
+ if (inv8(f.slice(1, -1)) !== f[f.length - 1]) return false;
31
+ let raw = 0;
32
+ for (let k = f.length - 2; k >= 1; k--) raw = raw * 256 + f[k];
33
+ const oor = p.oor !== null && raw >= p.oor;
34
+ return { len: p.len, frame: f, info: { raw, distance_m: oor ? null : raw * p.unit, out_of_range: oor } };
35
+ }
36
+ if (p.kind === "erdiA") {
37
+ if (buf[i] !== 0x55) return false;
38
+ if (buf.length - i < 2) return null;
39
+ if (buf[i + 1] !== 0xaa) return false;
40
+ if (buf.length - i < 8) return null;
41
+ const f = buf.slice(i, i + 8);
42
+ if (sum8(f.slice(0, 7)) !== f[7]) return false;
43
+ const raw = (f[5] << 8) | f[6];
44
+ const ok = f[3] === 1;
45
+ return { len: 8, frame: f, info: { function: f[2], status: f[3], raw, distance_m: ok ? raw * p.unit : null, out_of_range: !ok } };
46
+ }
47
+ if (p.kind === "tf9") {
48
+ if (buf[i] !== 0x59) return false;
49
+ if (buf.length - i < 2) return null;
50
+ if (buf[i + 1] !== 0x59) return false;
51
+ if (buf.length - i < 9) return null;
52
+ const f = buf.slice(i, i + 9);
53
+ if (sum8(f.slice(0, 8)) !== f[8]) return false;
54
+ const raw = f[2] | (f[3] << 8);
55
+ return { len: 9, frame: f, info: { raw, distance_m: raw * p.unit, strength: f[4] | (f[5] << 8), temp_raw: f[6] | (f[7] << 8), out_of_range: false } };
56
+ }
57
+ return false;
58
+ }
59
+
60
+ export class Decoder {
61
+ constructor(profile) {
62
+ if (!PROFILES[profile]) throw new Error("unknown profile " + profile);
63
+ this.profile = profile;
64
+ this.buf = [];
65
+ this.skipped = 0;
66
+ this.frames = 0;
67
+ }
68
+
69
+ // Feed bytes (Uint8Array | number[]); returns the list of complete valid frames found.
70
+ push(bytes) {
71
+ for (const b of bytes) this.buf.push(b);
72
+ const out = [];
73
+ let i = 0;
74
+ while (i < this.buf.length) {
75
+ const r = tryFrame(this.profile, this.buf, i);
76
+ if (r === null) break; // partial frame at tail: wait for more bytes
77
+ if (r === false) {
78
+ i += 1;
79
+ this.skipped += 1;
80
+ continue;
81
+ }
82
+ this.frames += 1;
83
+ out.push({ hex: hex(r.frame), ...r.info });
84
+ i += r.len;
85
+ }
86
+ this.buf = this.buf.slice(i);
87
+ if (this.buf.length > 4096) this.buf = this.buf.slice(-64);
88
+ return out;
89
+ }
90
+ }
91
+
92
+ export function parseHex(text) {
93
+ const clean = text.replace(/0x/gi, " ").replace(/[^0-9a-fA-F]+/g, " ").trim();
94
+ if (!clean) return [];
95
+ const parts = clean.split(" ");
96
+ const bytes = [];
97
+ for (const p of parts) {
98
+ if (p.length % 2) throw new Error("odd-length hex token: " + p);
99
+ for (let k = 0; k < p.length; k += 2) bytes.push(parseInt(p.slice(k, k + 2), 16));
100
+ }
101
+ return bytes;
102
+ }
103
+
104
+ export { hex };
index.html CHANGED
@@ -1,19 +1,90 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Laser rangefinder UART frame decoder (WebSerial) - ERDI LRF</title>
7
+ <meta name="description" content="Decode laser rangefinder UART frames in the browser: connect a module over WebSerial or paste hex. Supports ERDI 5C and 55 AA frames and Benewake TF-style 9-byte frames.">
8
+ <style>
9
+ :root { --bg:#fff; --fg:#14181f; --muted:#5b6472; --line:#d9dee6; --accent:#0b5fff; }
10
+ @media (prefers-color-scheme: dark) { :root { --bg:#0f1318; --fg:#e8ecf2; --muted:#9aa4b2; --line:#2a323d; --accent:#6ea2ff; } }
11
+ body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,sans-serif; }
12
+ main { max-width:860px; margin:0 auto; padding:24px 16px 64px; }
13
+ h1 { font-size:1.5rem; margin:0 0 4px; } p.lead { color:var(--muted); margin:0 0 20px; }
14
+ label { display:block; font-weight:600; margin:12px 0 4px; }
15
+ select, textarea, button, input { font:inherit; padding:8px 10px; border:1px solid var(--line); border-radius:6px; background:transparent; color:inherit; }
16
+ textarea { width:100%; box-sizing:border-box; min-height:84px; font-family:ui-monospace,monospace; }
17
+ button { cursor:pointer; background:var(--accent); color:#fff; border-color:var(--accent); } button.secondary { background:transparent; color:var(--fg); border-color:var(--line); }
18
+ .row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
19
+ #big { font-size:2.4rem; font-variant-numeric:tabular-nums; margin:16px 0 4px; }
20
+ table { border-collapse:collapse; width:100%; font-size:.9rem; margin-top:12px; } th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; }
21
+ code { font-family:ui-monospace,monospace; } .muted { color:var(--muted); font-size:.9rem; }
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <main>
26
+ <h1>Laser rangefinder UART frame decoder</h1>
27
+ <p class="lead">Connect a rangefinder module with WebSerial (Chrome / Edge) or paste captured hex. Runs entirely in your browser.</p>
28
+
29
+ <label for="profile">Protocol profile</label>
30
+ <select id="profile"></select>
31
+ <div class="muted" id="baudnote"></div>
32
+
33
+ <div class="row" style="margin-top:12px">
34
+ <button id="connect">Connect serial port</button>
35
+ <button id="disconnect" class="secondary" disabled>Disconnect</button>
36
+ <span class="muted" id="status">WebSerial: checking...</span>
37
+ </div>
38
+
39
+ <label for="hex">Or paste hex bytes</label>
40
+ <textarea id="hex" placeholder="5C 02 11 EC 5C 03 11 EB"></textarea>
41
+ <div class="row" style="margin-top:8px"><button id="decode">Decode pasted hex</button><button id="clear" class="secondary">Clear table</button></div>
42
+
43
+ <div id="big">-</div>
44
+ <div class="muted" id="stats"></div>
45
+ <table><thead><tr><th>#</th><th>Frame</th><th>Raw</th><th>Distance (m)</th><th>Note</th></tr></thead><tbody id="rows"></tbody></table>
46
+
47
+ <p class="muted">The same <code>5C 02 11 EC</code> frame is 43.54 m on an ERDI H-series module and 4.354 m on a VB-series module: pick the profile that matches your model.
48
+ Model pages and datasheets: <a href="https://erdilrf.com/models/?utm_source=github&utm_medium=pages&utm_campaign=lrf-uart-protocol-decoder">erdilrf.com/models</a>.
49
+ Source: <a href="https://github.com/ErdiTech/lrf-uart-protocol-decoder">github.com/ErdiTech/lrf-uart-protocol-decoder</a>.</p>
50
+ </main>
51
+ <script type="module">
52
+ import { Decoder, PROFILES, parseHex } from "./decoder.js";
53
+ const $ = (id) => document.getElementById(id);
54
+ const sel = $("profile");
55
+ for (const [k, p] of Object.entries(PROFILES)) sel.add(new Option(p.label, k));
56
+ let dec = new Decoder(sel.value), count = 0, port = null, reader = null;
57
+ function note() { $("baudnote").textContent = "Default baud " + PROFILES[sel.value].baud + ", 8N1."; }
58
+ note();
59
+ sel.onchange = () => { dec = new Decoder(sel.value); note(); };
60
+ function show(frames) {
61
+ for (const f of frames) {
62
+ count++;
63
+ const tr = document.createElement("tr");
64
+ const d = f.distance_m === null ? "-" : f.distance_m.toFixed(3);
65
+ for (const v of [count, f.hex, f.raw, d, f.out_of_range ? "out of range / no echo" : ""]) { const td = document.createElement("td"); td.textContent = v; tr.append(td); }
66
+ $("rows").prepend(tr);
67
+ while ($("rows").children.length > 200) $("rows").lastChild.remove();
68
+ $("big").textContent = f.distance_m === null ? "out of range" : f.distance_m.toFixed(3) + " m";
69
+ }
70
+ $("stats").textContent = dec.frames + " valid frames, " + dec.skipped + " bytes skipped (resync)";
71
+ }
72
+ $("decode").onclick = () => { try { dec = new Decoder(sel.value); show(dec.push(parseHex($("hex").value))); } catch (e) { $("stats").textContent = e.message; } };
73
+ $("clear").onclick = () => { $("rows").textContent = ""; count = 0; };
74
+ $("status").textContent = "serial" in navigator ? "WebSerial available" : "WebSerial not supported in this browser (use Chrome or Edge); paste mode still works";
75
+ $("connect").onclick = async () => {
76
+ if (!("serial" in navigator)) return;
77
+ try {
78
+ port = await navigator.serial.requestPort();
79
+ await port.open({ baudRate: PROFILES[sel.value].baud });
80
+ dec = new Decoder(sel.value);
81
+ $("connect").disabled = true; $("disconnect").disabled = false; $("status").textContent = "connected";
82
+ reader = port.readable.getReader();
83
+ for (;;) { const { value, done } = await reader.read(); if (done) break; if (value) show(dec.push(value)); }
84
+ } catch (e) { $("status").textContent = String(e.message || e); }
85
+ finally { try { reader && reader.releaseLock(); } catch (_) {} }
86
+ };
87
+ $("disconnect").onclick = async () => { try { reader && await reader.cancel(); await port.close(); } catch (_) {} $("connect").disabled = false; $("disconnect").disabled = true; $("status").textContent = "disconnected"; };
88
+ </script>
89
+ </body>
90
+ </html>