A .NET Framework 4.8 solution for talking to the Yaesu FT-891 HF/50 MHz transceiver over its CAT (Computer Aided Transceiver) serial protocol — plus a virtual radio simulator you can develop and test against without any hardware, and a full xUnit test suite.
The whole stack is built around one idea: the response length of every CAT
command is known in advance, so the engine reads an exact byte count instead
of scanning for a delimiter. That single table (CatSpec) is the only place that
encodes per-command knowledge — everything else is thin glue on top of it.
Built as a hand-off for a friend — you know who you are, Mr Half Greek Sir 👋 — who's working on something genuinely interesting and wanted a clean CAT interface in a framework he's comfortable with.
Honest attribution, because it was a proper joint effort:
- I wrote the core — the
ICatInterfacecontract and theFT891Cattransceiver implementation: the command-building and reply-parsing that actually talks to the radio. - Claude filled in the boilerplate (after the thing kicked my ass for eight hours and I wanted to go to bed), then wrote all the READMEs and the code comments afterwards.
- I also ended up writing the simulator, because I was losing the plot testing against nothing.
So: a solid joint effort.
- ✅ It builds (zero warnings), and the test suite runs green — 269 tests.
- ✅ Proven on real hardware — see the video below. (Originally shipped verified against the simulator only.)
- ✅ v2.0.0 released — error handling done properly (one
FT891Exceptionfor everything), CancellationTokens throughout, opt-in timeout retry, non-blocking I/O that never freezes a UI, wire tracing, formatting/range helpers, aRadioMonitorfor event-driven apps, and full IntelliSense. The whole list lives in CHANGELOG.md. All 1.x packages are deprecated.
Good luck with what you're building — a tidy interface in the framework you're comfortable with should help.
Upgrading from 1.x? v2.0.0 is a breaking change: library calls that used to throw
TimeoutException/InvalidOperationException/FormatExceptionnow throwFT891Exception(original error preserved asInnerException). See CHANGELOG.md.
It worked first time, out of the box, against a real FT-891 over USB — pulled the library, followed the wiki, and it just worked. Proof:
There's a tempting way to build a rig-control layer: one big handler that works out each command at runtime — parse, branch, cast, dispatch — with the abstraction tucked underneath the work. It's a thing of beauty for version one. Then you add commands. Every new one makes the handler heavier, because the cleverness runs on every call, and the design fights you each time you try to expand it. You make progress, but the longest way round.
The whole problem collapses on a single observation:
For CAT, the length of every reply is known in advance.
Once that's true, you don't need a handler that figures things out on the fly — you need a table and one read loop that trusts it. The cleverness moves to one place, up front:
CatSpec— every command mapped to its exact reply length.CatSerialEngine.TransceiveAsync— write the frame, read exactly that many bytes back. One method; the only I/O in the system.FT891Cat— every command is then a one-liner: build a frame, slice the value out of a fixed-width reply.
Adding a command becomes a single table entry plus a single line — not surgery. The abstraction sits in front of the work instead of beneath it, so the cost is paid once instead of on every call. That's the whole trick; everything else is glue.
| Project | Type | What it is | Docs |
|---|---|---|---|
| FT891.Core | Class library | The CAT client, protocol engine, command table, and transport abstraction. | FT891.Core/README.md |
| FT891.Simulator | Console app | A virtual FT-891 that listens on TCP and behaves like the real radio (validates frames, holds state, answers reads, drops junk). | FT891.Simulator/README.md |
| FT891.Demo | Console app | A keyboard-driven playground — live front panel, band scanner, and a spectrum/waterfall — driving the virtual radio over CAT. | FT891.Demo/README.md |
| FT891.Tests | xUnit tests | Spec checks, protocol-format spot-checks, and full loopback round-trips against the in-process simulator. | FT891.Tests/README.md |
┌──────────────────────────────────────────────┐
│ Your application │
└───────────────────────┬──────────────────────┘
│ ICatInterface (async API)
┌──────────▼──────────┐
│ FT891Cat │ builds + parses CAT frames
└──────────┬──────────┘ (every method a one-liner)
│ TransceiveAsync(command, expectedBytes, ct)
┌──────────▼──────────┐
│ CatSerialEngine │ count-driven I/O, one lock
└──────────┬──────────┘
│ ICatTransport
┌─────────────────┴─────────────────┐
┌──────────▼───────────┐ ┌───────────▼──────────┐
│ SerialPortTransport │ │ TcpCatTransport │
│ (real radio) │ │ (simulator / tests) │
└──────────┬───────────┘ └───────────┬──────────┘
│ RS-232 / USB (8-N-2) │ TCP loopback
┌──────────▼───────────┐ ┌───────────▼──────────┐
│ Yaesu FT-891 │ │ FT891.Simulator │
└──────────────────────┘ └──────────────────────┘
Because the client only depends on ICatTransport, the same FT891Cat code
drives a physical radio over a serial port or the simulator over a TCP
socket — which is exactly what the integration tests exploit.
- Frames are plain ASCII, terminated by a semicolon
;. - A frame starts with a 2-letter uppercase command followed by optional parameters.
- Read a value with the short form, set it with the long form:
| Intent | Frame sent | Reply |
|---|---|---|
| Read VFO-A | FA; |
FA014250000; |
| Set VFO-A to 14.250 MHz | FA014250000; |
(none) |
| Read mode | MD0; |
MD02; (2 = USB) |
| Set mode to CW | MD03; |
(none) |
A real radio silently ignores anything malformed — and so does the simulator.
dotnet add package FT891.Core # NuGet.org / GitHub Packages
dotnet add package FT891.Simulator # optional — the virtual radiousing FT891.Core;
using var radio = new FT891Cat("COM3"); // 38400 baud, 8-N-2, 2000 ms timeout
radio.Connect();
await radio.SetVfoAFrequencyAsync(14_250_000);
await radio.SetModeAsync(OperatingMode.USB);
long hz = await radio.GetVfoAFrequencyAsync(); // 14250000
var mode = await radio.GetModeAsync(); // OperatingMode.USBtry
{
long hz = await radio.GetVfoAFrequencyAsync();
}
catch (FT891Exception ex)
{
// "Radio did not respond to 'FA;' (0/12 bytes received). Check the radio
// is on and the cable/port settings are correct."
Console.WriteLine(ex.Message); // original error in ex.InnerException
}Every async method also takes an optional CancellationToken, and setting
radio.TimeoutRetryCount = 2 rides out a glitchy serial link by re-sending
timed-out commands automatically.
The FT-891 has one CAT channel (its second USB COM port carries PTT/keying
lines, not commands — see KeyingPort), so RadioMonitor polls over the same
serialized channel and raises change events. Polls and your own commands
interleave fairly on the engine's lock:
using var monitor = new RadioMonitor(radio) { PollIntervalMs = 250, IncludeSMeter = true };
monitor.FrequencyChanged += hz => label.Text = hz.ToFormattedString();
monitor.TransmitChanged += tx => txLamp.Visible = tx;
monitor.SMeterChanged += raw => bar.Value = raw;
monitor.Start(); // captures your UI thread's SynchronizationContext — events arrive on it# Terminal 1 — start the virtual radio
dotnet run --project FT891.Simulator -- 4000// Terminal 2 — your app
using FT891.Core;
using var radio = new FT891Cat(new TcpCatTransport("127.0.0.1", 4000));
radio.Connect();
await radio.SetTxPowerAsync(25);
int watts = await radio.GetTxPowerAsync(); // 25Requires the .NET SDK on Windows with .NET Framework 4.8 targeting
support. (The projects are SDK-style and pull in
Microsoft.NETFramework.ReferenceAssemblies, so dotnet build works without a
full Visual Studio install.)
dotnet build FT891.sln # builds all four projects, zero warnings
dotnet test FT891.sln # 269 tests, all greenFT891.sln
├── FT891.Core/ class library (the NuGet package)
│ ├── FT891Exception.cs the single exception type for all failures
│ ├── FT891Ranges.cs the radio's value limits, public, in one place
│ ├── FrequencyFormat.cs "14.250.000" / MHz / kHz strings + TryParseFrequency
│ ├── MeterScale.cs raw 0–255 meter values → S-units / watts / SWR
│ ├── RadioMonitor.cs background polling + change events for UI apps
│ ├── Enums/ OperatingMode, AgcMode, MeterType, …
│ ├── Models/ RadioInfo, MeterReading (records)
│ ├── Protocol/ CatSpec, CatSerialEngine, FT891Cat (+Diagnostics), transports,
│ │ KeyingPort (PTT/CW over the second COM port's RTS/DTR)
│ ├── Interface/ ICatInterface (the fully documented contract)
│ └── Properties/ IsExternalInit polyfill (records on net48)
├── FT891.Simulator/ console app (also packaged for in-process embedding)
│ ├── RadioState.cs the virtual radio's complete state
│ ├── SimulatorServer.cs TCP listener + validate/dispatch/log
│ ├── Morse/ Morse tables, decoder, abbreviations, CW beacon
│ └── Program.cs entry point (port arg, default 4000)
├── FT891.Demo/ console playground (front panel, scanner, waterfall, …)
└── FT891.Tests/ xUnit — 269 tests
├── CatSpecTests / CatSpecConsistencyTests
├── ProtocolFormatTests / ProtocolParseTests / ClampingTests
├── InterCommandDelayTests / DiagnosticTests
├── ErrorHandlingTests / RetryTests / CancellationTests / MeterScaleTests
├── FrequencyFormatTests / RangesTests / TracingTests / FormattingTests
├── RadioMonitorTests / KeyingPortTests
├── SimulatorIntegrationTests / Morse/MorseTests
└── CapturingTransport.cs in-memory ICatTransport test double
- One method, byte-count driven.
CatSerialEngine.TransceiveAsyncis the only place that does I/O. It looks up the expected reply length inCatSpecand reads exactly that many bytes — no fragile delimiter scanning. CatSpecis the single source of truth. 58 commands, each mapped to its exact reply length (prefix + payload +;). The lengths were reconciled against the actual frame formats so the count-driven reader always lines up.- Self-pacing engine. The library is faster than the radio: command bursts
(polling loops) can outrun it and trigger races. The engine enforces a
minimum gap between consecutive commands —
InterCommandDelayMs, default 10 ms (0 disables). Configure it three ways: pass it to any constructor for the edge cases (new FT891Cat("COM8", interCommandDelayMs: 25)), tune the property at runtime, or callInitializeLibraryAsync()to measure the attached radio and set it automatically. - Transport abstraction.
ICatTransport(serial or TCP) is what makes the radio testable over loopback. - One exception type. Every runtime failure — port won't open, radio not
responding, transport fault, unparseable reply — surfaces as
FT891Exceptionwith a descriptive message (including the CAT command and, for parse failures, the raw bytes received) and the original error asInnerException. A singlecatch (FT891Exception)handles everything. Argument-validation errors stay as standardArgumentNullException— those are bugs in the calling code. - Never blocks your UI. The blocking serial round-trip runs off the
caller's thread, so an unresponsive radio can't freeze a WinForms/WPF app
despite the synchronous transports underneath. Every async method takes an
optional
CancellationToken(cancellation throwsOperationCanceledException, never wrapped). - Opt-in retry.
TimeoutRetryCount(default 0) re-sends a timed-out command up to N extra times before giving up — only timeouts retry. - Meter scaling helpers.
MeterScaleconverts the raw 0–255 meter values to S-units ("S9+20"), watts, and SWR — clearly documented approximations. - Formatting & parsing helpers.
hz.ToFormattedString()→"14.250.000"(dial style),ToMegahertzString/ToKilohertzStringfor unit-scaled strings,FrequencyFormat.TryParseFrequencyfor keyboard input, and one-line summaries on the records (info.ToFormattedString()→"14.250.000 USB ch 025 TX"). All culture-invariant. - Public ranges.
FT891Rangespublishes every value limit the setters clamp to (TxPowerWatts5–100,KeySpeedWpm4–60, …) asIntRanges withClamp/Contains— pre-validate in your UI instead of being silently adjusted. - Wire tracing.
LastCommand/LastResponse/LastResponseBytes()show exactly what crossed the wire for the last completed command, andFrameSent/FrameReceivedevents let you log every frame. - Event-driven apps.
RadioMonitorpolls over the single shared channel (fairly — the engine lock serializes) and raises change events, marshalled to your UI thread viaSynchronizationContext. The first poll raises everything so a UI can initialize purely from events; poll failures raiseMonitorErrorand the loop keeps going. - Hardware keying.
KeyingPortdrives the second USB COM port's real function — PTT on RTS, CW/FSK key on DTR — and always drops both lines before closing so an exiting app never leaves the rig keyed. - IntelliSense everywhere. Every public member carries XML docs (CAT command, units, clamp ranges, exceptions) and the doc file ships in the package.
- net48 without compromise. Modern C# (records, target-typed
new) compiles on .NET Framework 4.8 via a tinyIsExternalInitshim and a couple of helpers.
Released under the GNU General Public License v3.0 (or later) — see LICENSE. You're free to use, study, share, and modify it; works that build on it should be shared under the same license.
READMEs written by Claude Code Opus 4.8m Context - From CodeBase - TSGBMPPT
