Add FT-991A Remote Control App for macOS

Complete Phase 1 implementation of the Yaesu FT-991A remote control
application with CAT protocol support over USB serial (CP210x).

Features implemented:
- SerialPortManager with auto-detection of CP210x ports
- Full CAT protocol parser and command builder
- RadioState model with all transceiver parameters
- Modern SwiftUI interface with frequency/mode/level controls
- Skeuomorphic front panel view (switchable)
- Debug panel with CAT command console
- QSO log panel with CSV export/import
- Audio routing panel with BlackHole integration
- Settings with connection, UI, keyboard configuration
- Menu bar extra for background operation
- German/English localization
- Logging system for debugging

Supports: Frequency control, VFO A/B, all modes (LSB/USB/CW/FM/AM/
DATA/RTTY/C4FM), level controls, NB/NR/DNF/ATU/Split functions,
S-meter/Power/SWR metering, PTT control via Shift key.

Target: macOS 15.0+ (Sequoia/Tahoe)
This commit is contained in:
Claude
2025-12-18 10:59:15 +00:00
parent 20904e2a96
commit 1e153f2f85
30 changed files with 6665 additions and 0 deletions
@@ -0,0 +1,238 @@
//
// RadioState.swift
// FT991A-Remote
//
// Model representing the current state of the FT-991A transceiver
//
import Foundation
// MARK: - Radio State
struct RadioState {
// VFO Frequencies
var vfoAFrequency: Int = 14_250_000 // Hz
var vfoBFrequency: Int = 14_255_000 // Hz
var activeVFO: VFO = .a
// Operating Mode
var mode: OperatingMode = .usb
var filterWidth: Int = 3000 // Hz
var filterShift: Int = 0 // Hz
// Levels (0-255)
var afGain: Int = 128
var rfGain: Int = 255
var squelch: Int = 0
var micGain: Int = 50
var power: Int = 100 // Watts (5-100)
// Functions
var noiseBlanker: Bool = false
var noiseReduction: Bool = false
var dnf: Bool = false
var contour: Bool = false
var atu: Bool = false
var split: Bool = false
var ipo: Bool = false
// Metering
var sMeter: Int = 0 // 0-255
var powerMeter: Int = 0 // 0-255
var swrMeter: Int = 0 // 0-255
// TX State
var isTransmitting: Bool = false
// Computed Properties
var activeFrequency: Int {
activeVFO == .a ? vfoAFrequency : vfoBFrequency
}
var sMeterDB: Double {
// S0-S9 = 0-54 dBμV, each S-unit = 6 dB
// Above S9: +10, +20, +40, +60 dB
let normalized = Double(sMeter) / 255.0
if normalized <= 0.6 {
return normalized / 0.6 * 54.0 // S0-S9
} else {
return 54.0 + (normalized - 0.6) / 0.4 * 60.0 // S9+60
}
}
var sMeterString: String {
let normalized = Double(sMeter) / 255.0
if normalized <= 0.6 {
let sUnit = Int(normalized / 0.6 * 9.0)
return "S\(sUnit)"
} else {
let db = Int((normalized - 0.6) / 0.4 * 60.0)
return "S9+\(db)"
}
}
var frequencyDisplay: String {
formatFrequency(activeFrequency)
}
func formatFrequency(_ freq: Int) -> String {
let mhz = freq / 1_000_000
let khz = (freq % 1_000_000) / 1_000
let hz = freq % 1_000
return String(format: "%d.%03d.%03d", mhz, khz, hz)
}
}
// MARK: - VFO
enum VFO: String, Codable {
case a = "A"
case b = "B"
}
// MARK: - Operating Mode
enum OperatingMode: String, CaseIterable, Codable {
case lsb = "LSB"
case usb = "USB"
case cw = "CW"
case fm = "FM"
case am = "AM"
case rttyLSB = "RTTY-L"
case cwReverse = "CW-R"
case dataLSB = "DATA-L"
case rttyUSB = "RTTY-U"
case dataFM = "DATA-FM"
case fmNarrow = "FM-N"
case dataUSB = "DATA-U"
case amNarrow = "AM-N"
case c4fm = "C4FM"
// CAT command value (MD0X)
var catValue: String {
switch self {
case .lsb: return "1"
case .usb: return "2"
case .cw: return "3"
case .fm: return "4"
case .am: return "5"
case .rttyLSB: return "6"
case .cwReverse: return "7"
case .dataLSB: return "8"
case .rttyUSB: return "9"
case .dataFM: return "A"
case .fmNarrow: return "B"
case .dataUSB: return "C"
case .amNarrow: return "D"
case .c4fm: return "E"
}
}
static func from(catValue: String) -> OperatingMode? {
allCases.first { $0.catValue == catValue }
}
var isDigital: Bool {
switch self {
case .dataLSB, .dataUSB, .dataFM, .rttyLSB, .rttyUSB, .c4fm:
return true
default:
return false
}
}
var defaultFilterWidth: Int {
switch self {
case .lsb, .usb, .dataLSB, .dataUSB: return 3000
case .cw, .cwReverse: return 500
case .am, .amNarrow: return 6000
case .fm, .fmNarrow, .dataFM, .c4fm: return 15000
case .rttyLSB, .rttyUSB: return 500
}
}
}
// MARK: - Frequency Step
enum FrequencyStep: Int, CaseIterable, Codable {
case hz1 = 1
case hz10 = 10
case hz100 = 100
case khz1 = 1000
case khz5 = 5000
case khz10 = 10000
case khz100 = 100000
case mhz1 = 1000000
var displayName: String {
switch self {
case .hz1: return "1 Hz"
case .hz10: return "10 Hz"
case .hz100: return "100 Hz"
case .khz1: return "1 kHz"
case .khz5: return "5 kHz"
case .khz10: return "10 kHz"
case .khz100: return "100 kHz"
case .mhz1: return "1 MHz"
}
}
}
// MARK: - Band
enum Band: String, CaseIterable {
case m160 = "160m"
case m80 = "80m"
case m60 = "60m"
case m40 = "40m"
case m30 = "30m"
case m20 = "20m"
case m17 = "17m"
case m15 = "15m"
case m12 = "12m"
case m10 = "10m"
case m6 = "6m"
case m2 = "2m"
case cm70 = "70cm"
var frequencyRange: ClosedRange<Int> {
switch self {
case .m160: return 1_800_000...2_000_000
case .m80: return 3_500_000...4_000_000
case .m60: return 5_351_500...5_366_500
case .m40: return 7_000_000...7_300_000
case .m30: return 10_100_000...10_150_000
case .m20: return 14_000_000...14_350_000
case .m17: return 18_068_000...18_168_000
case .m15: return 21_000_000...21_450_000
case .m12: return 24_890_000...24_990_000
case .m10: return 28_000_000...29_700_000
case .m6: return 50_000_000...54_000_000
case .m2: return 144_000_000...148_000_000
case .cm70: return 430_000_000...450_000_000
}
}
var defaultFrequency: Int {
switch self {
case .m160: return 1_840_000
case .m80: return 3_700_000
case .m60: return 5_357_000
case .m40: return 7_100_000
case .m30: return 10_120_000
case .m20: return 14_250_000
case .m17: return 18_110_000
case .m15: return 21_250_000
case .m12: return 24_930_000
case .m10: return 28_500_000
case .m6: return 50_150_000
case .m2: return 145_500_000
case .cm70: return 433_500_000
}
}
static func from(frequency: Int) -> Band? {
allCases.first { $0.frequencyRange.contains(frequency) }
}
}