Add static WebSerial decoder
Browse files- README.md +17 -10
- decoder.js +104 -0
- index.html +90 -19
README.md
CHANGED
|
@@ -1,10 +1,17 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo: gray
|
| 6 |
-
sdk: static
|
| 7 |
-
pinned: false
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|