1e153f2f85
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)
212 lines
5.4 KiB
Swift
212 lines
5.4 KiB
Swift
//
|
|
// LogViewModel.swift
|
|
// FT991A-Remote
|
|
//
|
|
// ViewModel for QSO logging
|
|
//
|
|
|
|
import Foundation
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
// MARK: - Log ViewModel
|
|
|
|
@MainActor
|
|
class LogViewModel: ObservableObject {
|
|
|
|
// MARK: - Published Properties
|
|
|
|
@Published var entries: [QSOEntry] = []
|
|
@Published var selectedEntry: QSOEntry?
|
|
@Published var searchText = ""
|
|
@Published var sortOrder: SortOrder = .dateDescending
|
|
|
|
// Current QSO being logged
|
|
@Published var currentQSO = QSOEntry()
|
|
|
|
// File management
|
|
@Published var currentLogFile: URL?
|
|
@Published var availableLogFiles: [URL] = []
|
|
@Published var isSaving = false
|
|
@Published var lastError: String?
|
|
|
|
// MARK: - Private Properties
|
|
|
|
private let csvManager = CSVManager()
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var filteredEntries: [QSOEntry] {
|
|
var result = entries
|
|
|
|
// Apply search filter
|
|
if !searchText.isEmpty {
|
|
let search = searchText.lowercased()
|
|
result = result.filter {
|
|
$0.callsign.lowercased().contains(search) ||
|
|
$0.name.lowercased().contains(search) ||
|
|
$0.qth.lowercased().contains(search) ||
|
|
$0.notes.lowercased().contains(search)
|
|
}
|
|
}
|
|
|
|
// Apply sorting
|
|
switch sortOrder {
|
|
case .dateDescending:
|
|
result.sort { $0.date > $1.date }
|
|
case .dateAscending:
|
|
result.sort { $0.date < $1.date }
|
|
case .callsignAscending:
|
|
result.sort { $0.callsign < $1.callsign }
|
|
case .callsignDescending:
|
|
result.sort { $0.callsign > $1.callsign }
|
|
case .frequencyAscending:
|
|
result.sort { $0.frequency < $1.frequency }
|
|
case .frequencyDescending:
|
|
result.sort { $0.frequency > $1.frequency }
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
var totalQSOs: Int {
|
|
entries.count
|
|
}
|
|
|
|
var uniqueCallsigns: Int {
|
|
Set(entries.map { $0.callsign.uppercased() }).count
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
init() {
|
|
setupBindings()
|
|
refreshLogFiles()
|
|
loadLatestLog()
|
|
}
|
|
|
|
private func setupBindings() {
|
|
csvManager.$logEntries
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$entries)
|
|
|
|
csvManager.$currentLogFile
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$currentLogFile)
|
|
|
|
csvManager.$isSaving
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$isSaving)
|
|
|
|
csvManager.$lastError
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$lastError)
|
|
}
|
|
|
|
// MARK: - File Management
|
|
|
|
func refreshLogFiles() {
|
|
availableLogFiles = csvManager.listLogFiles()
|
|
}
|
|
|
|
func loadLatestLog() {
|
|
if let latest = availableLogFiles.first {
|
|
_ = csvManager.openLogFile(latest)
|
|
}
|
|
}
|
|
|
|
func openLogFile(_ url: URL) {
|
|
_ = csvManager.openLogFile(url)
|
|
}
|
|
|
|
func createNewLogFile(name: String? = nil) {
|
|
_ = csvManager.createNewLogFile(name: name)
|
|
refreshLogFiles()
|
|
}
|
|
|
|
func exportToFile(_ url: URL) -> Bool {
|
|
csvManager.exportToFile(url)
|
|
}
|
|
|
|
func setLogDirectory(_ path: String) {
|
|
csvManager.setLogDirectory(path)
|
|
refreshLogFiles()
|
|
}
|
|
|
|
// MARK: - QSO Management
|
|
|
|
func addQSO() {
|
|
guard !currentQSO.callsign.isEmpty else { return }
|
|
|
|
var entry = currentQSO
|
|
entry.callsign = entry.callsign.uppercased()
|
|
|
|
csvManager.addEntry(entry)
|
|
resetCurrentQSO()
|
|
|
|
Logger.shared.log("Added QSO: \(entry.callsign)", level: .info)
|
|
}
|
|
|
|
func updateQSO(_ entry: QSOEntry) {
|
|
csvManager.updateEntry(entry)
|
|
}
|
|
|
|
func deleteQSO(_ entry: QSOEntry) {
|
|
csvManager.deleteEntry(entry)
|
|
}
|
|
|
|
func deleteQSOs(at offsets: IndexSet) {
|
|
// Convert offsets from filtered to original indices
|
|
let entriesToDelete = offsets.map { filteredEntries[$0] }
|
|
for entry in entriesToDelete {
|
|
csvManager.deleteEntry(entry)
|
|
}
|
|
}
|
|
|
|
func resetCurrentQSO() {
|
|
currentQSO = QSOEntry()
|
|
}
|
|
|
|
// MARK: - Radio Integration
|
|
|
|
func updateFromRadio(frequency: Int, mode: OperatingMode, power: Int) {
|
|
currentQSO.frequency = frequency
|
|
currentQSO.mode = mode
|
|
currentQSO.power = power
|
|
}
|
|
|
|
// MARK: - Statistics
|
|
|
|
var bandStatistics: [(band: String, count: Int)] {
|
|
var stats: [String: Int] = [:]
|
|
for entry in entries {
|
|
let band = entry.bandDisplay
|
|
stats[band, default: 0] += 1
|
|
}
|
|
return stats.map { (band: $0.key, count: $0.value) }
|
|
.sorted { $0.count > $1.count }
|
|
}
|
|
|
|
var modeStatistics: [(mode: String, count: Int)] {
|
|
var stats: [String: Int] = [:]
|
|
for entry in entries {
|
|
let mode = entry.mode.rawValue
|
|
stats[mode, default: 0] += 1
|
|
}
|
|
return stats.map { (mode: $0.key, count: $0.value) }
|
|
.sorted { $0.count > $1.count }
|
|
}
|
|
|
|
// MARK: - Sort Order
|
|
|
|
enum SortOrder: String, CaseIterable {
|
|
case dateDescending = "Datum (neu → alt)"
|
|
case dateAscending = "Datum (alt → neu)"
|
|
case callsignAscending = "Rufzeichen (A → Z)"
|
|
case callsignDescending = "Rufzeichen (Z → A)"
|
|
case frequencyAscending = "Frequenz (niedrig → hoch)"
|
|
case frequencyDescending = "Frequenz (hoch → niedrig)"
|
|
}
|
|
}
|