Pure-Dart CAT control and real-time audio spectrum tooling for the Yaesu FT-891.
A proof of concept for a Flutter touchscreen front panel. It exists to prove two things work in Dart before any UI is built on top of them:
- A CAT request/response loop that survives a radio being used, not just idled.
- A real-time FFT of the receiver's audio at a usable frame rate.
Then it puts them together in a terminal waterfall.
lib/ is Flutter-free by design and enforced by a test. The eventual Flutter
app consumes this package unchanged via a path dependency.
20m FT8 received on an FT-891 through a Digirig, rendered with 24-bit colour
half-block cells. Each vertical streak is one station; the quiet band across the
middle is the gap between FT8's 15-second transmit cycles. Captured audio-only —
run with --port as well and the header carries live frequency, mode, S-meter
and filter width.
| Criterion | Result |
|---|---|
§3.1 ports identifies the CP2105 Enhanced port |
pass — --probe confirms FT-891 at 38400 baud |
§3.2 cat --watch survives 60 s of knob spinning |
pass — 1164 responses, 0 desyncs, 0 timeouts, 0 resyncs |
| §3.3 recovers from a mid-run power cycle | pass — ready → degraded → connecting → ready, 0 desyncs |
§3.4 audio --peak tracks a real signal |
capture verified; needs a tune-across-carrier check |
| §3.5 FT8 visible as discrete lines on 14.074 | pass — see the screenshot above |
§3.6 scope --mock runs with no hardware |
pass — 12% of columns persistently bright, discrete lines |
| §3.7 clean analysis, tests, malformed-input coverage | pass — 179 tests, dart analyze --fatal-infos clean |
Measured on the real radio at 38400: round trips of 5–13 ms
(IF; median 12 ms, everything else 5–6 ms). FFT throughput is ~5700 fps
across the isolate boundary against a 15 fps target.
brew install libserialport ffmpeg # macOS
sudo apt install libserialport-dev alsa-utils # Debian / Raspberry Pi
dart pub getpackage:libserialport calls DynamicLibrary.open('libserialport.dylib'), and
dyld does not search /opt/homebrew/lib on Apple Silicon. Without this the
tool reports that it cannot load the native library:
export LIBSERIALPORT_PATH=/opt/homebrew/lib/libserialport.dylibLinux needs nothing — apt installs into the default search path.
Set these in the FT-891 menu before use. The CAT rate matters most: at the factory default nothing answers, and a wrong rate does not produce silence — it produces plausible-looking framing garbage.
| Menu | Setting | Value | Why |
|---|---|---|---|
| 05-06 | CAT RATE | 38400 | 4800 cannot sustain 10 Hz meter polling |
| 05-07 | CAT TOT | 1000 ms | Also the length of the post-rejection dead window |
| 05-08 | CAT RTS | OFF | Unless you are using RTS PTT |
| 08-05 | DATA LCUT FREQ | OFF | Otherwise the waterfall loses low-end bandwidth |
| 08-07 | DATA HCUT FREQ | OFF | Otherwise it loses high-end bandwidth |
| 08-11 | DATA OUT LEVEL | tune for ~50% | audio --peak reports real dBFS to aim at |
08-05 and 08-07 must be OFF or the DATA output is pre-filtered and the waterfall is cropped at both ends.
FT-891 rear USB-B ──► Silicon Labs CP2105 (dual) ──► two virtual serial ports
Enhanced = CAT
Standard = PTT/RTS, not CAT
FT-891 DATA jack ────► Digirig ────────────────────► USB sound card
needle ports # list serial ports, flag the likely CAT port
needle ports --probe # open each candidate and ask the radio to identify itself
needle devices # list audio inputs, flag the likely Digirig
needle cat --port <p> --send "FA;" # one-shot command
needle cat --port <p> --watch # live state, ends with a health verdict
needle cat --port <p> --record f # capture a fixture transcript
needle audio --device <d> --peak # dominant frequency, dBFS and SNR
needle audio --device <d> --bins # magnitude array as CSV
needle scope --port <p> --device <d> # the full demo
needle scope --mock # no hardware required
needle tx --port <p> --allow-transmit # guarded test transmissionGlobal flags: --verbose (round-trip timings and raw serial traffic),
--log <file>, --baud (default 38400).
A single CP2105 can appear four times on macOS, because Apple's built-in
CP210x driver and the Silicon Labs DriverKit extension each publish a node for
both interfaces. needle ports greys the duplicates and flags one candidate:
✓ /dev/cu.usbserial-00B61C5C0
vid=0x10C4 pid=0xEA70 serial=00B61C5C
CP2105 interface 0 = Enhanced
· /dev/cu.SLAB_USBtoUART6
same UART as /dev/cu.usbserial-00B61C5C0 (other driver)
Note that SLAB_USBtoUART6 is the Enhanced port while plain
SLAB_USBtoUART is Standard — picking the lower-numbered device selects PTT,
not CAT.
Default to never transmitting. needle tx refuses without
--allow-transmit, and there is no path to a key-down from any other command.
A watchdog un-keys unconditionally after 10 seconds, an exception during
key-down still un-keys, and SIGINT un-keys before exiting.
The FT-891 has no
RXcommand. Its command table runs RA, RC, RD, RG, RI, RL, RM, RS, RU — no RX — and the radio answers?;to it. Un-keying isTX0;. A test grepslib/to prove no source file can sendRX;, because doing so would leave the transmitter keyed.
Only ever test into a dummy load.
Things that cost real time to discover, recorded so they cost nobody else any.
After the radio answers ?; it ignores CAT for a full second. Measured:
commands sent 0.4–0.9 s after a rejection were all silently discarded; 1.0 s and
beyond always worked. That threshold is menu 05-07 CAT TOT. So a rejection costs
far more than the rejected command, and the queue must go quiet rather than
merely not retry. A corollary: never send a bare ; to "flush" the link — it is
itself invalid, so it triggers the dead window it was meant to clear.
SerialPortConfig.dispose() plus SerialPort.dispose() aborts the process.
Either alone is safe. Configs also cannot be reused across ports — the second
apply fails with ETIMEDOUT. needle builds a fresh config per open and never
disposes it.
SerialPortReader is unusable here. It hands a raw Pointer<sp_port> to a
spawned isolate and only stops it asynchronously, so closing the port frees the
struct mid-read. Since resync is built on close/reopen, needle runs its own 2 ms
read pump instead.
ready must mean the radio is answering, not that the port opened. The
CP2105 node can survive a power cycle, so a successful reopen proves nothing.
SH returns a table index, not a bandwidth. The same index means
different things per mode: 14 is 2400 Hz in wide SSB but 1700 Hz in wide CW,
and narrow SSB does not offer it at all. The table is transcribed in
lib/src/cat/filter_widths.dart; gaps the book prints as dashes are null
rather than guessed.
Auto-information mode makes things worse, measured. Spec §5.6 asks whether
AI1; reduces bus load and says to keep polling until AI is proven. It is now
disproven for this design. Twenty seconds on the same radio, back to back:
| responses | desyncs | timeouts | unsolicited | |
|---|---|---|---|---|
| polling | 390 | 0 | 0 | 0 |
--auto-info |
375 | 7 | 4 | 54 |
The reason is structural rather than a bug: this controller keeps exactly one
command in flight and matches answers by prefix, so a pushed update landing
mid-exchange either completes the wrong request or leaves the real answer to
time out. Benefiting from AI needs a controller built around pushed state as
first-class input, which is a redesign and not a flag. The flag stays because
the evidence is worth keeping, and it now restores AI0; on exit — AI persists
until power-off and would otherwise confuse the next program you open.
The S-meter zero glitch is auto-info-specific. Spec §5.7 warns of bogus
SM0000 readings during knob motion; a real 60-second capture under hard
spinning produced zero in 118 reads. Re-reading the spec, it describes them
"during auto-info bursts" — polling never enters that state. The debouncer
remains as insurance for --auto-info.
macOS is the only host this has actually run on. Linux and Windows capture
backends exist and are written from the ffmpeg/ALSA documentation, but have
never been executed — needle devices says so out loud when you are on
one. Everything host-specific lives in lib/src/dsp/capture_backend.dart;
adding a platform means adding one class there and nothing else.
| Host | CAT | Audio | Status |
|---|---|---|---|
| macOS | libserialport (needs LIBSERIALPORT_PATH) |
ffmpeg + avfoundation, device by index | verified on hardware |
| Linux / Pi | libserialport (on the default search path) | arecord, device as hw:card,dev |
untested |
| Windows | libserialport (serialport.dll) |
ffmpeg + dshow, device by name | untested |
| Android | usb_serial replaces libserialport |
plugin-based | not attempted; the CatTransport and PcmSource interfaces exist for it |
sMeterSUnits is deliberately null. The CAT reference book documents SM as
nothing more than P2 000 - 255 — no S-unit mapping, no dBµV, no curve — and a
guessed calibration is worse than an honest absence. Deriving one needs a
signal generator: step known levels into the antenna port, record the raw SM
value at each, and fit the breakpoints. sMeterRaw carries the number the
radio actually reported in the meantime.
dart analyze --fatal-infos
dart testtest/layering_test.dart enforces the architectural rules: dsp/ never imports
cat/, nothing in lib/ mentions Flutter, nothing outside cli/ writes to the
console, and no code path can send RX;.
MIT. Note that the libserialport Dart wrapper is LGPL-3.0; if you distribute a
compiled binary, that carries a relink obligation.
