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,266 @@
//
// CATCommand.swift
// FT991A-Remote
//
// CAT Command definitions for FT-991A
//
import Foundation
// MARK: - CAT Command
struct CATCommand {
let command: String
let description: String
let expectsResponse: Bool
init(_ command: String, description: String = "", expectsResponse: Bool = true) {
self.command = command
self.description = description
self.expectsResponse = expectsResponse
}
var data: Data {
(command + ";").data(using: .ascii) ?? Data()
}
}
// MARK: - CAT Commands Catalog
enum CAT {
// MARK: - Frequency Commands
/// Read VFO-A frequency
static let readVFOA = CATCommand("FA", description: "Read VFO-A frequency")
/// Set VFO-A frequency (9 digits in Hz)
static func setVFOA(_ frequency: Int) -> CATCommand {
CATCommand(String(format: "FA%09d", frequency), description: "Set VFO-A to \(frequency) Hz", expectsResponse: false)
}
/// Read VFO-B frequency
static let readVFOB = CATCommand("FB", description: "Read VFO-B frequency")
/// Set VFO-B frequency (9 digits in Hz)
static func setVFOB(_ frequency: Int) -> CATCommand {
CATCommand(String(format: "FB%09d", frequency), description: "Set VFO-B to \(frequency) Hz", expectsResponse: false)
}
/// Swap VFO A/B
static let swapVFO = CATCommand("SV", description: "Swap VFO A/B", expectsResponse: false)
/// Select VFO-A
static let selectVFOA = CATCommand("VS0", description: "Select VFO-A", expectsResponse: false)
/// Select VFO-B
static let selectVFOB = CATCommand("VS1", description: "Select VFO-B", expectsResponse: false)
/// Read active VFO
static let readActiveVFO = CATCommand("VS", description: "Read active VFO")
// MARK: - Mode Commands
/// Read operating mode
static let readMode = CATCommand("MD0", description: "Read operating mode")
/// Set operating mode
static func setMode(_ mode: OperatingMode) -> CATCommand {
CATCommand("MD0\(mode.catValue)", description: "Set mode to \(mode.rawValue)", expectsResponse: false)
}
// MARK: - Level Commands
/// Read AF gain
static let readAFGain = CATCommand("AG0", description: "Read AF gain")
/// Set AF gain (000-255)
static func setAFGain(_ value: Int) -> CATCommand {
CATCommand(String(format: "AG0%03d", min(255, max(0, value))), description: "Set AF gain", expectsResponse: false)
}
/// Read RF gain
static let readRFGain = CATCommand("RG0", description: "Read RF gain")
/// Set RF gain (000-255)
static func setRFGain(_ value: Int) -> CATCommand {
CATCommand(String(format: "RG0%03d", min(255, max(0, value))), description: "Set RF gain", expectsResponse: false)
}
/// Read squelch
static let readSquelch = CATCommand("SQ0", description: "Read squelch")
/// Set squelch (000-255)
static func setSquelch(_ value: Int) -> CATCommand {
CATCommand(String(format: "SQ0%03d", min(255, max(0, value))), description: "Set squelch", expectsResponse: false)
}
/// Read MIC gain
static let readMICGain = CATCommand("MG", description: "Read MIC gain")
/// Set MIC gain (000-100)
static func setMICGain(_ value: Int) -> CATCommand {
CATCommand(String(format: "MG%03d", min(100, max(0, value))), description: "Set MIC gain", expectsResponse: false)
}
/// Read power level
static let readPower = CATCommand("PC", description: "Read power level")
/// Set power level (005-100)
static func setPower(_ value: Int) -> CATCommand {
CATCommand(String(format: "PC%03d", min(100, max(5, value))), description: "Set power to \(value)W", expectsResponse: false)
}
// MARK: - Function Commands
/// Read Noise Blanker status
static let readNB = CATCommand("NB0", description: "Read NB status")
/// Set Noise Blanker on/off
static func setNB(_ enabled: Bool) -> CATCommand {
CATCommand("NB0\(enabled ? "1" : "0")", description: enabled ? "Enable NB" : "Disable NB", expectsResponse: false)
}
/// Read Noise Reduction status
static let readNR = CATCommand("NR0", description: "Read NR status")
/// Set Noise Reduction on/off
static func setNR(_ enabled: Bool) -> CATCommand {
CATCommand("NR0\(enabled ? "1" : "0")", description: enabled ? "Enable NR" : "Disable NR", expectsResponse: false)
}
/// Read DNF status
static let readDNF = CATCommand("BC0", description: "Read DNF status")
/// Set DNF on/off
static func setDNF(_ enabled: Bool) -> CATCommand {
CATCommand("BC0\(enabled ? "1" : "0")", description: enabled ? "Enable DNF" : "Disable DNF", expectsResponse: false)
}
/// Read Contour status
static let readContour = CATCommand("CO00", description: "Read Contour status")
/// Read ATU status
static let readATU = CATCommand("AC", description: "Read ATU status")
/// Start ATU tune
static let startATUTune = CATCommand("AC001", description: "Start ATU tune", expectsResponse: false)
/// Read Split status
static let readSplit = CATCommand("FT", description: "Read Split status")
/// Set Split on/off
static func setSplit(_ enabled: Bool) -> CATCommand {
CATCommand("FT\(enabled ? "1" : "0")", description: enabled ? "Enable Split" : "Disable Split", expectsResponse: false)
}
// MARK: - Metering Commands
/// Read S-Meter
static let readSMeter = CATCommand("SM0", description: "Read S-Meter")
/// Read Power meter
static let readPowerMeter = CATCommand("RM1", description: "Read Power meter")
/// Read SWR meter
static let readSWRMeter = CATCommand("RM6", description: "Read SWR meter")
// MARK: - PTT Commands
/// Start transmitting (MIC)
static let txOn = CATCommand("TX0", description: "TX on (MIC)", expectsResponse: false)
/// Start transmitting (DATA)
static let txOnData = CATCommand("TX1", description: "TX on (DATA)", expectsResponse: false)
/// Stop transmitting
static let txOff = CATCommand("RX", description: "TX off", expectsResponse: false)
/// Read TX status
static let readTXStatus = CATCommand("TX", description: "Read TX status")
// MARK: - Identification
/// Read radio ID
static let readID = CATCommand("ID", description: "Read radio ID")
// MARK: - Information
/// Read all status (IF command)
static let readInfo = CATCommand("IF", description: "Read info")
}
// MARK: - CAT Response
struct CATResponse {
let command: String
let value: String
let rawData: String
let timestamp: Date
init(rawData: String) {
self.rawData = rawData.trimmingCharacters(in: CharacterSet(charactersIn: ";\r\n"))
self.timestamp = Date()
// Parse command prefix (2 characters usually)
if rawData.count >= 2 {
let prefixEnd = rawData.index(rawData.startIndex, offsetBy: 2)
self.command = String(rawData[..<prefixEnd])
self.value = String(rawData[prefixEnd...]).trimmingCharacters(in: CharacterSet(charactersIn: ";\r\n"))
} else {
self.command = rawData
self.value = ""
}
}
// MARK: - Value Parsers
/// Parse frequency from FA/FB response (9 digits)
var frequency: Int? {
guard command == "FA" || command == "FB" else { return nil }
return Int(value)
}
/// Parse mode from MD0 response
var mode: OperatingMode? {
guard command == "MD" else { return nil }
let modeChar = value.dropFirst() // Remove "0" prefix
return OperatingMode.from(catValue: String(modeChar))
}
/// Parse level value (3 digits)
var levelValue: Int? {
// Handle commands like AG0XXX, RG0XXX, SQ0XXX
let numericPart = value.filter { $0.isNumber }
return Int(numericPart)
}
/// Parse S-Meter from SM0 response
var sMeter: Int? {
guard command == "SM" else { return nil }
// SM0XXX format - drop the "0" prefix
let numericPart = value.dropFirst()
return Int(numericPart)
}
/// Parse boolean status (0 or 1)
var boolValue: Bool? {
guard let last = value.last else { return nil }
return last == "1"
}
/// Parse VFO selection
var vfo: VFO? {
guard command == "VS" else { return nil }
switch value {
case "0": return .a
case "1": return .b
default: return nil
}
}
/// Check if this is the FT-991A ID
var isFT991A: Bool {
command == "ID" && value == "0670"
}
}
@@ -0,0 +1,163 @@
//
// QSOEntry.swift
// FT991A-Remote
//
// Model for QSO log entries
//
import Foundation
// MARK: - QSO Entry
struct QSOEntry: Identifiable, Codable, Hashable {
let id: UUID
var callsign: String
var date: Date
var frequency: Int // Hz
var mode: OperatingMode
var rstSent: String // e.g., "59", "599"
var rstReceived: String
var name: String
var qth: String
var locator: String // Maidenhead grid
var power: Int // Watts
var notes: String
init(
id: UUID = UUID(),
callsign: String = "",
date: Date = Date(),
frequency: Int = 14_250_000,
mode: OperatingMode = .usb,
rstSent: String = "59",
rstReceived: String = "59",
name: String = "",
qth: String = "",
locator: String = "",
power: Int = 100,
notes: String = ""
) {
self.id = id
self.callsign = callsign
self.date = date
self.frequency = frequency
self.mode = mode
self.rstSent = rstSent
self.rstReceived = rstReceived
self.name = name
self.qth = qth
self.locator = locator
self.power = power
self.notes = notes
}
// MARK: - CSV Export
static let csvHeader = "Call,Date,Time,Frequency,Mode,RST_TX,RST_RX,Name,QTH,Locator,Power,Notes"
var csvLine: String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm:ss"
timeFormatter.timeZone = TimeZone(identifier: "UTC")
let freqMHz = String(format: "%.6f", Double(frequency) / 1_000_000.0)
// Escape fields with commas or quotes
let escapedNotes = notes.contains(",") || notes.contains("\"")
? "\"\(notes.replacingOccurrences(of: "\"", with: "\"\""))\""
: notes
let escapedName = name.contains(",") || name.contains("\"")
? "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\""
: name
return [
callsign,
dateFormatter.string(from: date),
timeFormatter.string(from: date),
freqMHz,
mode.rawValue,
rstSent,
rstReceived,
escapedName,
qth,
locator,
String(power),
escapedNotes
].joined(separator: ",")
}
// MARK: - CSV Import
static func from(csvLine: String) -> QSOEntry? {
var fields: [String] = []
var current = ""
var inQuotes = false
for char in csvLine {
if char == "\"" {
inQuotes.toggle()
} else if char == "," && !inQuotes {
fields.append(current)
current = ""
} else {
current.append(char)
}
}
fields.append(current)
guard fields.count >= 12 else { return nil }
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.timeZone = TimeZone(identifier: "UTC")
guard let date = dateFormatter.date(from: "\(fields[1]) \(fields[2])") else { return nil }
guard let freqMHz = Double(fields[3]) else { return nil }
let frequency = Int(freqMHz * 1_000_000)
let mode = OperatingMode.allCases.first { $0.rawValue == fields[4] } ?? .usb
return QSOEntry(
callsign: fields[0],
date: date,
frequency: frequency,
mode: mode,
rstSent: fields[5],
rstReceived: fields[6],
name: fields[7],
qth: fields[8],
locator: fields[9],
power: Int(fields[10]) ?? 100,
notes: fields[11]
)
}
// MARK: - Display Helpers
var frequencyDisplay: String {
let mhz = frequency / 1_000_000
let khz = (frequency % 1_000_000) / 1_000
let hz = frequency % 1_000
return String(format: "%d.%03d.%03d", mhz, khz, hz)
}
var dateDisplay: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.yyyy"
return formatter.string(from: date)
}
var timeDisplay: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter.string(from: date) + " UTC"
}
var bandDisplay: String {
Band.from(frequency: frequency)?.rawValue ?? "?"
}
}
@@ -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) }
}
}
@@ -0,0 +1,123 @@
//
// Settings.swift
// FT991A-Remote
//
// Application settings model
//
import Foundation
// MARK: - App Settings
struct AppSettings: Codable {
// Connection
var serialPort: String = ""
var baudRate: Int = 38400
var autoReconnect: Bool = true
var reconnectInterval: TimeInterval = 5.0
// UI
var uiStyle: UIStyle = .modern
var language: AppLanguage = .german
var showDebugPanel: Bool = false
var showLogPanel: Bool = false
var compactMode: Bool = true
// Frequency
var frequencyStep: FrequencyStep = .khz1
// Logging
var logDirectory: String = "~/Documents/FT991A-Logs/"
var autoSaveLog: Bool = true
// Audio
var audioInputDevice: String = ""
var audioOutputDevice: String = ""
var useBlackHole: Bool = false
// Keyboard
var pttShortcutEnabled: Bool = true
var arrowFrequencyEnabled: Bool = true
var tunerShortcutEnabled: Bool = true
// MARK: - Persistence
static let defaults = AppSettings()
static var settingsURL: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let appFolder = appSupport.appendingPathComponent("FT991A-Remote", isDirectory: true)
try? FileManager.default.createDirectory(at: appFolder, withIntermediateDirectories: true)
return appFolder.appendingPathComponent("settings.json")
}
static func load() -> AppSettings {
guard FileManager.default.fileExists(atPath: settingsURL.path) else {
return defaults
}
do {
let data = try Data(contentsOf: settingsURL)
return try JSONDecoder().decode(AppSettings.self, from: data)
} catch {
print("Failed to load settings: \(error)")
return defaults
}
}
func save() {
do {
let data = try JSONEncoder().encode(self)
try data.write(to: AppSettings.settingsURL)
} catch {
print("Failed to save settings: \(error)")
}
}
// MARK: - Log Directory
var expandedLogDirectory: String {
(logDirectory as NSString).expandingTildeInPath
}
mutating func ensureLogDirectoryExists() {
let path = expandedLogDirectory
if !FileManager.default.fileExists(atPath: path) {
try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
}
}
}
// MARK: - Serial Port Configuration
struct SerialConfig: Codable {
var baudRate: Int = 38400
var dataBits: Int = 8
var stopBits: Int = 1
var parity: Parity = .none
var flowControl: FlowControl = .none
enum Parity: String, Codable, CaseIterable {
case none = "None"
case odd = "Odd"
case even = "Even"
}
enum FlowControl: String, Codable, CaseIterable {
case none = "None"
case hardware = "RTS/CTS"
case software = "XON/XOFF"
}
static let ft991aDefault = SerialConfig(
baudRate: 38400,
dataBits: 8,
stopBits: 1,
parity: .none,
flowControl: .none
)
static let availableBaudRates = [4800, 9600, 19200, 38400, 57600, 115200]
}