A native macOS client for the LP-700 WebSocket Server — the upstream daemon that owns the USB HID handle on the Telepost LP-500 / LP-700 Digital Station Monitor and exposes telemetry + control verbs over WebSocket. This app is one of those clients, replicating the look & feel of the server's embedded reference web UI as a real Mac app.
Companion of VU3ESV/LP-100A-App. Same shell, same reconnect machinery, different telemetry shape — HID instead of serial, with channel + range + alarm semantics specific to the LP-500/700.
A native Mac window with a standard NSToolbar — connection badge on
the leading side (green/yellow/red dot + host label), a backend pill in
the principal slot (HID / SIMULATOR), and shield + wrench gear buttons
trailing. The content area is two regularMaterial panels: live
readouts on top, status row + keypad beneath.
- Live telemetry from the LP-500/700: average power, peak power, SWR, range, channel + auto-channel, peak/avg/tune mode, alarm state, callsign, coupler, firmware revision, status messages — pushed over WebSocket from the server.
- Power & SWR mirror — the meter's main LCD page rendered with large numeric readouts, SWR-tinted (green/yellow/red at 1.5 / 2.0 thresholds), channel pills (Auto / 1..4), range cycle button, peak mode trio (Peak Hold / Average / Tune), alarm pill.
- Control verbs the server's
/wschannel accepts:peak_toggle,range_step,channel_step,alarm_toggle,mode_step. All gated behind the server'sallow_controlflag. - SETUP overlay — server log-level picker (
/api/log-level), backend annotation (HID vs simulator), and a read-only display of meter NVRAM fields (callsign / coupler / firmware).
Mac-specific affordances:
- First-launch Connect sheet — modal panel asking for the server URL,
with an inline "Test connection" probe (
/healthz). Re-openable from the toolbar shield button or via ⌘K. - Toolbar connection badge — green dot + host label.
- Backend pill — HID = real meter, SIMULATOR = synthesised data.
- Menu-bar live readout — glance-able Avg power + SWR + connection state while the main window is hidden.
- Native macOS notifications — alert when
alarm_trippedrises, throttled to one per 30 s. - Preferences (⌘,) — server status + Change/Reconnect/Disconnect buttons, notifications toggle, menu-bar toggle, update-check toggle.
- Update check — tells you when a newer release is published on GitHub (see Updates); never downloads anything itself.
- Keyboard shortcuts — ⌘R range step, ⌘A alarm toggle, ⌘M LCD mode step, ⌘Y resync, ⌘. SETUP overlay, ⇧⌘1/2/3 force Peak Hold / Avg / Tune, ⌘K Connect to Server…, ⇧⌘D Disconnect / Reconnect.
- Sleep/wake hook — reconnects on
NSWorkspace.didWakeNotificationso the meter is correct the moment the lid opens.
The window respects the system appearance (light/dark) — readouts use
the system tint color and regularMaterial panel backgrounds.
- Download
LP-700-App-<version>.dmgfrom the Releases page. - Open the DMG, drag LP-700-App.app to
/Applications. - Gatekeeper bypass. This app is ad-hoc-signed, not Apple-notarized
(yet). Once after install, run:
Then double-click as normal. A future release will be notarized; until then, this one-time bypass is the cost of skipping the Apple Developer Program fee.
xattr -dr com.apple.quarantine /Applications/LP-700-App.app
The scripts/install-local.sh helper builds the app and copies it into
/Applications in one step (including the xattr -d quarantine strip):
git clone https://github.com/VU3ESV/LP-700-App
cd LP-700-App
VERSION=$(git describe --tags --always 2>/dev/null || echo 0.0.0-dev) \
./scripts/install-local.sh
open /Applications/LP-700-App.appThe script builds a release binary for this Mac's architecture only (a local
install never runs anywhere else, so the universal build would double the
compile time and binary size for nothing), strips local symbols, ad-hoc signs
it, then ditto-copies the bundle into /Applications. Set
ARCHS="arm64 x86_64" to get a universal binary, or STRIP=0 to keep
symbols. If /Applications requires elevated access (rare on personal
Macs), it re-runs itself with sudo.
The app opens a Connect to LP-700 Server sheet automatically the
first time it runs (no serverURL configured yet). Enter the URL of your
server (e.g. http://localhost:8089 for a local server, or
http://raspberrypi.local:8089 for a Pi on the LAN), tap Test
connection to probe /healthz, then Connect. Note the default
port is 8089 — LP-100A-Server uses 8088, LP-700-Server uses 8089 so
the two can coexist on the same Pi.
To change servers later: click the shield icon in the toolbar, choose File → Connect to Server… (⌘K), or open Preferences (⌘,) → Server → Change Server…. To disconnect cleanly, hit ⇧⌘D.
Loaded in the Amateur Radio Suite, the plugin behaves the same way: the first time it appears it asks for the server URL, and from then on it connects on its own. The Suite plugin keeps its own copy of the URL (the extension's sandbox), so configure it once there even if the standalone app is already set up.
The app tells you when a newer LP-700-App release is out. About
10 seconds after launch, and then once a day while it keeps running, it
makes one anonymous request to GitHub —
GET https://api.github.com/repos/VU3ESV/LP-700-App/releases/latest —
and compares that release's tag with its own version. If the release is
newer, a dialog shows its notes with Download (opens the release
page in your browser), Skip This Version and Remind Me Later.
Nothing is downloaded or installed automatically, and no other server is
involved. When the dialog appears on its own it does not take the
keyboard or bring the app forward — whatever you are typing in keeps the
keyboard — and none of its buttons is the default, so Return never opens
the browser: Download needs a click, Esc is Remind Me Later.
- LP-700 ▸ Check for Updates… checks right away and always reports (newer release, up to date, or why the check failed).
- Preferences (⌘,) → Updates → Check for updates automatically turns the automatic check off (default on).
- Automatic checks are silent when GitHub can't be reached; the time is remembered only after a successful answer, so a failed attempt is retried (at most once an hour) instead of waiting a day.
- Development builds (
0.0.0-dev, the default whenVERSIONisn't set) never check on their own; the menu item still works. - Only the standalone app checks. Hosted as an Amateur Radio Suite plugin, LP-700 never makes the request.
To see the dialog against the real latest release, quit the app and run
open --env UPDATE_CHECK_TEST_CURRENT_VERSION=0.0.1 /Applications/LP-700-App.app,
then choose Check for Updates… (the variable is ignored unless set).
Requirements: macOS 13+, Xcode 15+ (or Xcode-CLT with Swift 5.9+).
git clone https://github.com/VU3ESV/LP-700-App
cd LP-700-App
# Run tests
swift test
# Run from the command line (debug, single-arch — fast iteration)
swift run
# Build a universal (arm64+x86_64) release .app bundle in dist/
# (ARCHS=native builds only this Mac's slice; STRIP=1 strips local symbols)
VERSION=$(git describe --tags --always) ./scripts/build-app.sh
# Wrap the .app in a DMG with /Applications symlink
VERSION=$(git describe --tags --always) ./scripts/make-dmg.sh
# Build (native arch only) + install directly to /Applications
VERSION=$(git describe --tags --always) ./scripts/install-local.shThe .app is ad-hoc-signed (codesign --sign -); fine for local use and
for distribution if users do the xattr -d step above. Notarization is a
TODO item.
Releases are produced by GitHub Actions
(.github/workflows/release.yml),
which runs on macos-14 (Apple Silicon, Xcode 15.x). Two ways to fire it:
git tag v0.1.0
git push origin v0.1.0The workflow:
- Runs
swift test. - Builds the universal
.appviascripts/build-app.sh. - Wraps it in a DMG via
scripts/make-dmg.sh. - Computes the SHA-256 checksum.
- Creates a GitHub Release at
v0.1.0with auto-generated release notes from the previous tag's commits, and attachesLP-700-App-0.1.0.dmgplus its.sha256.
From the GitHub Actions UI: Actions → Release → Run workflow. Enter
a version string (e.g. 0.1.0). The workflow creates the matching
v0.1.0 git tag at HEAD of main and cuts the same kind of public
Release the tag-push path does. Tick the prerelease checkbox if
you don't want the new tag to become latest.
Because manual dispatch creates a real tag, re-running with the same version will fail (the tag already exists). Bump the version or delete the old tag first.
CI smoke-builds the same .app on every push and PR, so a tag push
that gets to the release stage is already known to compile and pass tests.
-
One
MeterViewModel(@MainActor) owns the latest snapshot, connection state, and SETUP toggle. -
A
WSClientactor wrapsURLSessionWebSocketTask, auto-reconnects with 0.5 → 10 s exponential backoff, and runs a 4 s heartbeat watchdog (no inbound frame for >4 s = drop and reconnect). -
A
ConfigClientactor handlesGET /api/config(bootstrap) andGET/POST /api/log-level(SETUP overlay). -
The wire protocol mirrors the server's
internal/lpmeter/snapshot.goJSON shape and thecommand/resyncverbs handled byinternal/hub/hub.go. -
User manual — installation walkthrough, view-by-view tour with screenshots, keyboard shortcuts, troubleshooting.
-
Architecture review — layered design, concurrency model, connection lifecycle, risks.
LP-700-App/
├── README.md # this file
├── CLAUDE.md # session orientation for AI assistants
├── ARCHITECTURE.md # architecture review
├── LICENSE # MIT
├── Package.swift # Swift Package manifest (executable + tests)
├── Sources/LP700App/
│ ├── App.swift # @main, scenes (WindowGroup, Settings, MenuBarExtra)
│ ├── UpdateChecker.swift # GitHub release check (Check for Updates…)
│ ├── Net/ # WireProtocol, WSClient, ConfigClient
│ ├── ViewModels/ # MeterViewModel
│ ├── Views/ # ContentView, PowerSWRView, KeypadView,
│ │ # ConnectionSheet, PreferencesView, SetupOverlay,
│ │ # Panel
│ └── MenuBar/ # MenuBarLabel + MenuBarContent popover
├── Tests/LP700AppTests/ # WireProtocol round-trips, UpdateChecker, configuredServerURL, WS reconnect
├── Resources/Info.plist # bundle template (VERSION substituted at build time)
├── scripts/
│ ├── build-app.sh # release .app (universal; ARCHS=native, STRIP=1), ad-hoc signed
│ ├── make-dmg.sh # DMG with /Applications symlink
│ ├── make-icon.sh # generates AppIcon.icns from a 1024×1024 PNG
│ └── install-local.sh # native, symbol-stripped build + ditto-copy to /Applications
└── .github/workflows/
├── ci.yml # build+test on every push/PR
└── release.yml # builds DMG, creates Release on tag
The server has a built-in simulator backend that emits synthesised
telemetry frames. Start it with:
go run . -backend simulator -config deploy/config.example.tomlConnect this app to http://localhost:8089 and you'll see synthetic
power/SWR sweeps at the simulator's poll rate. The toolbar's SIMULATOR
pill makes it obvious the data isn't from a real meter.
- No authentication. The server is LAN-only by design; this client follows suit. For remote access, front the server with Tailscale or WireGuard.
- No bargraphs (yet). v0 is large numeric readouts only. The bargraph + sticky-peak-marker visuals from LP-100A-App will arrive in v0.2.
- No notarization — first release is ad-hoc-signed; Gatekeeper bypass documented above.
Telepost Inc. designed and manufactures the LP-500 and LP-700. This
project is unaffiliated; product names and trademarks belong to
TelePost. The wire protocol, range labels, and peak-mode encoding
mirror the upstream
LP-700-Server's
internal/lpmeter/snapshot.go and the embedded reference web client.
MIT — see LICENSE.