Skip to content

MarkS0485/FT891-Interface

CI CodeQL Dependabot Updates Publish packages

FT891-Interface

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.


A note from the author

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 ICatInterface contract and the FT891Cat transceiver 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.

Status & caveats

  • ✅ 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 FT891Exception for everything), CancellationTokens throughout, opt-in timeout retry, non-blocking I/O that never freezes a UI, wire tracing, formatting/range helpers, a RadioMonitor for 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 / FormatException now throw FT891Exception (original error preserved as InnerException). See CHANGELOG.md.

Proven on real hardware

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:

FT891-Interface running against a real FT-891


Why an interface?

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.


The projects

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

Architecture

            ┌──────────────────────────────────────────────┐
            │                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.


The CAT protocol in 30 seconds

  • 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.


Quick start

Install

dotnet add package FT891.Core            # NuGet.org / GitHub Packages
dotnet add package FT891.Simulator       # optional — the virtual radio

Against a real radio

using 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.USB

Handling failure — one catch covers everything

try
{
    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.

Event-driven apps — RadioMonitor

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

Against the simulator (no hardware)

# 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();          // 25

Build & test

Requires 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 green

Repository layout

FT891.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

Notable design points

  • One method, byte-count driven. CatSerialEngine.TransceiveAsync is the only place that does I/O. It looks up the expected reply length in CatSpec and reads exactly that many bytes — no fragile delimiter scanning.
  • CatSpec is 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 call InitializeLibraryAsync() 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 FT891Exception with a descriptive message (including the CAT command and, for parse failures, the raw bytes received) and the original error as InnerException. A single catch (FT891Exception) handles everything. Argument-validation errors stay as standard ArgumentNullException — 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 throws OperationCanceledException, 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. MeterScale converts 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/ToKilohertzString for unit-scaled strings, FrequencyFormat.TryParseFrequency for keyboard input, and one-line summaries on the records (info.ToFormattedString()"14.250.000 USB ch 025 TX"). All culture-invariant.
  • Public ranges. FT891Ranges publishes every value limit the setters clamp to (TxPowerWatts 5–100, KeySpeedWpm 4–60, …) as IntRanges with Clamp/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, and FrameSent/FrameReceived events let you log every frame.
  • Event-driven apps. RadioMonitor polls over the single shared channel (fairly — the engine lock serializes) and raises change events, marshalled to your UI thread via SynchronizationContext. The first poll raises everything so a UI can initialize purely from events; poll failures raise MonitorError and the loop keeps going.
  • Hardware keying. KeyingPort drives 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 tiny IsExternalInit shim and a couple of helpers.

License

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

About

Yaesu FT-891 CAT control library, virtual radio simulator, and xUnit tests (.NET Framework 4.8)

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages