WebSocket bridge for the Telepost LP-100A RF Power Meter. One server owns the serial port; many clients (browsers, logging PCs, phones) get live telemetry and can issue control actions, with every client kept in sync.
See PROPOSAL.md for the design and CLAUDE.md for the LP-100A serial protocol reference.
go mod tidy # first run only — fetches deps and writes go.sum
go test ./...
go run . -config deploy/config.example.toml
# open http://localhost:8088/Without a meter attached the server will retry the serial port forever (with backoff) and clients will see only heartbeat frames — that's expected.
The reference web client is embedded in the binary — no separate frontend build step.
From a dev machine with Go installed (Mac, Linux, or WSL):
# Pi 3/4/5 with 64-bit Raspberry Pi OS (recommended):
./deploy/build-pi.sh
# -> dist/lp100a-server-linux-arm64
# Pi Zero/1, or 32-bit OS:
ARCH=arm ./deploy/build-pi.sh
# -> dist/lp100a-server-linux-armv7Cross-compilation needs no C toolchain — the serial library is pure Go.
Two GitHub Actions workflows ship with the repo:
-
.github/workflows/ci.ymlruns on every push and pull request tomain:go vet,go test -race,go build. Required before merging a branch. -
.github/workflows/release.ymlis triggered manually from the Actions tab. Pick Release → Run workflow, type a tag likev1.0.0, and it will:- Tag the current
maincommit, - Cross-compile binaries for
linux/{amd64,arm64,armv7},darwin/{amd64,arm64}, andwindows/amd64, - Publish a GitHub Release with the binaries + a
SHA256SUMSfile attached and changelog auto-generated from commits since the previous release.
Locally you don't need to run anything — Watchers of the repo get a GitHub notification when the release is published.
- Tag the current
- Copy the project to the Pi (or just the binary plus
deploy/):scp -r dist/lp100a-server-linux-arm64 deploy pi@raspberrypi.local:~/lp100a/ - SSH in and install:
ssh pi@raspberrypi.local cd ~/lp100a sudo ./deploy/install.sh ./lp100a-server-linux-arm64
- Confirm:
systemctl status lp100a-server journalctl -u lp100a-server -f curl http://raspberrypi.local:8088/healthz
The installer:
- creates a system user
lp100aand adds it todialoutfor serial access, - installs the binary to
/usr/local/bin/lp100a-server, - writes
/etc/lp100a-server/config.toml(preserving any existing config), - installs the systemd unit and starts it,
- installs a udev rule that creates
/dev/ttyUSB_LPMfor the meter.
The default rule assumes an FTDI FT232 USB-serial chip (VID:PID 0403:6001).
If your meter uses a different adapter, find its IDs and edit the rule:
lsusb # find the meter's bus/device
udevadm info -a -n /dev/ttyUSB0 | head -40 # read its idVendor/idProduct
sudo nano /etc/udev/rules.d/99-lp100a.rules
sudo udevadm control --reload-rules && sudo udevadm trigger
ls -l /dev/ttyUSB_LPMIf /dev/ttyUSB_LPM is missing, set port = "/dev/ttyUSB0" (or whatever the
device is) in /etc/lp100a-server/config.toml and restart the service.
After the first install, push subsequent changes from your dev machine with
deploy/redeploy.sh:
./deploy/redeploy.sh pi@raspberrypi.local # rebuild, scp, restart
./deploy/redeploy.sh pi@host --service # also push systemd unit
./deploy/redeploy.sh pi@host --keep-config # binary only (skip toml/udev)
./deploy/redeploy.sh pi@host --restart-only # skip build; just bounce the service
ARCH=arm ./deploy/redeploy.sh pi@host # 32-bit ARMv7 targetBy default redeploy refreshes both the application binary and the
config.toml / udev rule from the templates in deploy/. The user's
serial.port value is detected and re-applied to the new config so a
custom device path (e.g. /dev/serial/by-id/usb-FTDI_…) survives the
update. The previous config.toml and udev rule are saved alongside
themselves with .bak.YYYYMMDD-HHMMSS suffixes before they're replaced,
so you can always diff or roll back.
Pass --keep-config if you've heavily customized config.toml (e.g.
edited enabled_views or listen) and want to keep it untouched —
only the binary will be updated.
/etc/lp100a-server/config.toml. Defaults are sensible; the fields you might
change:
| Field | Default | Notes |
|---|---|---|
serial.port |
auto |
auto, /dev/ttyUSB_LPM, COM3, etc. |
serial.poll_interval_ms |
100 |
50–250 ms range; matches LP-100-VCP |
server.listen |
0.0.0.0:8088 |
Bind to a LAN IP for safety |
server.allow_control |
true |
Set false for a read-only port |
server.max_clients |
32 |
|
ui.enabled_views |
["normal","vector"] |
Web Mode-button cycle: normal (power + SWR) and vector ( |
After editing: sudo systemctl restart lp100a-server.
The server defaults to error-level logging so the systemd journal stays quiet. Two ways to turn it up:
- At start time: pass
-v(sets debug). The deployed unit edits this viasystemctl edit lp100a-server→ExecStart=/usr/local/bin/lp100a-server -v -config /etc/lp100a-server/config.toml. - At runtime, no restart: open the web client, click SETUP, and pick a
level (ERROR / WARN / INFO / DEBUG). Or hit the API directly:
The change is in-memory; a restart resets to whatever
curl -s http://host:8088/api/log-level curl -s -XPOST http://host:8088/api/log-level -d '{"level":"debug"}'-vproduces.
There is no authentication. Anyone with network access to the listen
address can read telemetry and issue control commands. This is intentional for
LAN-only deployment. For remote access use a VPN (Tailscale, WireGuard) and
keep the listen address bound to the VPN interface or 127.0.0.1.
| Path | Purpose |
|---|---|
/ |
Embedded reference web client |
/ws |
WebSocket — telemetry stream + control verbs (see PROPOSAL.md §4) |
/healthz |
ok\n — for systemd / monitoring probes |
/api/config |
{views, allow_control} — UI bootstrap |
/api/log-level |
GET / POST the running slog level (error/warn/info/debug) |
Smoke test from the command line:
# requires websocat: brew install websocat / cargo install websocat
websocat ws://raspberrypi.local:8088/ws
# {"type":"telemetry","seq":1,"ts":"...","data":{"power_w":0,"swr":1.00,...}}
echo '{"type":"command","id":"1","action":"mode_step"}' | websocat ws://raspberrypi.local:8088/ws.
├── main.go # entry point: wires config -> serial -> hub -> http
├── internal/
│ ├── config/ # TOML loader with defaults + validation
│ ├── meter/ # serial port owner, parser, snapshot model
│ ├── hub/ # WebSocket hub: fan-out, fan-in, heartbeats
│ └── web/static/index.html # reference web client (embedded via go:embed)
├── deploy/
│ ├── config.example.toml
│ ├── lp100a-server.service # systemd unit
│ ├── 99-lp100a.rules # udev rule for /dev/ttyUSB_LPM
│ ├── build-pi.sh # cross-compile from dev box
│ ├── install.sh # first-time install on the Pi
│ └── redeploy.sh # subsequent updates (build + scp + restart)
└── .support/ # vendor + 3rd-party references (read-only)
# Tail logs
journalctl -u lp100a-server -f
# Restart after config change
sudo systemctl restart lp100a-server
# Stop / disable
sudo systemctl disable --now lp100a-server
# Health check
curl -sf http://localhost:8088/healthz && echo OK
# Run in the foreground for debugging (as the lp100a user)
sudo -u lp100a /usr/local/bin/lp100a-server -config /etc/lp100a-server/config.toml -v
# Bump log verbosity on a running instance without a restart
curl -s -XPOST http://localhost:8088/api/log-level -d '{"level":"debug"}'The embedded page mirrors the LP-100A LCD for the Normal and Vector
display modes. Power is shown with the meter's own unit suffix — w
(Average), W (Peak Hold), T (Tune) — so the web view tracks whichever
Peak-mode the device is in. The mode cycle on the page is driven client-side;
if it ever drifts from the LCD (e.g. after a meter power-cycle), open
SETUP and click the Peak-mode the meter is actually showing to re-align.
Opening multiple tabs / devices is the point of this server: any control button pressed on one client is reflected on every other client within one poll cycle.