From 1e153f2f8539742b35fcb4b461928e45d921c33c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Dec 2025 10:59:15 +0000 Subject: [PATCH] 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) --- .../FT991A-Remote.xcodeproj/project.pbxproj | 524 ++++++++++++++++ .../AccentColor.colorset/Contents.json | 38 ++ .../AppIcon.appiconset/Contents.json | 58 ++ .../Assets.xcassets/Contents.json | 6 + .../FT991A-Remote/FT991A_Remote.entitlements | 16 + .../FT991A-Remote/FT991A_RemoteApp.swift | 109 ++++ FT991A-Remote/FT991A-Remote/Info.plist | 53 ++ .../FT991A-Remote/Models/CATCommand.swift | 266 ++++++++ .../FT991A-Remote/Models/QSOEntry.swift | 163 +++++ .../FT991A-Remote/Models/RadioState.swift | 238 ++++++++ .../FT991A-Remote/Models/Settings.swift | 123 ++++ .../FT991A-Remote/Services/AudioRouter.swift | 250 ++++++++ .../FT991A-Remote/Services/CATProtocol.swift | 442 ++++++++++++++ .../FT991A-Remote/Services/CSVManager.swift | 240 ++++++++ .../Services/SerialPortManager.swift | 475 +++++++++++++++ .../Localization/de.lproj/Localizable.strings | 126 ++++ .../Localization/en.lproj/Localizable.strings | 126 ++++ .../FT991A-Remote/Utilities/Logger.swift | 223 +++++++ .../ViewModels/LogViewModel.swift | 211 +++++++ .../ViewModels/RadioViewModel.swift | 306 ++++++++++ .../ViewModels/SettingsController.swift | 172 ++++++ .../FT991A-Remote/Views/MainView.swift | 216 +++++++ .../Views/MenuBar/MenuBarView.swift | 134 ++++ .../Views/ModernView/ModernRadioView.swift | 576 ++++++++++++++++++ .../Views/Panels/AudioPanel.swift | 148 +++++ .../Views/Panels/DebugPanel.swift | 178 ++++++ .../FT991A-Remote/Views/Panels/LogPanel.swift | 318 ++++++++++ .../Views/Settings/SettingsView.swift | 302 +++++++++ .../SkeuomorphView/SkeuomorphRadioView.swift | 484 +++++++++++++++ FT991A-Remote/README.md | 144 +++++ 30 files changed, 6665 insertions(+) create mode 100644 FT991A-Remote/FT991A-Remote.xcodeproj/project.pbxproj create mode 100644 FT991A-Remote/FT991A-Remote/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 FT991A-Remote/FT991A-Remote/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 FT991A-Remote/FT991A-Remote/Assets.xcassets/Contents.json create mode 100644 FT991A-Remote/FT991A-Remote/FT991A_Remote.entitlements create mode 100644 FT991A-Remote/FT991A-Remote/FT991A_RemoteApp.swift create mode 100644 FT991A-Remote/FT991A-Remote/Info.plist create mode 100644 FT991A-Remote/FT991A-Remote/Models/CATCommand.swift create mode 100644 FT991A-Remote/FT991A-Remote/Models/QSOEntry.swift create mode 100644 FT991A-Remote/FT991A-Remote/Models/RadioState.swift create mode 100644 FT991A-Remote/FT991A-Remote/Models/Settings.swift create mode 100644 FT991A-Remote/FT991A-Remote/Services/AudioRouter.swift create mode 100644 FT991A-Remote/FT991A-Remote/Services/CATProtocol.swift create mode 100644 FT991A-Remote/FT991A-Remote/Services/CSVManager.swift create mode 100644 FT991A-Remote/FT991A-Remote/Services/SerialPortManager.swift create mode 100644 FT991A-Remote/FT991A-Remote/Utilities/Localization/de.lproj/Localizable.strings create mode 100644 FT991A-Remote/FT991A-Remote/Utilities/Localization/en.lproj/Localizable.strings create mode 100644 FT991A-Remote/FT991A-Remote/Utilities/Logger.swift create mode 100644 FT991A-Remote/FT991A-Remote/ViewModels/LogViewModel.swift create mode 100644 FT991A-Remote/FT991A-Remote/ViewModels/RadioViewModel.swift create mode 100644 FT991A-Remote/FT991A-Remote/ViewModels/SettingsController.swift create mode 100644 FT991A-Remote/FT991A-Remote/Views/MainView.swift create mode 100644 FT991A-Remote/FT991A-Remote/Views/MenuBar/MenuBarView.swift create mode 100644 FT991A-Remote/FT991A-Remote/Views/ModernView/ModernRadioView.swift create mode 100644 FT991A-Remote/FT991A-Remote/Views/Panels/AudioPanel.swift create mode 100644 FT991A-Remote/FT991A-Remote/Views/Panels/DebugPanel.swift create mode 100644 FT991A-Remote/FT991A-Remote/Views/Panels/LogPanel.swift create mode 100644 FT991A-Remote/FT991A-Remote/Views/Settings/SettingsView.swift create mode 100644 FT991A-Remote/FT991A-Remote/Views/SkeuomorphView/SkeuomorphRadioView.swift create mode 100644 FT991A-Remote/README.md diff --git a/FT991A-Remote/FT991A-Remote.xcodeproj/project.pbxproj b/FT991A-Remote/FT991A-Remote.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4c8a553 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote.xcodeproj/project.pbxproj @@ -0,0 +1,524 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A10000001 /* FT991A_RemoteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000001; }; + A10000002 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000002; }; + A10000003 /* ModernRadioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000003; }; + A10000004 /* SkeuomorphRadioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000004; }; + A10000005 /* DebugPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000005; }; + A10000006 /* LogPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000006; }; + A10000007 /* AudioPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000007; }; + A10000008 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000008; }; + A10000009 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000009; }; + A10000010 /* RadioState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000010; }; + A10000011 /* CATCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000011; }; + A10000012 /* QSOEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000012; }; + A10000013 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000013; }; + A10000014 /* SerialPortManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000014; }; + A10000015 /* CATProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000015; }; + A10000016 /* CSVManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000016; }; + A10000017 /* AudioRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000017; }; + A10000018 /* RadioViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000018; }; + A10000019 /* LogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000019; }; + A10000020 /* SettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000020; }; + A10000021 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000021; }; + A10000022 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000022; }; + A10000023 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = A20000023; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A20000001 /* FT991A_RemoteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FT991A_RemoteApp.swift; sourceTree = ""; }; + A20000002 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + A20000003 /* ModernRadioView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernRadioView.swift; sourceTree = ""; }; + A20000004 /* SkeuomorphRadioView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeuomorphRadioView.swift; sourceTree = ""; }; + A20000005 /* DebugPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPanel.swift; sourceTree = ""; }; + A20000006 /* LogPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogPanel.swift; sourceTree = ""; }; + A20000007 /* AudioPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPanel.swift; sourceTree = ""; }; + A20000008 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + A20000009 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; + A20000010 /* RadioState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioState.swift; sourceTree = ""; }; + A20000011 /* CATCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CATCommand.swift; sourceTree = ""; }; + A20000012 /* QSOEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QSOEntry.swift; sourceTree = ""; }; + A20000013 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; + A20000014 /* SerialPortManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialPortManager.swift; sourceTree = ""; }; + A20000015 /* CATProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CATProtocol.swift; sourceTree = ""; }; + A20000016 /* CSVManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVManager.swift; sourceTree = ""; }; + A20000017 /* AudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouter.swift; sourceTree = ""; }; + A20000018 /* RadioViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioViewModel.swift; sourceTree = ""; }; + A20000019 /* LogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewModel.swift; sourceTree = ""; }; + A20000020 /* SettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsController.swift; sourceTree = ""; }; + A20000021 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + A20000022 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A20000023 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + A20000024 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + A20000025 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A20000026 /* FT991A_Remote.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FT991A_Remote.entitlements; sourceTree = ""; }; + A30000001 /* FT991A-Remote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FT991A-Remote.app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A40000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A50000001 = { + isa = PBXGroup; + children = ( + A50000002 /* FT991A-Remote */, + A50000003 /* Products */, + ); + sourceTree = ""; + }; + A50000002 /* FT991A-Remote */ = { + isa = PBXGroup; + children = ( + A20000001 /* FT991A_RemoteApp.swift */, + A50000004 /* Models */, + A50000005 /* Services */, + A50000006 /* ViewModels */, + A50000007 /* Views */, + A50000008 /* Utilities */, + A20000022 /* Assets.xcassets */, + A20000025 /* Info.plist */, + A20000026 /* FT991A_Remote.entitlements */, + ); + path = "FT991A-Remote"; + sourceTree = ""; + }; + A50000003 /* Products */ = { + isa = PBXGroup; + children = ( + A30000001 /* FT991A-Remote.app */, + ); + name = Products; + sourceTree = ""; + }; + A50000004 /* Models */ = { + isa = PBXGroup; + children = ( + A20000010 /* RadioState.swift */, + A20000011 /* CATCommand.swift */, + A20000012 /* QSOEntry.swift */, + A20000013 /* Settings.swift */, + ); + path = Models; + sourceTree = ""; + }; + A50000005 /* Services */ = { + isa = PBXGroup; + children = ( + A20000014 /* SerialPortManager.swift */, + A20000015 /* CATProtocol.swift */, + A20000016 /* CSVManager.swift */, + A20000017 /* AudioRouter.swift */, + ); + path = Services; + sourceTree = ""; + }; + A50000006 /* ViewModels */ = { + isa = PBXGroup; + children = ( + A20000018 /* RadioViewModel.swift */, + A20000019 /* LogViewModel.swift */, + A20000020 /* SettingsController.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + A50000007 /* Views */ = { + isa = PBXGroup; + children = ( + A20000002 /* MainView.swift */, + A50000009 /* ModernView */, + A50000010 /* SkeuomorphView */, + A50000011 /* Panels */, + A50000012 /* Settings */, + A50000013 /* MenuBar */, + ); + path = Views; + sourceTree = ""; + }; + A50000008 /* Utilities */ = { + isa = PBXGroup; + children = ( + A20000021 /* Logger.swift */, + A50000014 /* Localization */, + ); + path = Utilities; + sourceTree = ""; + }; + A50000009 /* ModernView */ = { + isa = PBXGroup; + children = ( + A20000003 /* ModernRadioView.swift */, + ); + path = ModernView; + sourceTree = ""; + }; + A50000010 /* SkeuomorphView */ = { + isa = PBXGroup; + children = ( + A20000004 /* SkeuomorphRadioView.swift */, + ); + path = SkeuomorphView; + sourceTree = ""; + }; + A50000011 /* Panels */ = { + isa = PBXGroup; + children = ( + A20000005 /* DebugPanel.swift */, + A20000006 /* LogPanel.swift */, + A20000007 /* AudioPanel.swift */, + ); + path = Panels; + sourceTree = ""; + }; + A50000012 /* Settings */ = { + isa = PBXGroup; + children = ( + A20000008 /* SettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + A50000013 /* MenuBar */ = { + isa = PBXGroup; + children = ( + A20000009 /* MenuBarView.swift */, + ); + path = MenuBar; + sourceTree = ""; + }; + A50000014 /* Localization */ = { + isa = PBXGroup; + children = ( + A20000023 /* Localizable.strings */, + ); + path = Localization; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A60000001 /* FT991A-Remote */ = { + isa = PBXNativeTarget; + buildConfigurationList = A70000001; + buildPhases = ( + A80000001 /* Sources */, + A40000001 /* Frameworks */, + A90000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "FT991A-Remote"; + productName = "FT991A-Remote"; + productReference = A30000001 /* FT991A-Remote.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AB0000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + A60000001 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = AC0000001; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = de; + hasScannedForEncodings = 0; + knownRegions = ( + de, + en, + Base, + ); + mainGroup = A50000001; + productRefGroup = A50000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A60000001 /* FT991A-Remote */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A90000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000022 /* Assets.xcassets in Resources */, + A10000023 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A80000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000001 /* FT991A_RemoteApp.swift in Sources */, + A10000002 /* MainView.swift in Sources */, + A10000003 /* ModernRadioView.swift in Sources */, + A10000004 /* SkeuomorphRadioView.swift in Sources */, + A10000005 /* DebugPanel.swift in Sources */, + A10000006 /* LogPanel.swift in Sources */, + A10000007 /* AudioPanel.swift in Sources */, + A10000008 /* SettingsView.swift in Sources */, + A10000009 /* MenuBarView.swift in Sources */, + A10000010 /* RadioState.swift in Sources */, + A10000011 /* CATCommand.swift in Sources */, + A10000012 /* QSOEntry.swift in Sources */, + A10000013 /* Settings.swift in Sources */, + A10000014 /* SerialPortManager.swift in Sources */, + A10000015 /* CATProtocol.swift in Sources */, + A10000016 /* CSVManager.swift in Sources */, + A10000017 /* AudioRouter.swift in Sources */, + A10000018 /* RadioViewModel.swift in Sources */, + A10000019 /* LogViewModel.swift in Sources */, + A10000020 /* SettingsController.swift in Sources */, + A10000021 /* Logger.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + A20000023 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + A20000024 /* de */, + A20000025 /* en */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + AD0000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AD0000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + AE0000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "FT991A-Remote/FT991A_Remote.entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FT991A-Remote/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "FT-991A Remote"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright 2024"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "FT-991A Remote needs microphone access for audio monitoring and digital modes."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.hamradio.FT991A-Remote"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AE0000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "FT991A-Remote/FT991A_Remote.entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FT991A-Remote/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "FT-991A Remote"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright 2024"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "FT-991A Remote needs microphone access for audio monitoring and digital modes."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.hamradio.FT991A-Remote"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A70000001 /* Build configuration list for PBXNativeTarget "FT991A-Remote" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AE0000001 /* Debug */, + AE0000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AC0000001 /* Build configuration list for PBXProject "FT991A-Remote" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AD0000001 /* Debug */, + AD0000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AB0000001 /* Project object */; +} diff --git a/FT991A-Remote/FT991A-Remote/Assets.xcassets/AccentColor.colorset/Contents.json b/FT991A-Remote/FT991A-Remote/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..6c5d24e --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.984", + "green" : "0.584", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.678", + "red" : "0.251" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FT991A-Remote/FT991A-Remote/Assets.xcassets/AppIcon.appiconset/Contents.json b/FT991A-Remote/FT991A-Remote/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FT991A-Remote/FT991A-Remote/Assets.xcassets/Contents.json b/FT991A-Remote/FT991A-Remote/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FT991A-Remote/FT991A-Remote/FT991A_Remote.entitlements b/FT991A-Remote/FT991A-Remote/FT991A_Remote.entitlements new file mode 100644 index 0000000..7be3c9b --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/FT991A_Remote.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.device.serial + + com.apple.security.device.audio-input + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + + diff --git a/FT991A-Remote/FT991A-Remote/FT991A_RemoteApp.swift b/FT991A-Remote/FT991A-Remote/FT991A_RemoteApp.swift new file mode 100644 index 0000000..cdba51d --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/FT991A_RemoteApp.swift @@ -0,0 +1,109 @@ +// +// FT991A_RemoteApp.swift +// FT991A-Remote +// +// Yaesu FT-991A Remote Control Application for macOS +// CAT Protocol via USB Serial (Silicon Labs CP210x) +// + +import SwiftUI + +@main +struct FT991A_RemoteApp: App { + @StateObject private var radioViewModel = RadioViewModel() + @StateObject private var settingsController = SettingsController() + @StateObject private var logViewModel = LogViewModel() + + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + MainView() + .environmentObject(radioViewModel) + .environmentObject(settingsController) + .environmentObject(logViewModel) + .frame(minWidth: 800, minHeight: 600) + } + .windowStyle(.hiddenTitleBar) + .commands { + CommandGroup(replacing: .newItem) { } + + CommandMenu("Radio") { + Button(radioViewModel.isConnected ? "Trennen" : "Verbinden") { + radioViewModel.toggleConnection() + } + .keyboardShortcut("k", modifiers: .command) + + Divider() + + Button("VFO A/B tauschen") { + radioViewModel.swapVFO() + } + .keyboardShortcut("s", modifiers: [.command, .shift]) + .disabled(!radioViewModel.isConnected) + + Button("A=B") { + radioViewModel.equalizeVFO() + } + .keyboardShortcut("e", modifiers: [.command, .shift]) + .disabled(!radioViewModel.isConnected) + + Divider() + + Button("ATU Tune") { + radioViewModel.startATUTune() + } + .keyboardShortcut("t", modifiers: [.command, .shift]) + .disabled(!radioViewModel.isConnected) + } + + CommandMenu("Ansicht") { + Picker("UI-Stil", selection: $settingsController.uiStyle) { + Text("Modern").tag(UIStyle.modern) + Text("Frontpanel").tag(UIStyle.skeuomorph) + } + + Divider() + + Toggle("Debug-Panel anzeigen", isOn: $settingsController.showDebugPanel) + .keyboardShortcut("d", modifiers: [.command, .option]) + + Toggle("Log-Panel anzeigen", isOn: $settingsController.showLogPanel) + .keyboardShortcut("l", modifiers: [.command, .option]) + } + } + + Settings { + SettingsView() + .environmentObject(radioViewModel) + .environmentObject(settingsController) + } + + MenuBarExtra("FT-991A", systemImage: radioViewModel.isConnected ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash") { + MenuBarView() + .environmentObject(radioViewModel) + .environmentObject(settingsController) + } + } +} + +// MARK: - UI Style Enum + +enum UIStyle: String, Codable, CaseIterable { + case modern = "Modern" + case skeuomorph = "Frontpanel" +} + +// MARK: - Language Enum + +enum AppLanguage: String, Codable, CaseIterable { + case german = "de" + case english = "en" + + var displayName: String { + switch self { + case .german: return "Deutsch" + case .english: return "English" + } + } +} diff --git a/FT991A-Remote/FT991A-Remote/Info.plist b/FT991A-Remote/FT991A-Remote/Info.plist new file mode 100644 index 0000000..54bef53 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDevelopmentRegion + de + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + Copyright 2024 + NSPrincipalClass + NSApplication + CFBundleDocumentTypes + + + CFBundleTypeExtensions + + csv + + CFBundleTypeName + QSO Log File + CFBundleTypeRole + Editor + LSItemContentTypes + + public.comma-separated-values-text + + + + LSUIElement + + NSAppleEventsUsageDescription + FT-991A Remote needs to control other applications for audio routing. + NSMicrophoneUsageDescription + FT-991A Remote needs microphone access for audio monitoring and digital modes. + + diff --git a/FT991A-Remote/FT991A-Remote/Models/CATCommand.swift b/FT991A-Remote/FT991A-Remote/Models/CATCommand.swift new file mode 100644 index 0000000..655f096 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Models/CATCommand.swift @@ -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[.. 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 ?? "?" + } +} diff --git a/FT991A-Remote/FT991A-Remote/Models/RadioState.swift b/FT991A-Remote/FT991A-Remote/Models/RadioState.swift new file mode 100644 index 0000000..3dc4d08 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Models/RadioState.swift @@ -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 { + 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) } + } +} diff --git a/FT991A-Remote/FT991A-Remote/Models/Settings.swift b/FT991A-Remote/FT991A-Remote/Models/Settings.swift new file mode 100644 index 0000000..f98b668 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Models/Settings.swift @@ -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] +} diff --git a/FT991A-Remote/FT991A-Remote/Services/AudioRouter.swift b/FT991A-Remote/FT991A-Remote/Services/AudioRouter.swift new file mode 100644 index 0000000..5428ddf --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Services/AudioRouter.swift @@ -0,0 +1,250 @@ +// +// AudioRouter.swift +// FT991A-Remote +// +// BlackHole audio routing integration for digital modes +// + +import Foundation +import AVFoundation + +// MARK: - Audio Device + +struct AudioDevice: Identifiable, Hashable { + let id: AudioDeviceID + let name: String + let uid: String + let isInput: Bool + let isOutput: Bool + let isBlackHole: Bool + + var displayName: String { + if isBlackHole { + return "\(name) (Virtual)" + } + return name + } +} + +// MARK: - Audio Router + +class AudioRouter: ObservableObject { + + // MARK: - Published Properties + + @Published var inputDevices: [AudioDevice] = [] + @Published var outputDevices: [AudioDevice] = [] + + @Published var selectedInputDevice: AudioDeviceID? + @Published var selectedOutputDevice: AudioDeviceID? + + @Published var blackHoleDevice: AudioDevice? + @Published var ft991aDevice: AudioDevice? + + @Published var isBlackHoleInstalled = false + @Published var lastError: String? + + // MARK: - Initialization + + init() { + refreshDevices() + } + + // MARK: - Device Discovery + + func refreshDevices() { + inputDevices = [] + outputDevices = [] + + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var dataSize: UInt32 = 0 + var status = AudioObjectGetPropertyDataSize( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, nil, + &dataSize + ) + + guard status == noErr else { + lastError = "Fehler beim Abrufen der Audio-Geräte" + return + } + + let deviceCount = Int(dataSize) / MemoryLayout.size + var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount) + + status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, nil, + &dataSize, + &deviceIDs + ) + + guard status == noErr else { + lastError = "Fehler beim Laden der Audio-Geräte" + return + } + + for deviceID in deviceIDs { + if let device = createAudioDevice(from: deviceID) { + if device.isInput { + inputDevices.append(device) + } + if device.isOutput { + outputDevices.append(device) + } + + // Detect BlackHole + if device.isBlackHole && blackHoleDevice == nil { + blackHoleDevice = device + isBlackHoleInstalled = true + } + + // Detect FT-991A (usually shows as "USB Audio CODEC") + if device.name.contains("USB Audio") || device.name.contains("FT-991") { + ft991aDevice = device + } + } + } + + Logger.shared.log("Found \(inputDevices.count) input and \(outputDevices.count) output devices", level: .debug) + + if isBlackHoleInstalled { + Logger.shared.log("BlackHole detected: \(blackHoleDevice?.name ?? "Unknown")", level: .info) + } + } + + private func createAudioDevice(from deviceID: AudioDeviceID) -> AudioDevice? { + // Get device name + var name: CFString = "" as CFString + var nameSize = UInt32(MemoryLayout.size) + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceNameCFString, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &nameSize, &name) + guard status == noErr else { return nil } + + // Get device UID + var uid: CFString = "" as CFString + var uidSize = UInt32(MemoryLayout.size) + propertyAddress.mSelector = kAudioDevicePropertyDeviceUID + + status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &uidSize, &uid) + let deviceUID = status == noErr ? uid as String : "" + + // Check for input channels + var inputSize: UInt32 = 0 + propertyAddress.mSelector = kAudioDevicePropertyStreamConfiguration + propertyAddress.mScope = kAudioDevicePropertyScopeInput + + _ = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &inputSize) + let hasInput = inputSize > 0 + + // Check for output channels + var outputSize: UInt32 = 0 + propertyAddress.mScope = kAudioDevicePropertyScopeOutput + + _ = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &outputSize) + let hasOutput = outputSize > 0 + + let deviceName = name as String + let isBlackHole = deviceName.lowercased().contains("blackhole") + + return AudioDevice( + id: deviceID, + name: deviceName, + uid: deviceUID, + isInput: hasInput, + isOutput: hasOutput, + isBlackHole: isBlackHole + ) + } + + // MARK: - Device Selection + + func selectInputDevice(_ device: AudioDevice) { + selectedInputDevice = device.id + Logger.shared.log("Selected input device: \(device.name)", level: .info) + } + + func selectOutputDevice(_ device: AudioDevice) { + selectedOutputDevice = device.id + Logger.shared.log("Selected output device: \(device.name)", level: .info) + } + + // MARK: - BlackHole Setup + + func configureForDigitalModes() -> Bool { + guard isBlackHoleInstalled, let blackHole = blackHoleDevice else { + lastError = "BlackHole ist nicht installiert" + return false + } + + // Route: FT-991A USB Audio → BlackHole → Digital Mode App + // Route back: Digital Mode App → BlackHole → FT-991A USB Audio + + if let ft991a = ft991aDevice { + selectedInputDevice = ft991a.id // FT-991A as input (RX audio) + selectedOutputDevice = blackHole.id // BlackHole as output (to digital mode app) + + Logger.shared.log("Configured for digital modes: \(ft991a.name) → \(blackHole.name)", level: .info) + return true + } else { + lastError = "FT-991A Audio-Gerät nicht gefunden" + return false + } + } + + // MARK: - System Audio + + func setSystemDefaultInput(_ deviceID: AudioDeviceID) { + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var deviceIDVar = deviceID + let status = AudioObjectSetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, nil, + UInt32(MemoryLayout.size), + &deviceIDVar + ) + + if status != noErr { + lastError = "Fehler beim Setzen des Standard-Eingangs" + } + } + + func setSystemDefaultOutput(_ deviceID: AudioDeviceID) { + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var deviceIDVar = deviceID + let status = AudioObjectSetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, nil, + UInt32(MemoryLayout.size), + &deviceIDVar + ) + + if status != noErr { + lastError = "Fehler beim Setzen des Standard-Ausgangs" + } + } +} diff --git a/FT991A-Remote/FT991A-Remote/Services/CATProtocol.swift b/FT991A-Remote/FT991A-Remote/Services/CATProtocol.swift new file mode 100644 index 0000000..ab057d6 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Services/CATProtocol.swift @@ -0,0 +1,442 @@ +// +// CATProtocol.swift +// FT991A-Remote +// +// CAT Protocol handler for FT-991A communication +// + +import Foundation +import Combine + +// MARK: - CAT Protocol + +class CATProtocol: ObservableObject { + + // MARK: - Published Properties + + @Published var radioState = RadioState() + @Published var isPolling = false + @Published var lastCommandTime: Date? + @Published var pendingCommands: Int = 0 + + // Debug console + @Published var commandHistory: [CommandLogEntry] = [] + + // MARK: - Private Properties + + private let serialManager: SerialPortManager + private var responseQueue: [CATResponse] = [] + private var pollingTimer: Timer? + private var cancellables = Set() + + private let commandQueue = DispatchQueue(label: "cat.command", qos: .userInitiated) + private var commandSemaphore = DispatchSemaphore(value: 1) + + // Polling intervals + private let fastPollInterval: TimeInterval = 0.1 // 100ms for meters + private let slowPollInterval: TimeInterval = 0.5 // 500ms for frequency/mode + + // MARK: - Initialization + + init(serialManager: SerialPortManager) { + self.serialManager = serialManager + + serialManager.onDataReceived = { [weak self] data in + self?.handleReceivedData(data) + } + + serialManager.onConnectionChanged = { [weak self] connected in + if connected { + self?.startPolling() + self?.requestInitialState() + } else { + self?.stopPolling() + } + } + } + + // MARK: - Command Sending + + func send(_ command: CATCommand) { + serialManager.send(command) + lastCommandTime = Date() + + // Log command + let entry = CommandLogEntry( + timestamp: Date(), + direction: .sent, + command: command.command, + description: command.description + ) + DispatchQueue.main.async { + self.commandHistory.append(entry) + if self.commandHistory.count > 500 { + self.commandHistory.removeFirst(100) + } + } + } + + func sendRaw(_ command: String) { + let catCommand = CATCommand(command, description: "Manual: \(command)") + send(catCommand) + } + + // MARK: - Response Handling + + private func handleReceivedData(_ data: Data) { + guard let responseString = String(data: data, encoding: .ascii) else { return } + + let response = CATResponse(rawData: responseString) + + // Log response + let entry = CommandLogEntry( + timestamp: Date(), + direction: .received, + command: response.rawData, + description: parseResponseDescription(response) + ) + DispatchQueue.main.async { + self.commandHistory.append(entry) + } + + // Update radio state + updateState(from: response) + } + + private func updateState(from response: CATResponse) { + DispatchQueue.main.async { + switch response.command { + case "FA": + if let freq = response.frequency { + self.radioState.vfoAFrequency = freq + } + case "FB": + if let freq = response.frequency { + self.radioState.vfoBFrequency = freq + } + case "VS": + if let vfo = response.vfo { + self.radioState.activeVFO = vfo + } + case "MD": + if let mode = response.mode { + self.radioState.mode = mode + } + case "AG": + if let level = response.levelValue { + self.radioState.afGain = level + } + case "RG": + if let level = response.levelValue { + self.radioState.rfGain = level + } + case "SQ": + if let level = response.levelValue { + self.radioState.squelch = level + } + case "MG": + if let level = response.levelValue { + self.radioState.micGain = level + } + case "PC": + if let power = response.levelValue { + self.radioState.power = power + } + case "SM": + if let meter = response.sMeter { + self.radioState.sMeter = meter + } + case "RM": + // RM1 = power, RM6 = SWR + if let level = response.levelValue { + if response.value.hasPrefix("1") { + self.radioState.powerMeter = level + } else if response.value.hasPrefix("6") { + self.radioState.swrMeter = level + } + } + case "NB": + if let enabled = response.boolValue { + self.radioState.noiseBlanker = enabled + } + case "NR": + if let enabled = response.boolValue { + self.radioState.noiseReduction = enabled + } + case "BC": + if let enabled = response.boolValue { + self.radioState.dnf = enabled + } + case "FT": + if let enabled = response.boolValue { + self.radioState.split = enabled + } + case "TX": + if response.value == "0" { + self.radioState.isTransmitting = false + } else if response.value == "1" || response.value == "2" { + self.radioState.isTransmitting = true + } + default: + break + } + } + } + + private func parseResponseDescription(_ response: CATResponse) -> String { + switch response.command { + case "FA": + if let freq = response.frequency { + return "VFO-A: \(radioState.formatFrequency(freq)) Hz" + } + case "FB": + if let freq = response.frequency { + return "VFO-B: \(radioState.formatFrequency(freq)) Hz" + } + case "MD": + if let mode = response.mode { + return "Mode: \(mode.rawValue)" + } + case "SM": + if let meter = response.sMeter { + return "S-Meter: \(meter)" + } + case "ID": + if response.isFT991A { + return "FT-991A identified" + } + default: + break + } + return response.value + } + + // MARK: - Polling + + func startPolling() { + guard !isPolling else { return } + isPolling = true + + // Fast polling for meters + pollingTimer = Timer.scheduledTimer(withTimeInterval: fastPollInterval, repeats: true) { [weak self] _ in + self?.pollMeters() + } + + // Start slow polling for frequency/mode + Timer.scheduledTimer(withTimeInterval: slowPollInterval, repeats: true) { [weak self] _ in + self?.pollStatus() + } + } + + func stopPolling() { + pollingTimer?.invalidate() + pollingTimer = nil + isPolling = false + } + + private func pollMeters() { + send(CAT.readSMeter) + if radioState.isTransmitting { + send(CAT.readPowerMeter) + send(CAT.readSWRMeter) + } + } + + private func pollStatus() { + send(CAT.readVFOA) + send(CAT.readVFOB) + send(CAT.readActiveVFO) + send(CAT.readMode) + } + + // MARK: - Initial State + + private func requestInitialState() { + // Verify radio identity + send(CAT.readID) + + // Request all current values + send(CAT.readVFOA) + send(CAT.readVFOB) + send(CAT.readActiveVFO) + send(CAT.readMode) + send(CAT.readAFGain) + send(CAT.readRFGain) + send(CAT.readSquelch) + send(CAT.readMICGain) + send(CAT.readPower) + send(CAT.readNB) + send(CAT.readNR) + send(CAT.readDNF) + send(CAT.readSplit) + send(CAT.readSMeter) + } + + // MARK: - Radio Control + + func setFrequency(_ frequency: Int, vfo: VFO = .a) { + if vfo == .a { + send(CAT.setVFOA(frequency)) + radioState.vfoAFrequency = frequency + } else { + send(CAT.setVFOB(frequency)) + radioState.vfoBFrequency = frequency + } + } + + func changeFrequency(by step: Int) { + let newFreq = radioState.activeFrequency + step + setFrequency(newFreq, vfo: radioState.activeVFO) + } + + func setMode(_ mode: OperatingMode) { + send(CAT.setMode(mode)) + radioState.mode = mode + } + + func setAFGain(_ value: Int) { + send(CAT.setAFGain(value)) + radioState.afGain = value + } + + func setRFGain(_ value: Int) { + send(CAT.setRFGain(value)) + radioState.rfGain = value + } + + func setSquelch(_ value: Int) { + send(CAT.setSquelch(value)) + radioState.squelch = value + } + + func setMICGain(_ value: Int) { + send(CAT.setMICGain(value)) + radioState.micGain = value + } + + func setPower(_ value: Int) { + send(CAT.setPower(value)) + radioState.power = value + } + + func toggleNB() { + let newValue = !radioState.noiseBlanker + send(CAT.setNB(newValue)) + radioState.noiseBlanker = newValue + } + + func toggleNR() { + let newValue = !radioState.noiseReduction + send(CAT.setNR(newValue)) + radioState.noiseReduction = newValue + } + + func toggleDNF() { + let newValue = !radioState.dnf + send(CAT.setDNF(newValue)) + radioState.dnf = newValue + } + + func toggleSplit() { + let newValue = !radioState.split + send(CAT.setSplit(newValue)) + radioState.split = newValue + } + + func selectVFO(_ vfo: VFO) { + if vfo == .a { + send(CAT.selectVFOA) + } else { + send(CAT.selectVFOB) + } + radioState.activeVFO = vfo + } + + func swapVFO() { + send(CAT.swapVFO) + let temp = radioState.vfoAFrequency + radioState.vfoAFrequency = radioState.vfoBFrequency + radioState.vfoBFrequency = temp + } + + func equalizeVFO() { + // Set VFO-B to VFO-A frequency + send(CAT.setVFOB(radioState.vfoAFrequency)) + radioState.vfoBFrequency = radioState.vfoAFrequency + } + + func startATUTune() { + send(CAT.startATUTune) + } + + // MARK: - PTT Control + + func startTransmit(dataMode: Bool = false) { + if dataMode { + send(CAT.txOnData) + } else { + send(CAT.txOn) + } + radioState.isTransmitting = true + } + + func stopTransmit() { + send(CAT.txOff) + radioState.isTransmitting = false + } + + func toggleTransmit(dataMode: Bool = false) { + if radioState.isTransmitting { + stopTransmit() + } else { + startTransmit(dataMode: dataMode) + } + } + + // MARK: - Band Selection + + func selectBand(_ band: Band) { + setFrequency(band.defaultFrequency, vfo: radioState.activeVFO) + } + + // MARK: - Debug + + func clearCommandHistory() { + commandHistory.removeAll() + } +} + +// MARK: - Command Log Entry + +struct CommandLogEntry: Identifiable { + let id = UUID() + let timestamp: Date + let direction: Direction + let command: String + let description: String + + enum Direction { + case sent + case received + + var symbol: String { + switch self { + case .sent: return "→" + case .received: return "←" + } + } + + var color: String { + switch self { + case .sent: return "blue" + case .received: return "green" + } + } + } + + var timeString: String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: timestamp) + } +} diff --git a/FT991A-Remote/FT991A-Remote/Services/CSVManager.swift b/FT991A-Remote/FT991A-Remote/Services/CSVManager.swift new file mode 100644 index 0000000..b113e97 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Services/CSVManager.swift @@ -0,0 +1,240 @@ +// +// CSVManager.swift +// FT991A-Remote +// +// CSV Log file management +// + +import Foundation + +// MARK: - CSV Manager + +class CSVManager: ObservableObject { + + // MARK: - Published Properties + + @Published var logEntries: [QSOEntry] = [] + @Published var currentLogFile: URL? + @Published var lastError: String? + @Published var isSaving = false + + // MARK: - Properties + + private let fileManager = FileManager.default + private var logDirectory: URL + + // MARK: - Initialization + + init(logDirectory: String = "~/Documents/FT991A-Logs/") { + let expandedPath = (logDirectory as NSString).expandingTildeInPath + self.logDirectory = URL(fileURLWithPath: expandedPath, isDirectory: true) + + ensureDirectoryExists() + } + + // MARK: - Directory Management + + private func ensureDirectoryExists() { + if !fileManager.fileExists(atPath: logDirectory.path) { + do { + try fileManager.createDirectory(at: logDirectory, withIntermediateDirectories: true) + Logger.shared.log("Created log directory: \(logDirectory.path)", level: .info) + } catch { + lastError = "Konnte Log-Verzeichnis nicht erstellen: \(error.localizedDescription)" + Logger.shared.log(lastError!, level: .error) + } + } + } + + func setLogDirectory(_ path: String) { + let expandedPath = (path as NSString).expandingTildeInPath + logDirectory = URL(fileURLWithPath: expandedPath, isDirectory: true) + ensureDirectoryExists() + } + + // MARK: - File Operations + + func createNewLogFile(name: String? = nil) -> URL { + let fileName = name ?? generateLogFileName() + let fileURL = logDirectory.appendingPathComponent(fileName) + + // Write header + do { + try QSOEntry.csvHeader.appending("\n").write(to: fileURL, atomically: true, encoding: .utf8) + currentLogFile = fileURL + Logger.shared.log("Created new log file: \(fileURL.lastPathComponent)", level: .info) + } catch { + lastError = "Konnte Log-Datei nicht erstellen: \(error.localizedDescription)" + Logger.shared.log(lastError!, level: .error) + } + + return fileURL + } + + private func generateLogFileName() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HHmmss" + return "QSO_Log_\(formatter.string(from: Date())).csv" + } + + func openLogFile(_ url: URL) -> Bool { + guard fileManager.fileExists(atPath: url.path) else { + lastError = "Datei existiert nicht: \(url.lastPathComponent)" + return false + } + + do { + let content = try String(contentsOf: url, encoding: .utf8) + logEntries = parseCSV(content) + currentLogFile = url + Logger.shared.log("Opened log file: \(url.lastPathComponent) with \(logEntries.count) entries", level: .info) + return true + } catch { + lastError = "Konnte Datei nicht lesen: \(error.localizedDescription)" + Logger.shared.log(lastError!, level: .error) + return false + } + } + + // MARK: - Parsing + + private func parseCSV(_ content: String) -> [QSOEntry] { + var entries: [QSOEntry] = [] + let lines = content.components(separatedBy: .newlines) + + for (index, line) in lines.enumerated() { + // Skip header and empty lines + guard index > 0, !line.trimmingCharacters(in: .whitespaces).isEmpty else { continue } + + if let entry = QSOEntry.from(csvLine: line) { + entries.append(entry) + } + } + + return entries + } + + // MARK: - Entry Management + + func addEntry(_ entry: QSOEntry) { + logEntries.append(entry) + saveCurrentLog() + } + + func updateEntry(_ entry: QSOEntry) { + if let index = logEntries.firstIndex(where: { $0.id == entry.id }) { + logEntries[index] = entry + saveCurrentLog() + } + } + + func deleteEntry(_ entry: QSOEntry) { + logEntries.removeAll { $0.id == entry.id } + saveCurrentLog() + } + + func deleteEntries(at offsets: IndexSet) { + logEntries.remove(atOffsets: offsets) + saveCurrentLog() + } + + // MARK: - Saving + + func saveCurrentLog() { + guard let fileURL = currentLogFile else { + // Create new file if none exists + _ = createNewLogFile() + guard let newURL = currentLogFile else { return } + saveToFile(newURL) + return + } + + saveToFile(fileURL) + } + + private func saveToFile(_ url: URL) { + isSaving = true + + var content = QSOEntry.csvHeader + "\n" + for entry in logEntries { + content += entry.csvLine + "\n" + } + + do { + try content.write(to: url, atomically: true, encoding: .utf8) + Logger.shared.log("Saved \(logEntries.count) entries to \(url.lastPathComponent)", level: .debug) + } catch { + lastError = "Fehler beim Speichern: \(error.localizedDescription)" + Logger.shared.log(lastError!, level: .error) + } + + isSaving = false + } + + func exportToFile(_ url: URL) -> Bool { + var content = QSOEntry.csvHeader + "\n" + for entry in logEntries { + content += entry.csvLine + "\n" + } + + do { + try content.write(to: url, atomically: true, encoding: .utf8) + Logger.shared.log("Exported \(logEntries.count) entries to \(url.path)", level: .info) + return true + } catch { + lastError = "Export fehlgeschlagen: \(error.localizedDescription)" + Logger.shared.log(lastError!, level: .error) + return false + } + } + + // MARK: - File Listing + + func listLogFiles() -> [URL] { + do { + let files = try fileManager.contentsOfDirectory( + at: logDirectory, + includingPropertiesForKeys: [.creationDateKey], + options: [.skipsHiddenFiles] + ) + return files + .filter { $0.pathExtension.lowercased() == "csv" } + .sorted { url1, url2 in + let date1 = (try? url1.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date.distantPast + let date2 = (try? url2.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date.distantPast + return date1 > date2 + } + } catch { + Logger.shared.log("Error listing log files: \(error)", level: .error) + return [] + } + } + + // MARK: - Statistics + + var totalQSOs: Int { + logEntries.count + } + + var uniqueCallsigns: Int { + Set(logEntries.map { $0.callsign.uppercased() }).count + } + + var bandStatistics: [String: Int] { + var stats: [String: Int] = [:] + for entry in logEntries { + let band = entry.bandDisplay + stats[band, default: 0] += 1 + } + return stats + } + + var modeStatistics: [String: Int] { + var stats: [String: Int] = [:] + for entry in logEntries { + let mode = entry.mode.rawValue + stats[mode, default: 0] += 1 + } + return stats + } +} diff --git a/FT991A-Remote/FT991A-Remote/Services/SerialPortManager.swift b/FT991A-Remote/FT991A-Remote/Services/SerialPortManager.swift new file mode 100644 index 0000000..b738be9 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Services/SerialPortManager.swift @@ -0,0 +1,475 @@ +// +// SerialPortManager.swift +// FT991A-Remote +// +// USB Serial communication for FT-991A (Silicon Labs CP210x) +// + +import Foundation +import IOKit +import IOKit.serial + +// MARK: - Serial Port + +struct SerialPort: Identifiable, Hashable { + let id: String + let path: String + let name: String + let vendorID: Int? + let productID: Int? + let isFT991A: Bool + + init(path: String, name: String, vendorID: Int? = nil, productID: Int? = nil, isFT991A: Bool = false) { + self.id = path + self.path = path + self.name = name + self.vendorID = vendorID + self.productID = productID + self.isFT991A = isFT991A + } +} + +// MARK: - Connection State + +enum ConnectionState: Equatable { + case disconnected + case connecting + case connected + case error(String) + + var isConnected: Bool { + if case .connected = self { return true } + return false + } + + var displayString: String { + switch self { + case .disconnected: return "Getrennt" + case .connecting: return "Verbinde..." + case .connected: return "Verbunden" + case .error(let msg): return "Fehler: \(msg)" + } + } +} + +// MARK: - Serial Port Manager + +class SerialPortManager: ObservableObject { + + // MARK: - Published Properties + + @Published var connectionState: ConnectionState = .disconnected + @Published var availablePorts: [SerialPort] = [] + @Published var selectedPortPath: String = "" + @Published var baudRate: Int = 38400 + @Published var lastError: String? + + @Published var bytesSent: UInt64 = 0 + @Published var bytesReceived: UInt64 = 0 + + // MARK: - Callbacks + + var onDataReceived: ((Data) -> Void)? + var onConnectionChanged: ((Bool) -> Void)? + + // MARK: - Private Properties + + private var fileDescriptor: Int32 = -1 + private let writeQueue = DispatchQueue(label: "ft991a.serial.write", qos: .userInteractive) + private let readQueue = DispatchQueue(label: "ft991a.serial.read", qos: .userInteractive) + + private var readBuffer = Data() + private var isReading = false + private var readSource: DispatchSourceRead? + + // Auto-reconnect + private var reconnectTimer: Timer? + private var shouldReconnect = false + + // MARK: - Constants + + private static let CP210X_VENDOR_ID = 0x10C4 // Silicon Labs + private static let CP210X_PRODUCT_ID = 0xEA60 // CP210x + + // MARK: - Initialization + + init() { + refreshPorts() + } + + deinit { + disconnect() + } + + // MARK: - Port Discovery + + func refreshPorts() { + availablePorts = findSerialPorts() + + // Auto-select FT-991A port (CP210x / SLAB) + if let ft991a = availablePorts.first(where: { $0.isFT991A }) { + selectedPortPath = ft991a.path + } else if selectedPortPath.isEmpty, let first = availablePorts.first { + selectedPortPath = first.path + } + } + + private func findSerialPorts() -> [SerialPort] { + var ports: [SerialPort] = [] + var iterator: io_iterator_t = 0 + + let matching = IOServiceMatching(kIOSerialBSDServiceValue) + guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else { + return ports + } + + var service = IOIteratorNext(iterator) + while service != 0 { + defer { + IOObjectRelease(service) + service = IOIteratorNext(iterator) + } + + guard let path = IORegistryEntryCreateCFProperty( + service, kIOCalloutDeviceKey as CFString, kCFAllocatorDefault, 0 + )?.takeRetainedValue() as? String else { continue } + + // Only callout devices (cu.*) + guard path.contains("cu.") else { continue } + + var name = path.components(separatedBy: "/").last ?? "Unknown" + var vendorID: Int? + var productID: Int? + var isFT991A = false + + // Check for Silicon Labs CP210x (FT-991A uses this) + if path.contains("SLAB_USBtoUART") || path.contains("CP210") { + isFT991A = true + name = "FT-991A (CP210x)" + } + + // Walk USB registry for device info + var parent: io_object_t = 0 + var current = service + IOObjectRetain(current) + + for _ in 0..<10 { + if IORegistryEntryGetParentEntry(current, kIOServicePlane, &parent) != KERN_SUCCESS { break } + IOObjectRelease(current) + current = parent + + if let vid = IORegistryEntryCreateCFProperty(current, "idVendor" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int { + vendorID = vid + } + if let pid = IORegistryEntryCreateCFProperty(current, "idProduct" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int { + productID = pid + } + if let usbName = IORegistryEntryCreateCFProperty(current, "USB Product Name" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? String { + if usbName.contains("CP210") || usbName.contains("UART") { + name = usbName + } + } + + // Silicon Labs CP210x = likely FT-991A + if vendorID == Self.CP210X_VENDOR_ID && productID == Self.CP210X_PRODUCT_ID { + isFT991A = true + name = "FT-991A (CP210x)" + } + + if vendorID != nil && productID != nil { break } + } + IOObjectRelease(current) + + ports.append(SerialPort( + path: path, + name: name, + vendorID: vendorID, + productID: productID, + isFT991A: isFT991A + )) + } + + IOObjectRelease(iterator) + + // Sort: FT-991A first, then alphabetically + return ports.sorted { ($0.isFT991A ? 0 : 1, $0.name) < ($1.isFT991A ? 0 : 1, $1.name) } + } + + // MARK: - Connection + + func connect() { + guard !selectedPortPath.isEmpty else { + connectionState = .error("Kein Port ausgewählt") + return + } + + connectionState = .connecting + + // Open port + fileDescriptor = open(selectedPortPath, O_RDWR | O_NOCTTY | O_NONBLOCK) + guard fileDescriptor != -1 else { + let error = String(cString: strerror(errno)) + connectionState = .error(error) + lastError = error + return + } + + // Configure serial port + if !configurePort() { + close(fileDescriptor) + fileDescriptor = -1 + return + } + + // Clear buffers + tcflush(fileDescriptor, TCIOFLUSH) + readBuffer.removeAll() + + // Start reading + startReading() + + connectionState = .connected + lastError = nil + onConnectionChanged?(true) + + Logger.shared.log("Connected to \(selectedPortPath) at \(baudRate) baud", level: .info) + } + + private func configurePort() -> Bool { + var options = termios() + if tcgetattr(fileDescriptor, &options) != 0 { + connectionState = .error("Fehler beim Lesen der Port-Einstellungen") + return false + } + + // Set baud rate + let speed = baudRateToSpeed(baudRate) + cfsetispeed(&options, speed) + cfsetospeed(&options, speed) + + // 8N1 configuration + options.c_cflag &= ~UInt(PARENB) // No parity + options.c_cflag &= ~UInt(CSTOPB) // 1 stop bit + options.c_cflag &= ~UInt(CSIZE) // Clear size bits + options.c_cflag |= UInt(CS8) // 8 data bits + + // Enable receiver, ignore modem control + options.c_cflag |= UInt(CREAD | CLOCAL) + + // No hardware flow control + options.c_cflag &= ~UInt(CRTSCTS) + + // Raw mode (no processing) + options.c_lflag &= ~UInt(ICANON | ECHO | ECHOE | ISIG) + options.c_oflag &= ~UInt(OPOST) + options.c_iflag &= ~UInt(IXON | IXOFF | IXANY | ICRNL | INLCR | IGNBRK) + + // Timeouts + options.c_cc.16 = 0 // VMIN - minimum characters + options.c_cc.17 = 10 // VTIME - timeout in 0.1s + + if tcsetattr(fileDescriptor, TCSANOW, &options) != 0 { + connectionState = .error("Fehler beim Setzen der Port-Einstellungen") + return false + } + + return true + } + + private func baudRateToSpeed(_ rate: Int) -> speed_t { + switch rate { + case 4800: return speed_t(B4800) + case 9600: return speed_t(B9600) + case 19200: return speed_t(B19200) + case 38400: return speed_t(B38400) + case 57600: return speed_t(B57600) + case 115200: return speed_t(B115200) + default: return speed_t(B38400) + } + } + + func disconnect() { + stopReading() + stopReconnectTimer() + + if fileDescriptor != -1 { + close(fileDescriptor) + fileDescriptor = -1 + } + + connectionState = .disconnected + onConnectionChanged?(false) + + Logger.shared.log("Disconnected", level: .info) + } + + func toggleConnection() { + if connectionState.isConnected { + disconnect() + } else { + connect() + } + } + + // MARK: - Reading + + private func startReading() { + guard fileDescriptor != -1 else { return } + + isReading = true + + readSource = DispatchSource.makeReadSource(fileDescriptor: fileDescriptor, queue: readQueue) + readSource?.setEventHandler { [weak self] in + self?.readAvailableData() + } + readSource?.setCancelHandler { [weak self] in + self?.isReading = false + } + readSource?.resume() + } + + private func stopReading() { + readSource?.cancel() + readSource = nil + isReading = false + } + + private func readAvailableData() { + guard fileDescriptor != -1 else { return } + + var buffer = [UInt8](repeating: 0, count: 256) + let bytesRead = read(fileDescriptor, &buffer, buffer.count) + + guard bytesRead > 0 else { + if bytesRead < 0 && errno != EAGAIN { + DispatchQueue.main.async { + self.handleReadError() + } + } + return + } + + let data = Data(buffer[0.. Int in + guard let base = buffer.baseAddress else { return -1 } + return write(self.fileDescriptor, base, data.count) + } + + if written > 0 { + DispatchQueue.main.async { + self.bytesSent += UInt64(written) + } + + if let command = String(data: data, encoding: .ascii) { + Logger.shared.log("TX: \(command.trimmingCharacters(in: .whitespaces))", level: .debug) + } + } else if written < 0 { + DispatchQueue.main.async { + self.handleWriteError() + } + } + } + } + + func send(_ command: CATCommand) { + send(command.data) + } + + func sendString(_ string: String) { + if let data = string.data(using: .ascii) { + send(data) + } + } + + private func handleWriteError() { + let error = String(cString: strerror(errno)) + connectionState = .error(error) + lastError = error + } + + // MARK: - Auto-Reconnect + + func enableAutoReconnect(_ enabled: Bool) { + shouldReconnect = enabled + if !enabled { + stopReconnectTimer() + } + } + + private func startReconnectTimer() { + stopReconnectTimer() + + reconnectTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + + Logger.shared.log("Attempting to reconnect...", level: .info) + + self.refreshPorts() + if self.availablePorts.contains(where: { $0.path == self.selectedPortPath }) { + self.connect() + if self.connectionState.isConnected { + self.stopReconnectTimer() + } + } + } + } + + private func stopReconnectTimer() { + reconnectTimer?.invalidate() + reconnectTimer = nil + } + + // MARK: - Statistics + + func resetStatistics() { + bytesSent = 0 + bytesReceived = 0 + } + + var isConnected: Bool { + connectionState.isConnected + } +} diff --git a/FT991A-Remote/FT991A-Remote/Utilities/Localization/de.lproj/Localizable.strings b/FT991A-Remote/FT991A-Remote/Utilities/Localization/de.lproj/Localizable.strings new file mode 100644 index 0000000..f0339c2 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Utilities/Localization/de.lproj/Localizable.strings @@ -0,0 +1,126 @@ +/* German Localization for FT991A-Remote */ + +/* Connection */ +"Connected" = "Verbunden"; +"Disconnected" = "Getrennt"; +"Connecting" = "Verbinde..."; +"Connect" = "Verbinden"; +"Disconnect" = "Trennen"; +"Select Port" = "Port wählen..."; +"Refresh Ports" = "Ports aktualisieren"; +"No Port Selected" = "Kein Port ausgewählt"; +"Connection Error" = "Verbindungsfehler"; +"Auto Reconnect" = "Auto-Reconnect"; + +/* Frequency */ +"Frequency" = "Frequenz"; +"Step" = "Schritt"; +"VFO-A" = "VFO-A"; +"VFO-B" = "VFO-B"; +"Swap VFO" = "VFO tauschen"; +"Band" = "Band"; + +/* Modes */ +"Mode" = "Betriebsart"; +"Filter Width" = "Filterbreite"; +"Filter Shift" = "Filter-Shift"; + +/* Levels */ +"Levels" = "Pegel"; +"AF Gain" = "AF Verstärkung"; +"RF Gain" = "RF Verstärkung"; +"Squelch" = "Squelch"; +"MIC Gain" = "MIC Verstärkung"; +"Power" = "Leistung"; + +/* Functions */ +"Functions" = "Funktionen"; +"Noise Blanker" = "Störaustaster"; +"Noise Reduction" = "Rauschminderung"; +"ATU Tune" = "Tuner abstimmen"; +"Split" = "Split-Betrieb"; + +/* Metering */ +"Metering" = "Messwerte"; +"S-Meter" = "S-Meter"; +"Power Meter" = "Leistungsmesser"; +"SWR Meter" = "SWR-Meter"; + +/* PTT */ +"PTT" = "PTT"; +"Transmit" = "Senden"; +"Receive" = "Empfangen"; +"TX" = "TX"; +"RX" = "RX"; +"Hold Shift for PTT" = "Shift-Taste gedrückt halten = PTT"; + +/* Log */ +"QSO Log" = "QSO Log"; +"Add QSO" = "QSO hinzufügen"; +"Edit QSO" = "QSO bearbeiten"; +"Delete QSO" = "QSO löschen"; +"Callsign" = "Rufzeichen"; +"Date" = "Datum"; +"Time" = "Zeit"; +"RST Sent" = "RST gesendet"; +"RST Received" = "RST empfangen"; +"Name" = "Name"; +"QTH" = "QTH"; +"Locator" = "Locator"; +"Notes" = "Notizen"; +"Search" = "Suchen..."; +"Export" = "Exportieren"; +"Import" = "Importieren"; + +/* Debug */ +"CAT Console" = "CAT Konsole"; +"Send Command" = "Befehl senden"; +"Clear History" = "Verlauf löschen"; +"Auto Scroll" = "Auto-Scroll"; +"Bytes Sent" = "Bytes gesendet"; +"Bytes Received" = "Bytes empfangen"; +"Commands" = "Befehle"; + +/* Settings */ +"Settings" = "Einstellungen"; +"Connection" = "Verbindung"; +"Interface" = "Oberfläche"; +"Audio" = "Audio"; +"Keyboard" = "Tastatur"; +"Logging" = "Logging"; +"UI Style" = "UI-Stil"; +"Modern" = "Modern"; +"Front Panel" = "Frontpanel"; +"Language" = "Sprache"; +"German" = "Deutsch"; +"English" = "English"; +"Frequency Step" = "Frequenzschritt"; +"Log Directory" = "Log-Verzeichnis"; +"Auto Save" = "Automatisch speichern"; +"Reset to Defaults" = "Auf Standard zurücksetzen"; + +/* Audio */ +"Audio Routing" = "Audio-Routing"; +"Input Device" = "Eingabegerät"; +"Output Device" = "Ausgabegerät"; +"BlackHole Status" = "BlackHole Status"; +"Installed" = "Installiert"; +"Not Found" = "Nicht gefunden"; +"Configure for Digital Modes" = "Für Digimodes konfigurieren"; +"Use BlackHole" = "BlackHole verwenden"; + +/* Menu */ +"Radio" = "Radio"; +"View" = "Ansicht"; +"Show Debug Panel" = "Debug-Panel anzeigen"; +"Show Log Panel" = "Log-Panel anzeigen"; +"Open Main Window" = "Hauptfenster öffnen"; +"Quit" = "Beenden"; + +/* Errors */ +"Error" = "Fehler"; +"Failed to connect" = "Verbindung fehlgeschlagen"; +"Failed to open port" = "Port konnte nicht geöffnet werden"; +"No serial ports found" = "Keine seriellen Ports gefunden"; +"Save failed" = "Speichern fehlgeschlagen"; +"Export failed" = "Export fehlgeschlagen"; diff --git a/FT991A-Remote/FT991A-Remote/Utilities/Localization/en.lproj/Localizable.strings b/FT991A-Remote/FT991A-Remote/Utilities/Localization/en.lproj/Localizable.strings new file mode 100644 index 0000000..3991957 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Utilities/Localization/en.lproj/Localizable.strings @@ -0,0 +1,126 @@ +/* English Localization for FT991A-Remote */ + +/* Connection */ +"Connected" = "Connected"; +"Disconnected" = "Disconnected"; +"Connecting" = "Connecting..."; +"Connect" = "Connect"; +"Disconnect" = "Disconnect"; +"Select Port" = "Select Port..."; +"Refresh Ports" = "Refresh Ports"; +"No Port Selected" = "No Port Selected"; +"Connection Error" = "Connection Error"; +"Auto Reconnect" = "Auto Reconnect"; + +/* Frequency */ +"Frequency" = "Frequency"; +"Step" = "Step"; +"VFO-A" = "VFO-A"; +"VFO-B" = "VFO-B"; +"Swap VFO" = "Swap VFO"; +"Band" = "Band"; + +/* Modes */ +"Mode" = "Mode"; +"Filter Width" = "Filter Width"; +"Filter Shift" = "Filter Shift"; + +/* Levels */ +"Levels" = "Levels"; +"AF Gain" = "AF Gain"; +"RF Gain" = "RF Gain"; +"Squelch" = "Squelch"; +"MIC Gain" = "MIC Gain"; +"Power" = "Power"; + +/* Functions */ +"Functions" = "Functions"; +"Noise Blanker" = "Noise Blanker"; +"Noise Reduction" = "Noise Reduction"; +"ATU Tune" = "ATU Tune"; +"Split" = "Split"; + +/* Metering */ +"Metering" = "Metering"; +"S-Meter" = "S-Meter"; +"Power Meter" = "Power Meter"; +"SWR Meter" = "SWR Meter"; + +/* PTT */ +"PTT" = "PTT"; +"Transmit" = "Transmit"; +"Receive" = "Receive"; +"TX" = "TX"; +"RX" = "RX"; +"Hold Shift for PTT" = "Hold Shift key for PTT"; + +/* Log */ +"QSO Log" = "QSO Log"; +"Add QSO" = "Add QSO"; +"Edit QSO" = "Edit QSO"; +"Delete QSO" = "Delete QSO"; +"Callsign" = "Callsign"; +"Date" = "Date"; +"Time" = "Time"; +"RST Sent" = "RST Sent"; +"RST Received" = "RST Received"; +"Name" = "Name"; +"QTH" = "QTH"; +"Locator" = "Locator"; +"Notes" = "Notes"; +"Search" = "Search..."; +"Export" = "Export"; +"Import" = "Import"; + +/* Debug */ +"CAT Console" = "CAT Console"; +"Send Command" = "Send Command"; +"Clear History" = "Clear History"; +"Auto Scroll" = "Auto Scroll"; +"Bytes Sent" = "Bytes Sent"; +"Bytes Received" = "Bytes Received"; +"Commands" = "Commands"; + +/* Settings */ +"Settings" = "Settings"; +"Connection" = "Connection"; +"Interface" = "Interface"; +"Audio" = "Audio"; +"Keyboard" = "Keyboard"; +"Logging" = "Logging"; +"UI Style" = "UI Style"; +"Modern" = "Modern"; +"Front Panel" = "Front Panel"; +"Language" = "Language"; +"German" = "German"; +"English" = "English"; +"Frequency Step" = "Frequency Step"; +"Log Directory" = "Log Directory"; +"Auto Save" = "Auto Save"; +"Reset to Defaults" = "Reset to Defaults"; + +/* Audio */ +"Audio Routing" = "Audio Routing"; +"Input Device" = "Input Device"; +"Output Device" = "Output Device"; +"BlackHole Status" = "BlackHole Status"; +"Installed" = "Installed"; +"Not Found" = "Not Found"; +"Configure for Digital Modes" = "Configure for Digital Modes"; +"Use BlackHole" = "Use BlackHole"; + +/* Menu */ +"Radio" = "Radio"; +"View" = "View"; +"Show Debug Panel" = "Show Debug Panel"; +"Show Log Panel" = "Show Log Panel"; +"Open Main Window" = "Open Main Window"; +"Quit" = "Quit"; + +/* Errors */ +"Error" = "Error"; +"Failed to connect" = "Failed to connect"; +"Failed to open port" = "Failed to open port"; +"No serial ports found" = "No serial ports found"; +"Save failed" = "Save failed"; +"Export failed" = "Export failed"; diff --git a/FT991A-Remote/FT991A-Remote/Utilities/Logger.swift b/FT991A-Remote/FT991A-Remote/Utilities/Logger.swift new file mode 100644 index 0000000..d7c6db6 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Utilities/Logger.swift @@ -0,0 +1,223 @@ +// +// Logger.swift +// FT991A-Remote +// +// Debug logging system +// + +import Foundation +import os.log + +// MARK: - Log Level + +enum LogLevel: String, Comparable { + case debug = "DEBUG" + case info = "INFO" + case warning = "WARN" + case error = "ERROR" + + var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .warning: return .default + case .error: return .error + } + } + + var symbol: String { + switch self { + case .debug: return "🔍" + case .info: return "ℹ️" + case .warning: return "⚠️" + case .error: return "❌" + } + } + + static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + let order: [LogLevel] = [.debug, .info, .warning, .error] + guard let lhsIndex = order.firstIndex(of: lhs), + let rhsIndex = order.firstIndex(of: rhs) else { return false } + return lhsIndex < rhsIndex + } +} + +// MARK: - Log Entry + +struct LogEntry: Identifiable { + let id = UUID() + let timestamp: Date + let level: LogLevel + let message: String + let file: String + let function: String + let line: Int + + var timeString: String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: timestamp) + } + + var shortFile: String { + URL(fileURLWithPath: file).lastPathComponent + } + + var formattedMessage: String { + "[\(timeString)] [\(level.rawValue)] \(message)" + } + + var detailedMessage: String { + "[\(timeString)] [\(level.rawValue)] [\(shortFile):\(line)] \(message)" + } +} + +// MARK: - Logger + +class Logger: ObservableObject { + + // MARK: - Singleton + + static let shared = Logger() + + // MARK: - Published Properties + + @Published var entries: [LogEntry] = [] + @Published var minimumLevel: LogLevel = .debug + @Published var isLoggingEnabled = true + + // MARK: - Private Properties + + private let osLog = OSLog(subsystem: "com.ft991a.remote", category: "General") + private let queue = DispatchQueue(label: "logger.queue", qos: .utility) + private let maxEntries = 1000 + + // File logging + private var logFileURL: URL? + private var logFileHandle: FileHandle? + + // MARK: - Initialization + + private init() { + setupFileLogging() + } + + deinit { + logFileHandle?.closeFile() + } + + // MARK: - Logging + + func log( + _ message: String, + level: LogLevel = .info, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + guard isLoggingEnabled, level >= minimumLevel else { return } + + let entry = LogEntry( + timestamp: Date(), + level: level, + message: message, + file: file, + function: function, + line: line + ) + + // Console output + queue.async { + os_log("%{public}@", log: self.osLog, type: level.osLogType, entry.formattedMessage) + #if DEBUG + print(entry.detailedMessage) + #endif + } + + // In-memory storage + DispatchQueue.main.async { + self.entries.append(entry) + if self.entries.count > self.maxEntries { + self.entries.removeFirst(100) + } + } + + // File logging + writeToFile(entry) + } + + // MARK: - Convenience Methods + + func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .debug, file: file, function: function, line: line) + } + + func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .info, file: file, function: function, line: line) + } + + func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .warning, file: file, function: function, line: line) + } + + func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .error, file: file, function: function, line: line) + } + + // MARK: - File Logging + + private func setupFileLogging() { + let fileManager = FileManager.default + guard let logsDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } + + let appLogsDir = logsDir.appendingPathComponent("FT991A-Remote/Logs", isDirectory: true) + + do { + try fileManager.createDirectory(at: appLogsDir, withIntermediateDirectories: true) + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let fileName = "ft991a_\(formatter.string(from: Date())).log" + + logFileURL = appLogsDir.appendingPathComponent(fileName) + + if !fileManager.fileExists(atPath: logFileURL!.path) { + fileManager.createFile(atPath: logFileURL!.path, contents: nil) + } + + logFileHandle = try FileHandle(forWritingTo: logFileURL!) + logFileHandle?.seekToEndOfFile() + + let header = "\n=== FT-991A Remote Log Started at \(Date()) ===\n" + if let data = header.data(using: .utf8) { + logFileHandle?.write(data) + } + } catch { + print("Failed to setup file logging: \(error)") + } + } + + private func writeToFile(_ entry: LogEntry) { + guard let handle = logFileHandle else { return } + + queue.async { + if let data = (entry.detailedMessage + "\n").data(using: .utf8) { + handle.write(data) + } + } + } + + // MARK: - Management + + func clear() { + entries.removeAll() + } + + func exportLogs() -> String { + entries.map { $0.detailedMessage }.joined(separator: "\n") + } + + var filteredEntries: [LogEntry] { + entries.filter { $0.level >= minimumLevel } + } +} diff --git a/FT991A-Remote/FT991A-Remote/ViewModels/LogViewModel.swift b/FT991A-Remote/FT991A-Remote/ViewModels/LogViewModel.swift new file mode 100644 index 0000000..5a96821 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/ViewModels/LogViewModel.swift @@ -0,0 +1,211 @@ +// +// 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() + + // 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)" + } +} diff --git a/FT991A-Remote/FT991A-Remote/ViewModels/RadioViewModel.swift b/FT991A-Remote/FT991A-Remote/ViewModels/RadioViewModel.swift new file mode 100644 index 0000000..33155d1 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/ViewModels/RadioViewModel.swift @@ -0,0 +1,306 @@ +// +// RadioViewModel.swift +// FT991A-Remote +// +// Main ViewModel for radio control +// + +import Foundation +import Combine +import SwiftUI + +// MARK: - Radio ViewModel + +@MainActor +class RadioViewModel: ObservableObject { + + // MARK: - Published Properties + + // Connection + @Published var isConnected = false + @Published var connectionState: ConnectionState = .disconnected + @Published var availablePorts: [SerialPort] = [] + @Published var selectedPort: String = "" + @Published var baudRate: Int = 38400 + + // Radio State (mirrored for convenience) + @Published var vfoAFrequency: Int = 14_250_000 + @Published var vfoBFrequency: Int = 14_255_000 + @Published var activeVFO: VFO = .a + @Published var mode: OperatingMode = .usb + @Published var frequencyStep: FrequencyStep = .khz1 + + // Levels + @Published var afGain: Int = 128 + @Published var rfGain: Int = 255 + @Published var squelch: Int = 0 + @Published var micGain: Int = 50 + @Published var power: Int = 100 + + // Functions + @Published var noiseBlanker = false + @Published var noiseReduction = false + @Published var dnf = false + @Published var contour = false + @Published var atu = false + @Published var split = false + @Published var ipo = false + + // Metering + @Published var sMeter: Int = 0 + @Published var powerMeter: Int = 0 + @Published var swrMeter: Int = 0 + @Published var isTransmitting = false + + // Statistics + @Published var bytesSent: UInt64 = 0 + @Published var bytesReceived: UInt64 = 0 + + // Debug + @Published var commandHistory: [CommandLogEntry] = [] + + // MARK: - Services + + private let serialManager = SerialPortManager() + private let catProtocol: CATProtocol + private var cancellables = Set() + + // MARK: - Computed Properties + + var activeFrequency: Int { + activeVFO == .a ? vfoAFrequency : vfoBFrequency + } + + var frequencyDisplay: String { + formatFrequency(activeFrequency) + } + + var sMeterDisplay: 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 currentBand: Band? { + Band.from(frequency: activeFrequency) + } + + // MARK: - Initialization + + init() { + catProtocol = CATProtocol(serialManager: serialManager) + setupBindings() + refreshPorts() + } + + private func setupBindings() { + // Serial Manager bindings + serialManager.$connectionState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.connectionState = state + self?.isConnected = state.isConnected + } + .store(in: &cancellables) + + serialManager.$availablePorts + .receive(on: DispatchQueue.main) + .assign(to: &$availablePorts) + + serialManager.$selectedPortPath + .receive(on: DispatchQueue.main) + .assign(to: &$selectedPort) + + serialManager.$bytesSent + .receive(on: DispatchQueue.main) + .assign(to: &$bytesSent) + + serialManager.$bytesReceived + .receive(on: DispatchQueue.main) + .assign(to: &$bytesReceived) + + // CAT Protocol bindings + catProtocol.$radioState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.updateFromRadioState(state) + } + .store(in: &cancellables) + + catProtocol.$commandHistory + .receive(on: DispatchQueue.main) + .assign(to: &$commandHistory) + } + + private func updateFromRadioState(_ state: RadioState) { + vfoAFrequency = state.vfoAFrequency + vfoBFrequency = state.vfoBFrequency + activeVFO = state.activeVFO + mode = state.mode + afGain = state.afGain + rfGain = state.rfGain + squelch = state.squelch + micGain = state.micGain + power = state.power + noiseBlanker = state.noiseBlanker + noiseReduction = state.noiseReduction + dnf = state.dnf + contour = state.contour + atu = state.atu + split = state.split + ipo = state.ipo + sMeter = state.sMeter + powerMeter = state.powerMeter + swrMeter = state.swrMeter + isTransmitting = state.isTransmitting + } + + // MARK: - Connection + + func refreshPorts() { + serialManager.refreshPorts() + } + + func connect() { + serialManager.selectedPortPath = selectedPort + serialManager.baudRate = baudRate + serialManager.connect() + } + + func disconnect() { + serialManager.disconnect() + } + + func toggleConnection() { + if isConnected { + disconnect() + } else { + connect() + } + } + + func selectPort(_ path: String) { + selectedPort = path + serialManager.selectedPortPath = path + } + + // MARK: - Frequency Control + + func setFrequency(_ frequency: Int) { + catProtocol.setFrequency(frequency, vfo: activeVFO) + } + + func incrementFrequency() { + catProtocol.changeFrequency(by: frequencyStep.rawValue) + } + + func decrementFrequency() { + catProtocol.changeFrequency(by: -frequencyStep.rawValue) + } + + func selectBand(_ band: Band) { + catProtocol.selectBand(band) + } + + // MARK: - VFO Control + + func selectVFO(_ vfo: VFO) { + catProtocol.selectVFO(vfo) + } + + func swapVFO() { + catProtocol.swapVFO() + } + + func equalizeVFO() { + catProtocol.equalizeVFO() + } + + // MARK: - Mode Control + + func setMode(_ mode: OperatingMode) { + catProtocol.setMode(mode) + } + + // MARK: - Level Control + + func setAFGain(_ value: Int) { + catProtocol.setAFGain(value) + } + + func setRFGain(_ value: Int) { + catProtocol.setRFGain(value) + } + + func setSquelch(_ value: Int) { + catProtocol.setSquelch(value) + } + + func setMICGain(_ value: Int) { + catProtocol.setMICGain(value) + } + + func setPower(_ value: Int) { + catProtocol.setPower(value) + } + + // MARK: - Function Control + + func toggleNB() { + catProtocol.toggleNB() + } + + func toggleNR() { + catProtocol.toggleNR() + } + + func toggleDNF() { + catProtocol.toggleDNF() + } + + func toggleSplit() { + catProtocol.toggleSplit() + } + + func startATUTune() { + catProtocol.startATUTune() + } + + // MARK: - PTT Control + + func startTransmit(dataMode: Bool = false) { + catProtocol.startTransmit(dataMode: dataMode) + } + + func stopTransmit() { + catProtocol.stopTransmit() + } + + func toggleTransmit(dataMode: Bool = false) { + catProtocol.toggleTransmit(dataMode: dataMode) + } + + // MARK: - Debug + + func sendRawCommand(_ command: String) { + catProtocol.sendRaw(command) + } + + func clearCommandHistory() { + catProtocol.clearCommandHistory() + } + + // MARK: - Helpers + + 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) + } +} diff --git a/FT991A-Remote/FT991A-Remote/ViewModels/SettingsController.swift b/FT991A-Remote/FT991A-Remote/ViewModels/SettingsController.swift new file mode 100644 index 0000000..154ddff --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/ViewModels/SettingsController.swift @@ -0,0 +1,172 @@ +// +// SettingsController.swift +// FT991A-Remote +// +// Application settings controller +// + +import Foundation +import Combine +import SwiftUI + +// MARK: - Settings Controller + +@MainActor +class SettingsController: ObservableObject { + + // MARK: - Published Properties + + // UI Settings + @Published var uiStyle: UIStyle = .modern { + didSet { saveSettings() } + } + + @Published var language: AppLanguage = .german { + didSet { saveSettings() } + } + + @Published var compactMode: Bool = true { + didSet { saveSettings() } + } + + @Published var showDebugPanel: Bool = false { + didSet { saveSettings() } + } + + @Published var showLogPanel: Bool = false { + didSet { saveSettings() } + } + + // Connection Settings + @Published var autoReconnect: Bool = true { + didSet { saveSettings() } + } + + @Published var reconnectInterval: TimeInterval = 5.0 { + didSet { saveSettings() } + } + + @Published var defaultBaudRate: Int = 38400 { + didSet { saveSettings() } + } + + // Frequency Settings + @Published var frequencyStep: FrequencyStep = .khz1 { + didSet { saveSettings() } + } + + // Logging Settings + @Published var logDirectory: String = "~/Documents/FT991A-Logs/" { + didSet { saveSettings() } + } + + @Published var autoSaveLog: Bool = true { + didSet { saveSettings() } + } + + // Audio Settings + @Published var audioInputDevice: String = "" { + didSet { saveSettings() } + } + + @Published var audioOutputDevice: String = "" { + didSet { saveSettings() } + } + + @Published var useBlackHole: Bool = false { + didSet { saveSettings() } + } + + // Keyboard Settings + @Published var pttShortcutEnabled: Bool = true { + didSet { saveSettings() } + } + + @Published var arrowFrequencyEnabled: Bool = true { + didSet { saveSettings() } + } + + @Published var tunerShortcutEnabled: Bool = true { + didSet { saveSettings() } + } + + // MARK: - Private Properties + + private var settings: AppSettings + private var saveDebounce: Timer? + + // MARK: - Initialization + + init() { + settings = AppSettings.load() + loadFromSettings() + } + + // MARK: - Settings Management + + private func loadFromSettings() { + uiStyle = settings.uiStyle + language = settings.language + compactMode = settings.compactMode + showDebugPanel = settings.showDebugPanel + showLogPanel = settings.showLogPanel + autoReconnect = settings.autoReconnect + reconnectInterval = settings.reconnectInterval + frequencyStep = settings.frequencyStep + logDirectory = settings.logDirectory + autoSaveLog = settings.autoSaveLog + audioInputDevice = settings.audioInputDevice + audioOutputDevice = settings.audioOutputDevice + useBlackHole = settings.useBlackHole + pttShortcutEnabled = settings.pttShortcutEnabled + arrowFrequencyEnabled = settings.arrowFrequencyEnabled + tunerShortcutEnabled = settings.tunerShortcutEnabled + defaultBaudRate = settings.baudRate + } + + private func saveSettings() { + // Debounce saves to avoid excessive disk writes + saveDebounce?.invalidate() + saveDebounce = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + self?.performSave() + } + } + + private func performSave() { + settings.uiStyle = uiStyle + settings.language = language + settings.compactMode = compactMode + settings.showDebugPanel = showDebugPanel + settings.showLogPanel = showLogPanel + settings.autoReconnect = autoReconnect + settings.reconnectInterval = reconnectInterval + settings.frequencyStep = frequencyStep + settings.logDirectory = logDirectory + settings.autoSaveLog = autoSaveLog + settings.audioInputDevice = audioInputDevice + settings.audioOutputDevice = audioOutputDevice + settings.useBlackHole = useBlackHole + settings.pttShortcutEnabled = pttShortcutEnabled + settings.arrowFrequencyEnabled = arrowFrequencyEnabled + settings.tunerShortcutEnabled = tunerShortcutEnabled + settings.baudRate = defaultBaudRate + + settings.save() + Logger.shared.log("Settings saved", level: .debug) + } + + func resetToDefaults() { + settings = AppSettings.defaults + loadFromSettings() + settings.save() + Logger.shared.log("Settings reset to defaults", level: .info) + } + + // MARK: - Helpers + + var expandedLogDirectory: String { + (logDirectory as NSString).expandingTildeInPath + } + + static let availableBaudRates = [4800, 9600, 19200, 38400, 57600, 115200] +} diff --git a/FT991A-Remote/FT991A-Remote/Views/MainView.swift b/FT991A-Remote/FT991A-Remote/Views/MainView.swift new file mode 100644 index 0000000..d8aeb69 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Views/MainView.swift @@ -0,0 +1,216 @@ +// +// MainView.swift +// FT991A-Remote +// +// Main application window container +// + +import SwiftUI + +// MARK: - Main View + +struct MainView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + @EnvironmentObject var settingsController: SettingsController + @EnvironmentObject var logViewModel: LogViewModel + + @State private var isDebugPanelDetached = false + @State private var isLogPanelDetached = false + + var body: some View { + NavigationSplitView { + // Sidebar + SidebarView() + .frame(minWidth: 200) + } detail: { + // Main content + HSplitView { + // Radio control area + VStack(spacing: 0) { + // Connection bar + ConnectionBar() + .padding(.horizontal) + .padding(.top, 8) + + Divider() + .padding(.top, 8) + + // Radio view based on UI style + if settingsController.uiStyle == .modern { + ModernRadioView() + } else { + SkeuomorphRadioView() + } + } + .frame(minWidth: 600) + + // Side panels + if settingsController.showLogPanel && !isLogPanelDetached { + Divider() + LogPanel() + .frame(minWidth: 300, maxWidth: 400) + } + + if settingsController.showDebugPanel && !isDebugPanelDetached { + Divider() + DebugPanel() + .frame(minWidth: 300, maxWidth: 400) + } + } + } + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + // UI Style toggle + Picker("UI", selection: $settingsController.uiStyle) { + Image(systemName: "rectangle.3.group") + .tag(UIStyle.modern) + Image(systemName: "dial.medium") + .tag(UIStyle.skeuomorph) + } + .pickerStyle(.segmented) + .help("UI-Stil wechseln") + + Divider() + + // Panel toggles + Toggle(isOn: $settingsController.showLogPanel) { + Image(systemName: "list.bullet.rectangle") + } + .help("Log-Panel anzeigen") + + Toggle(isOn: $settingsController.showDebugPanel) { + Image(systemName: "terminal") + } + .help("Debug-Panel anzeigen") + } + } + .navigationTitle("FT-991A Remote") + .onAppear { + setupKeyboardShortcuts() + } + } + + private func setupKeyboardShortcuts() { + // Keyboard shortcuts are handled in the App commands + } +} + +// MARK: - Sidebar View + +struct SidebarView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + var body: some View { + List { + Section("Verbindung") { + Label { + VStack(alignment: .leading) { + Text(radioViewModel.isConnected ? "Verbunden" : "Getrennt") + .font(.headline) + if radioViewModel.isConnected { + Text(radioViewModel.selectedPort) + .font(.caption) + .foregroundColor(.secondary) + } + } + } icon: { + Image(systemName: radioViewModel.isConnected ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash") + .foregroundColor(radioViewModel.isConnected ? .green : .red) + } + } + + Section("Frequenz") { + Label { + Text(radioViewModel.frequencyDisplay + " Hz") + .font(.system(.body, design: .monospaced)) + } icon: { + Image(systemName: "waveform") + } + + if let band = radioViewModel.currentBand { + Label(band.rawValue, systemImage: "chart.bar") + } + + Label(radioViewModel.mode.rawValue, systemImage: "waveform.path") + } + + Section("Bänder") { + ForEach(Band.allCases, id: \.self) { band in + Button { + radioViewModel.selectBand(band) + } label: { + Label(band.rawValue, systemImage: "antenna.radiowaves.left.and.right") + } + .disabled(!radioViewModel.isConnected) + } + } + } + .listStyle(.sidebar) + } +} + +// MARK: - Connection Bar + +struct ConnectionBar: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + var body: some View { + HStack(spacing: 12) { + // Port selection + Picker("Port", selection: $radioViewModel.selectedPort) { + Text("Port wählen...").tag("") + ForEach(radioViewModel.availablePorts) { port in + Text(port.name).tag(port.path) + } + } + .frame(width: 200) + + // Refresh button + Button { + radioViewModel.refreshPorts() + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Ports aktualisieren") + + // Baud rate + Picker("Baud", selection: $radioViewModel.baudRate) { + ForEach(SerialConfig.availableBaudRates, id: \.self) { rate in + Text("\(rate)").tag(rate) + } + } + .frame(width: 100) + + Spacer() + + // Connection status + HStack(spacing: 6) { + Circle() + .fill(radioViewModel.isConnected ? Color.green : Color.red) + .frame(width: 10, height: 10) + + Text(radioViewModel.connectionState.displayString) + .foregroundColor(.secondary) + } + + // Connect button + Button { + radioViewModel.toggleConnection() + } label: { + Text(radioViewModel.isConnected ? "Trennen" : "Verbinden") + } + .keyboardShortcut("k", modifiers: .command) + } + .padding(.vertical, 8) + } +} + +// MARK: - Preview + +#Preview { + MainView() + .environmentObject(RadioViewModel()) + .environmentObject(SettingsController()) + .environmentObject(LogViewModel()) + .frame(width: 1200, height: 800) +} diff --git a/FT991A-Remote/FT991A-Remote/Views/MenuBar/MenuBarView.swift b/FT991A-Remote/FT991A-Remote/Views/MenuBar/MenuBarView.swift new file mode 100644 index 0000000..3f6c04f --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Views/MenuBar/MenuBarView.swift @@ -0,0 +1,134 @@ +// +// MenuBarView.swift +// FT991A-Remote +// +// Menu bar extra for background operation +// + +import SwiftUI + +// MARK: - Menu Bar View + +struct MenuBarView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + @EnvironmentObject var settingsController: SettingsController + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Connection status + HStack { + Circle() + .fill(radioViewModel.isConnected ? Color.green : Color.red) + .frame(width: 10, height: 10) + + Text(radioViewModel.isConnected ? "Verbunden" : "Getrennt") + .font(.headline) + + Spacer() + + Button(radioViewModel.isConnected ? "Trennen" : "Verbinden") { + radioViewModel.toggleConnection() + } + .controlSize(.small) + } + + if radioViewModel.isConnected { + Divider() + + // Frequency display + VStack(alignment: .leading, spacing: 4) { + Text("Frequenz") + .font(.caption) + .foregroundColor(.secondary) + + Text(radioViewModel.frequencyDisplay + " Hz") + .font(.system(.title3, design: .monospaced)) + } + + // Mode and Band + HStack { + Text(radioViewModel.mode.rawValue) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.2)) + .cornerRadius(4) + + if let band = radioViewModel.currentBand { + Text(band.rawValue) + .foregroundColor(.secondary) + } + + Spacer() + + Text(radioViewModel.sMeterDisplay) + .font(.caption.monospacedDigit()) + } + + // TX Status + if radioViewModel.isTransmitting { + HStack { + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + Text("Senden") + .foregroundColor(.red) + } + } + + Divider() + + // Quick controls + HStack(spacing: 12) { + Button { + radioViewModel.selectVFO(radioViewModel.activeVFO == .a ? .b : .a) + } label: { + Text("VFO \(radioViewModel.activeVFO.rawValue)") + } + .controlSize(.small) + + Button("A/B") { + radioViewModel.swapVFO() + } + .controlSize(.small) + + Button("ATU") { + radioViewModel.startATUTune() + } + .controlSize(.small) + } + } + + Divider() + + // App controls + Button("Hauptfenster öffnen") { + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first { + window.makeKeyAndOrderFront(nil) + } + } + + Button("Einstellungen...") { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } + .keyboardShortcut(",", modifiers: .command) + + Divider() + + Button("Beenden") { + NSApp.terminate(nil) + } + .keyboardShortcut("q", modifiers: .command) + } + .padding() + .frame(width: 280) + } +} + +// MARK: - Preview + +#Preview { + MenuBarView() + .environmentObject(RadioViewModel()) + .environmentObject(SettingsController()) +} diff --git a/FT991A-Remote/FT991A-Remote/Views/ModernView/ModernRadioView.swift b/FT991A-Remote/FT991A-Remote/Views/ModernView/ModernRadioView.swift new file mode 100644 index 0000000..e38d4aa --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Views/ModernView/ModernRadioView.swift @@ -0,0 +1,576 @@ +// +// ModernRadioView.swift +// FT991A-Remote +// +// Modern UI style for radio control +// + +import SwiftUI + +// MARK: - Modern Radio View + +struct ModernRadioView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + @EnvironmentObject var settingsController: SettingsController + + var body: some View { + ScrollView { + VStack(spacing: 20) { + // Frequency Section + FrequencyView() + + // Mode & Filter Section + HStack(spacing: 20) { + ModeView() + Spacer() + LevelView() + } + + // Functions Section + FunctionsView() + + // Metering Section + MeteringView() + + // PTT Section + PTTButton() + + Spacer() + } + .padding() + } + } +} + +// MARK: - Frequency View + +struct FrequencyView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + @EnvironmentObject var settingsController: SettingsController + + @State private var frequencyInput = "" + @State private var isEditing = false + + var body: some View { + GroupBox("Frequenz") { + VStack(spacing: 16) { + // VFO Selection + HStack { + // VFO A + Button { + radioViewModel.selectVFO(.a) + } label: { + HStack { + Circle() + .fill(radioViewModel.activeVFO == .a ? Color.green : Color.gray.opacity(0.3)) + .frame(width: 12, height: 12) + Text("VFO-A") + .font(.headline) + Text(radioViewModel.formatFrequency(radioViewModel.vfoAFrequency)) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(radioViewModel.activeVFO == .a ? Color.accentColor.opacity(0.1) : Color.clear) + .cornerRadius(8) + } + .buttonStyle(.plain) + .disabled(!radioViewModel.isConnected) + + Spacer() + + // VFO controls + HStack(spacing: 8) { + Button("A/B") { + radioViewModel.swapVFO() + } + .help("VFO A und B tauschen") + + Button("A=B") { + radioViewModel.equalizeVFO() + } + .help("VFO B auf A-Frequenz setzen") + } + .disabled(!radioViewModel.isConnected) + + Spacer() + + // VFO B + Button { + radioViewModel.selectVFO(.b) + } label: { + HStack { + Text(radioViewModel.formatFrequency(radioViewModel.vfoBFrequency)) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + Text("VFO-B") + .font(.headline) + Circle() + .fill(radioViewModel.activeVFO == .b ? Color.green : Color.gray.opacity(0.3)) + .frame(width: 12, height: 12) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(radioViewModel.activeVFO == .b ? Color.accentColor.opacity(0.1) : Color.clear) + .cornerRadius(8) + } + .buttonStyle(.plain) + .disabled(!radioViewModel.isConnected) + } + + // Main frequency display + HStack { + Button { + radioViewModel.decrementFrequency() + } label: { + Image(systemName: "minus.circle.fill") + .font(.title) + } + .buttonStyle(.plain) + .keyboardShortcut(.leftArrow, modifiers: []) + .disabled(!radioViewModel.isConnected) + + Spacer() + + // Frequency display + VStack(spacing: 4) { + Text(radioViewModel.frequencyDisplay) + .font(.system(size: 48, weight: .bold, design: .monospaced)) + .foregroundColor(radioViewModel.isTransmitting ? .red : .primary) + + Text("Hz") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button { + radioViewModel.incrementFrequency() + } label: { + Image(systemName: "plus.circle.fill") + .font(.title) + } + .buttonStyle(.plain) + .keyboardShortcut(.rightArrow, modifiers: []) + .disabled(!radioViewModel.isConnected) + } + + // Frequency step selector + HStack { + Text("Schritt:") + .foregroundColor(.secondary) + + Picker("Schritt", selection: $settingsController.frequencyStep) { + ForEach(FrequencyStep.allCases, id: \.self) { step in + Text(step.displayName).tag(step) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 500) + } + + // Band buttons + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Band.allCases, id: \.self) { band in + Button { + radioViewModel.selectBand(band) + } label: { + Text(band.rawValue) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(radioViewModel.currentBand == band ? Color.accentColor : Color.secondary.opacity(0.2)) + .foregroundColor(radioViewModel.currentBand == band ? .white : .primary) + .cornerRadius(6) + } + .buttonStyle(.plain) + .disabled(!radioViewModel.isConnected) + } + } + } + } + .padding() + } + } +} + +// MARK: - Mode View + +struct ModeView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + let commonModes: [OperatingMode] = [.lsb, .usb, .cw, .fm, .am] + let digitalModes: [OperatingMode] = [.dataLSB, .dataUSB, .rttyLSB, .rttyUSB, .c4fm] + + var body: some View { + GroupBox("Betriebsart") { + VStack(alignment: .leading, spacing: 12) { + // Common modes + HStack(spacing: 8) { + ForEach(commonModes, id: \.self) { mode in + Button { + radioViewModel.setMode(mode) + } label: { + Text(mode.rawValue) + .font(.caption.bold()) + .frame(width: 50) + .padding(.vertical, 6) + .background(radioViewModel.mode == mode ? Color.accentColor : Color.secondary.opacity(0.2)) + .foregroundColor(radioViewModel.mode == mode ? .white : .primary) + .cornerRadius(6) + } + .buttonStyle(.plain) + .disabled(!radioViewModel.isConnected) + } + } + + // Digital modes + HStack(spacing: 8) { + ForEach(digitalModes, id: \.self) { mode in + Button { + radioViewModel.setMode(mode) + } label: { + Text(mode.rawValue) + .font(.caption.bold()) + .frame(minWidth: 50) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(radioViewModel.mode == mode ? Color.orange : Color.secondary.opacity(0.2)) + .foregroundColor(radioViewModel.mode == mode ? .white : .primary) + .cornerRadius(6) + } + .buttonStyle(.plain) + .disabled(!radioViewModel.isConnected) + } + } + } + .padding() + } + } +} + +// MARK: - Level View + +struct LevelView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + var body: some View { + GroupBox("Pegel") { + VStack(spacing: 12) { + LevelSlider(label: "AF", value: Binding( + get: { Double(radioViewModel.afGain) }, + set: { radioViewModel.setAFGain(Int($0)) } + ), range: 0...255, disabled: !radioViewModel.isConnected) + + LevelSlider(label: "RF", value: Binding( + get: { Double(radioViewModel.rfGain) }, + set: { radioViewModel.setRFGain(Int($0)) } + ), range: 0...255, disabled: !radioViewModel.isConnected) + + LevelSlider(label: "SQL", value: Binding( + get: { Double(radioViewModel.squelch) }, + set: { radioViewModel.setSquelch(Int($0)) } + ), range: 0...255, disabled: !radioViewModel.isConnected) + + LevelSlider(label: "MIC", value: Binding( + get: { Double(radioViewModel.micGain) }, + set: { radioViewModel.setMICGain(Int($0)) } + ), range: 0...100, disabled: !radioViewModel.isConnected) + + LevelSlider(label: "PWR", value: Binding( + get: { Double(radioViewModel.power) }, + set: { radioViewModel.setPower(Int($0)) } + ), range: 5...100, unit: "W", disabled: !radioViewModel.isConnected) + } + .padding() + } + .frame(width: 300) + } +} + +// MARK: - Level Slider + +struct LevelSlider: View { + let label: String + @Binding var value: Double + let range: ClosedRange + var unit: String = "%" + var disabled: Bool = false + + var displayValue: String { + if unit == "W" { + return "\(Int(value))W" + } else { + let percent = (value - range.lowerBound) / (range.upperBound - range.lowerBound) * 100 + return "\(Int(percent))%" + } + } + + var body: some View { + HStack { + Text(label) + .font(.caption.bold()) + .frame(width: 35, alignment: .leading) + + Slider(value: $value, in: range) + .disabled(disabled) + + Text(displayValue) + .font(.caption.monospacedDigit()) + .frame(width: 45, alignment: .trailing) + } + } +} + +// MARK: - Functions View + +struct FunctionsView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + var body: some View { + GroupBox("Funktionen") { + HStack(spacing: 12) { + FunctionButton(label: "NB", isActive: radioViewModel.noiseBlanker) { + radioViewModel.toggleNB() + } + .disabled(!radioViewModel.isConnected) + + FunctionButton(label: "NR", isActive: radioViewModel.noiseReduction) { + radioViewModel.toggleNR() + } + .disabled(!radioViewModel.isConnected) + + FunctionButton(label: "DNF", isActive: radioViewModel.dnf) { + radioViewModel.toggleDNF() + } + .disabled(!radioViewModel.isConnected) + + FunctionButton(label: "CONT", isActive: radioViewModel.contour) { + // Toggle contour + } + .disabled(!radioViewModel.isConnected) + + Divider() + .frame(height: 30) + + FunctionButton(label: "ATU", isActive: radioViewModel.atu, color: .orange) { + radioViewModel.startATUTune() + } + .disabled(!radioViewModel.isConnected) + .keyboardShortcut(.upArrow, modifiers: []) + + FunctionButton(label: "SPLIT", isActive: radioViewModel.split) { + radioViewModel.toggleSplit() + } + .disabled(!radioViewModel.isConnected) + + FunctionButton(label: "IPO", isActive: radioViewModel.ipo) { + // Toggle IPO + } + .disabled(!radioViewModel.isConnected) + + Spacer() + } + .padding() + } + } +} + +// MARK: - Function Button + +struct FunctionButton: View { + let label: String + let isActive: Bool + var color: Color = .accentColor + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.caption.bold()) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isActive ? color : Color.secondary.opacity(0.2)) + .foregroundColor(isActive ? .white : .primary) + .cornerRadius(6) + } + .buttonStyle(.plain) + } +} + +// MARK: - Metering View + +struct MeteringView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + var body: some View { + GroupBox("Messwerte") { + VStack(spacing: 16) { + // S-Meter + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("S-Meter") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(radioViewModel.sMeterDisplay) + .font(.caption.bold().monospacedDigit()) + } + + SMeterBar(value: Double(radioViewModel.sMeter) / 255.0) + } + + // Power meter (only shown when transmitting) + if radioViewModel.isTransmitting { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Leistung") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("\(radioViewModel.powerMeter)W") + .font(.caption.bold().monospacedDigit()) + } + + MeterBar(value: Double(radioViewModel.powerMeter) / 100.0, color: .orange) + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("SWR") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(String(format: "%.1f:1", 1.0 + Double(radioViewModel.swrMeter) / 50.0)) + .font(.caption.bold().monospacedDigit()) + } + + MeterBar(value: Double(radioViewModel.swrMeter) / 255.0, color: radioViewModel.swrMeter > 100 ? .red : .green) + } + } + } + .padding() + } + } +} + +// MARK: - S-Meter Bar + +struct SMeterBar: View { + let value: Double + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.2)) + + // S-Unit markers + HStack(spacing: 0) { + ForEach(0..<10) { i in + Rectangle() + .fill(Color.secondary.opacity(0.3)) + .frame(width: 1) + if i < 9 { + Spacer() + } + } + } + .padding(.horizontal, 2) + + // Value bar + RoundedRectangle(cornerRadius: 4) + .fill( + LinearGradient( + colors: [.green, .yellow, .orange, .red], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: max(0, geometry.size.width * value)) + } + } + .frame(height: 20) + } +} + +// MARK: - Meter Bar + +struct MeterBar: View { + let value: Double + var color: Color = .green + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.2)) + + RoundedRectangle(cornerRadius: 4) + .fill(color) + .frame(width: max(0, geometry.size.width * min(1, value))) + } + } + .frame(height: 16) + } +} + +// MARK: - PTT Button + +struct PTTButton: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + @State private var isPressed = false + + var body: some View { + GroupBox("PTT") { + VStack(spacing: 12) { + Button { + radioViewModel.toggleTransmit() + } label: { + HStack { + Image(systemName: radioViewModel.isTransmitting ? "mic.fill" : "mic") + Text(radioViewModel.isTransmitting ? "EMPFANG" : "SENDEN") + .font(.headline) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(radioViewModel.isTransmitting ? Color.red : Color.accentColor) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + .disabled(!radioViewModel.isConnected) + + Text("Shift-Taste gedrückt halten = PTT") + .font(.caption) + .foregroundColor(.secondary) + + // TX indicator + HStack { + Circle() + .fill(radioViewModel.isTransmitting ? Color.red : Color.gray.opacity(0.3)) + .frame(width: 16, height: 16) + Text(radioViewModel.isTransmitting ? "TX" : "RX") + .font(.caption.bold()) + .foregroundColor(radioViewModel.isTransmitting ? .red : .green) + } + } + .padding() + } + } +} + +// MARK: - Preview + +#Preview { + ModernRadioView() + .environmentObject(RadioViewModel()) + .environmentObject(SettingsController()) + .frame(width: 800, height: 900) + .padding() +} diff --git a/FT991A-Remote/FT991A-Remote/Views/Panels/AudioPanel.swift b/FT991A-Remote/FT991A-Remote/Views/Panels/AudioPanel.swift new file mode 100644 index 0000000..ab6d417 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Views/Panels/AudioPanel.swift @@ -0,0 +1,148 @@ +// +// AudioPanel.swift +// FT991A-Remote +// +// BlackHole audio routing panel +// + +import SwiftUI + +// MARK: - Audio Panel + +struct AudioPanel: View { + @StateObject private var audioRouter = AudioRouter() + @EnvironmentObject var settingsController: SettingsController + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Audio Routing") + .font(.headline) + + Spacer() + + Button { + audioRouter.refreshDevices() + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Geräte aktualisieren") + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.1)) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // BlackHole Status + GroupBox("BlackHole Status") { + HStack { + Circle() + .fill(audioRouter.isBlackHoleInstalled ? Color.green : Color.red) + .frame(width: 12, height: 12) + + Text(audioRouter.isBlackHoleInstalled ? "Installiert" : "Nicht gefunden") + + Spacer() + + if !audioRouter.isBlackHoleInstalled { + Link("Installieren", destination: URL(string: "https://existential.audio/blackhole/")!) + .font(.caption) + } + } + .padding(.vertical, 4) + + if let device = audioRouter.blackHoleDevice { + Text("Gerät: \(device.name)") + .font(.caption) + .foregroundColor(.secondary) + } + } + + // Input Device + GroupBox("Eingang (RX Audio)") { + Picker("Eingabegerät", selection: $audioRouter.selectedInputDevice) { + Text("Keines").tag(nil as AudioDeviceID?) + ForEach(audioRouter.inputDevices) { device in + Text(device.displayName).tag(device.id as AudioDeviceID?) + } + } + .pickerStyle(.menu) + + if let ft991a = audioRouter.ft991aDevice { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("FT-991A erkannt: \(ft991a.name)") + .font(.caption) + } + } + } + + // Output Device + GroupBox("Ausgang (TX Audio)") { + Picker("Ausgabegerät", selection: $audioRouter.selectedOutputDevice) { + Text("Keines").tag(nil as AudioDeviceID?) + ForEach(audioRouter.outputDevices) { device in + Text(device.displayName).tag(device.id as AudioDeviceID?) + } + } + .pickerStyle(.menu) + } + + // Digital Mode Configuration + GroupBox("Digitale Betriebsarten") { + VStack(alignment: .leading, spacing: 8) { + Text("Für FT8, WSPR, RTTY und andere digitale Modi:") + .font(.caption) + .foregroundColor(.secondary) + + Button("Für Digimodes konfigurieren") { + _ = audioRouter.configureForDigitalModes() + } + .disabled(!audioRouter.isBlackHoleInstalled) + + Toggle("BlackHole verwenden", isOn: $settingsController.useBlackHole) + .disabled(!audioRouter.isBlackHoleInstalled) + } + .padding(.vertical, 4) + } + + // Routing Diagram + GroupBox("Routing-Schema") { + VStack(alignment: .leading, spacing: 4) { + Text("FT-991A USB Audio → BlackHole → WSJT-X/fldigi") + .font(.caption.monospaced()) + Text("WSJT-X/fldigi → BlackHole → FT-991A USB Audio") + .font(.caption.monospaced()) + } + .foregroundColor(.secondary) + .padding(.vertical, 4) + } + + // Error display + if let error = audioRouter.lastError { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text(error) + .font(.caption) + } + } + } + .padding() + } + } + } +} + +// MARK: - Preview + +#Preview { + AudioPanel() + .environmentObject(SettingsController()) + .frame(width: 350, height: 500) +} diff --git a/FT991A-Remote/FT991A-Remote/Views/Panels/DebugPanel.swift b/FT991A-Remote/FT991A-Remote/Views/Panels/DebugPanel.swift new file mode 100644 index 0000000..9d84c64 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Views/Panels/DebugPanel.swift @@ -0,0 +1,178 @@ +// +// DebugPanel.swift +// FT991A-Remote +// +// CAT command console for debugging +// + +import SwiftUI + +// MARK: - Debug Panel + +struct DebugPanel: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + @State private var commandInput = "" + @State private var autoScroll = true + @State private var showOnlySent = false + @State private var showOnlyReceived = false + + var filteredHistory: [CommandLogEntry] { + radioViewModel.commandHistory.filter { entry in + if showOnlySent && entry.direction != .sent { return false } + if showOnlyReceived && entry.direction != .received { return false } + return true + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("CAT Konsole") + .font(.headline) + + Spacer() + + // Filter buttons + Toggle("TX", isOn: Binding( + get: { showOnlySent }, + set: { showOnlySent = $0; if $0 { showOnlyReceived = false } } + )) + .toggleStyle(.button) + .controlSize(.small) + + Toggle("RX", isOn: Binding( + get: { showOnlyReceived }, + set: { showOnlyReceived = $0; if $0 { showOnlySent = false } } + )) + .toggleStyle(.button) + .controlSize(.small) + + Toggle(isOn: $autoScroll) { + Image(systemName: "arrow.down.to.line") + } + .toggleStyle(.button) + .controlSize(.small) + .help("Auto-Scroll") + + Button { + radioViewModel.clearCommandHistory() + } label: { + Image(systemName: "trash") + } + .controlSize(.small) + .help("Verlauf löschen") + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.1)) + + Divider() + + // Command history + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(filteredHistory) { entry in + CommandLogRow(entry: entry) + .id(entry.id) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .font(.system(size: 11, design: .monospaced)) + .onChange(of: radioViewModel.commandHistory.count) { _, _ in + if autoScroll, let last = filteredHistory.last { + withAnimation { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + + Divider() + + // Command input + HStack { + TextField("CAT-Befehl eingeben (z.B. FA;)", text: $commandInput) + .textFieldStyle(.plain) + .font(.system(size: 12, design: .monospaced)) + .onSubmit { + sendCommand() + } + + Button("Senden") { + sendCommand() + } + .disabled(commandInput.isEmpty || !radioViewModel.isConnected) + .keyboardShortcut(.return, modifiers: []) + } + .padding(8) + .background(Color.secondary.opacity(0.1)) + + // Statistics + HStack { + Text("TX: \(radioViewModel.bytesSent) Bytes") + Spacer() + Text("RX: \(radioViewModel.bytesReceived) Bytes") + Spacer() + Text("\(radioViewModel.commandHistory.count) Befehle") + } + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } + + private func sendCommand() { + guard !commandInput.isEmpty else { return } + + var cmd = commandInput.trimmingCharacters(in: .whitespaces) + if !cmd.hasSuffix(";") { + cmd += ";" + } + + radioViewModel.sendRawCommand(cmd) + commandInput = "" + } +} + +// MARK: - Command Log Row + +struct CommandLogRow: View { + let entry: CommandLogEntry + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Text(entry.timeString) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + + Text(entry.direction.symbol) + .foregroundColor(entry.direction == .sent ? .blue : .green) + .frame(width: 15) + + Text(entry.command) + .foregroundColor(.primary) + + if !entry.description.isEmpty { + Text("// \(entry.description)") + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 1) + } +} + +// MARK: - Preview + +#Preview { + DebugPanel() + .environmentObject(RadioViewModel()) + .frame(width: 400, height: 500) +} diff --git a/FT991A-Remote/FT991A-Remote/Views/Panels/LogPanel.swift b/FT991A-Remote/FT991A-Remote/Views/Panels/LogPanel.swift new file mode 100644 index 0000000..8604887 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Views/Panels/LogPanel.swift @@ -0,0 +1,318 @@ +// +// LogPanel.swift +// FT991A-Remote +// +// QSO Log panel +// + +import SwiftUI + +// MARK: - Log Panel + +struct LogPanel: View { + @EnvironmentObject var logViewModel: LogViewModel + @EnvironmentObject var radioViewModel: RadioViewModel + + @State private var isAddingQSO = false + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("QSO Log") + .font(.headline) + + Spacer() + + Text("\(logViewModel.totalQSOs) QSOs") + .font(.caption) + .foregroundColor(.secondary) + + Button { + isAddingQSO = true + } label: { + Image(systemName: "plus") + } + .help("Neues QSO hinzufügen") + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.1)) + + Divider() + + // Quick entry form + if isAddingQSO { + QuickLogEntry(isPresented: $isAddingQSO) + Divider() + } + + // Search and filter + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Suchen...", text: $logViewModel.searchText) + .textFieldStyle(.plain) + + if !logViewModel.searchText.isEmpty { + Button { + logViewModel.searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + + Picker("Sortierung", selection: $logViewModel.sortOrder) { + ForEach(LogViewModel.SortOrder.allCases, id: \.self) { order in + Text(order.rawValue).tag(order) + } + } + .pickerStyle(.menu) + .frame(width: 150) + } + .padding(.horizontal) + .padding(.vertical, 6) + + Divider() + + // QSO List + List { + ForEach(logViewModel.filteredEntries) { entry in + QSORow(entry: entry) + .contextMenu { + Button("Bearbeiten") { + logViewModel.selectedEntry = entry + } + Button("Löschen", role: .destructive) { + logViewModel.deleteQSO(entry) + } + } + } + .onDelete(perform: logViewModel.deleteQSOs) + } + .listStyle(.plain) + + Divider() + + // Footer with statistics + HStack { + Text("\(logViewModel.uniqueCallsigns) Stationen") + + Spacer() + + if let file = logViewModel.currentLogFile { + Text(file.lastPathComponent) + .lineLimit(1) + .truncationMode(.middle) + } + } + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + .padding(.vertical, 4) + } + .sheet(item: $logViewModel.selectedEntry) { entry in + QSOEditSheet(entry: entry) + } + } +} + +// MARK: - QSO Row + +struct QSORow: View { + let entry: QSOEntry + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(entry.callsign) + .font(.headline) + + Spacer() + + Text(entry.dateDisplay) + .font(.caption) + .foregroundColor(.secondary) + + Text(entry.timeDisplay) + .font(.caption) + .foregroundColor(.secondary) + } + + HStack { + Text(entry.frequencyDisplay) + .font(.caption.monospacedDigit()) + + Text(entry.mode.rawValue) + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.2)) + .cornerRadius(4) + + Text(entry.bandDisplay) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Text("RST: \(entry.rstSent)/\(entry.rstReceived)") + .font(.caption) + .foregroundColor(.secondary) + } + + if !entry.name.isEmpty || !entry.qth.isEmpty { + HStack { + if !entry.name.isEmpty { + Text(entry.name) + .font(.caption) + } + if !entry.qth.isEmpty { + Text("- \(entry.qth)") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Quick Log Entry + +struct QuickLogEntry: View { + @EnvironmentObject var logViewModel: LogViewModel + @EnvironmentObject var radioViewModel: RadioViewModel + + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 8) { + HStack { + TextField("Rufzeichen", text: $logViewModel.currentQSO.callsign) + .textFieldStyle(.roundedBorder) + + TextField("RST TX", text: $logViewModel.currentQSO.rstSent) + .textFieldStyle(.roundedBorder) + .frame(width: 50) + + TextField("RST RX", text: $logViewModel.currentQSO.rstReceived) + .textFieldStyle(.roundedBorder) + .frame(width: 50) + } + + HStack { + TextField("Name", text: $logViewModel.currentQSO.name) + .textFieldStyle(.roundedBorder) + + TextField("QTH", text: $logViewModel.currentQSO.qth) + .textFieldStyle(.roundedBorder) + + TextField("Locator", text: $logViewModel.currentQSO.locator) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + } + + HStack { + Button("Von Radio") { + logViewModel.updateFromRadio( + frequency: radioViewModel.activeFrequency, + mode: radioViewModel.mode, + power: radioViewModel.power + ) + } + .disabled(!radioViewModel.isConnected) + + Spacer() + + Button("Abbrechen") { + logViewModel.resetCurrentQSO() + isPresented = false + } + + Button("Speichern") { + logViewModel.addQSO() + isPresented = false + } + .disabled(logViewModel.currentQSO.callsign.isEmpty) + .keyboardShortcut(.return, modifiers: .command) + } + } + .padding() + .background(Color.secondary.opacity(0.05)) + } +} + +// MARK: - QSO Edit Sheet + +struct QSOEditSheet: View { + @EnvironmentObject var logViewModel: LogViewModel + @Environment(\.dismiss) var dismiss + + let entry: QSOEntry + @State private var editedEntry: QSOEntry + + init(entry: QSOEntry) { + self.entry = entry + self._editedEntry = State(initialValue: entry) + } + + var body: some View { + VStack(spacing: 16) { + Text("QSO bearbeiten") + .font(.headline) + + Form { + TextField("Rufzeichen", text: $editedEntry.callsign) + TextField("Name", text: $editedEntry.name) + TextField("QTH", text: $editedEntry.qth) + TextField("Locator", text: $editedEntry.locator) + + HStack { + TextField("RST TX", text: $editedEntry.rstSent) + TextField("RST RX", text: $editedEntry.rstReceived) + } + + Picker("Mode", selection: $editedEntry.mode) { + ForEach(OperatingMode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + + TextField("Notizen", text: $editedEntry.notes, axis: .vertical) + .lineLimit(3...6) + } + .formStyle(.grouped) + + HStack { + Button("Abbrechen") { + dismiss() + } + .keyboardShortcut(.escape, modifiers: []) + + Spacer() + + Button("Speichern") { + logViewModel.updateQSO(editedEntry) + dismiss() + } + .keyboardShortcut(.return, modifiers: .command) + } + } + .padding() + .frame(width: 400, height: 400) + } +} + +// MARK: - Preview + +#Preview { + LogPanel() + .environmentObject(LogViewModel()) + .environmentObject(RadioViewModel()) + .frame(width: 350, height: 600) +} diff --git a/FT991A-Remote/FT991A-Remote/Views/Settings/SettingsView.swift b/FT991A-Remote/FT991A-Remote/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..aa1d419 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Views/Settings/SettingsView.swift @@ -0,0 +1,302 @@ +// +// SettingsView.swift +// FT991A-Remote +// +// Application settings view +// + +import SwiftUI + +// MARK: - Settings View + +struct SettingsView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + @EnvironmentObject var settingsController: SettingsController + + var body: some View { + TabView { + // Connection Settings + ConnectionSettingsView() + .tabItem { + Label("Verbindung", systemImage: "cable.connector") + } + + // UI Settings + UISettingsView() + .tabItem { + Label("Oberfläche", systemImage: "paintbrush") + } + + // Audio Settings + AudioSettingsView() + .tabItem { + Label("Audio", systemImage: "speaker.wave.2") + } + + // Keyboard Settings + KeyboardSettingsView() + .tabItem { + Label("Tastatur", systemImage: "keyboard") + } + + // Logging Settings + LoggingSettingsView() + .tabItem { + Label("Logging", systemImage: "doc.text") + } + } + .frame(width: 500, height: 400) + } +} + +// MARK: - Connection Settings + +struct ConnectionSettingsView: View { + @EnvironmentObject var settingsController: SettingsController + + var body: some View { + Form { + Section("Serielle Verbindung") { + Picker("Standard-Baudrate", selection: $settingsController.defaultBaudRate) { + ForEach(SettingsController.availableBaudRates, id: \.self) { rate in + Text("\(rate) baud").tag(rate) + } + } + + Toggle("Auto-Reconnect aktivieren", isOn: $settingsController.autoReconnect) + + if settingsController.autoReconnect { + HStack { + Text("Intervall:") + Slider(value: $settingsController.reconnectInterval, in: 1...30, step: 1) + Text("\(Int(settingsController.reconnectInterval))s") + .frame(width: 30) + } + } + } + + Section("FT-991A Einstellungen") { + Text("Stelle sicher, dass im Radio-Menü folgende Einstellungen aktiv sind:") + .font(.caption) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("• CAT RATE: 38400 bps") + Text("• CAT TOT: 100 ms") + Text("• CAT RTS: OFF") + } + .font(.caption.monospaced()) + } + } + .formStyle(.grouped) + .padding() + } +} + +// MARK: - UI Settings + +struct UISettingsView: View { + @EnvironmentObject var settingsController: SettingsController + + var body: some View { + Form { + Section("Erscheinungsbild") { + Picker("UI-Stil", selection: $settingsController.uiStyle) { + Text("Modern").tag(UIStyle.modern) + Text("Frontpanel (Skeuomorph)").tag(UIStyle.skeuomorph) + } + + Toggle("Kompakter Modus", isOn: $settingsController.compactMode) + } + + Section("Sprache") { + Picker("Sprache", selection: $settingsController.language) { + ForEach(AppLanguage.allCases, id: \.self) { lang in + Text(lang.displayName).tag(lang) + } + } + + Text("Änderungen werden nach Neustart wirksam.") + .font(.caption) + .foregroundColor(.secondary) + } + + Section("Frequenz") { + Picker("Standard-Schrittweite", selection: $settingsController.frequencyStep) { + ForEach(FrequencyStep.allCases, id: \.self) { step in + Text(step.displayName).tag(step) + } + } + } + } + .formStyle(.grouped) + .padding() + } +} + +// MARK: - Audio Settings + +struct AudioSettingsView: View { + @EnvironmentObject var settingsController: SettingsController + @StateObject private var audioRouter = AudioRouter() + + var body: some View { + Form { + Section("Audio-Geräte") { + Picker("Eingabegerät", selection: $settingsController.audioInputDevice) { + Text("Standard").tag("") + ForEach(audioRouter.inputDevices) { device in + Text(device.name).tag(device.uid) + } + } + + Picker("Ausgabegerät", selection: $settingsController.audioOutputDevice) { + Text("Standard").tag("") + ForEach(audioRouter.outputDevices) { device in + Text(device.name).tag(device.uid) + } + } + } + + Section("BlackHole Integration") { + HStack { + Circle() + .fill(audioRouter.isBlackHoleInstalled ? Color.green : Color.red) + .frame(width: 10, height: 10) + Text(audioRouter.isBlackHoleInstalled ? "BlackHole installiert" : "BlackHole nicht gefunden") + } + + Toggle("BlackHole für Digimodes verwenden", isOn: $settingsController.useBlackHole) + .disabled(!audioRouter.isBlackHoleInstalled) + + if !audioRouter.isBlackHoleInstalled { + Link("BlackHole herunterladen", destination: URL(string: "https://existential.audio/blackhole/")!) + } + } + } + .formStyle(.grouped) + .padding() + .onAppear { + audioRouter.refreshDevices() + } + } +} + +// MARK: - Keyboard Settings + +struct KeyboardSettingsView: View { + @EnvironmentObject var settingsController: SettingsController + + var body: some View { + Form { + Section("Tastaturkürzel") { + Toggle("Shift = PTT (Push-to-Talk)", isOn: $settingsController.pttShortcutEnabled) + + Toggle("Pfeiltasten = Frequenz ändern", isOn: $settingsController.arrowFrequencyEnabled) + + Toggle("Pfeil hoch = ATU Tune", isOn: $settingsController.tunerShortcutEnabled) + } + + Section("Übersicht") { + VStack(alignment: .leading, spacing: 8) { + KeyboardShortcutRow(key: "⌘K", action: "Verbinden/Trennen") + KeyboardShortcutRow(key: "⇧⌘S", action: "VFO A/B tauschen") + KeyboardShortcutRow(key: "⇧⌘E", action: "A=B") + KeyboardShortcutRow(key: "⇧⌘T", action: "ATU Tune") + KeyboardShortcutRow(key: "⌥⌘D", action: "Debug-Panel") + KeyboardShortcutRow(key: "⌥⌘L", action: "Log-Panel") + Divider() + KeyboardShortcutRow(key: "←/→", action: "Frequenz +/-") + KeyboardShortcutRow(key: "↑", action: "ATU Tune") + KeyboardShortcutRow(key: "Shift", action: "PTT (halten)") + } + } + } + .formStyle(.grouped) + .padding() + } +} + +// MARK: - Keyboard Shortcut Row + +struct KeyboardShortcutRow: View { + let key: String + let action: String + + var body: some View { + HStack { + Text(key) + .font(.system(.caption, design: .monospaced)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.2)) + .cornerRadius(4) + .frame(width: 70, alignment: .leading) + + Text(action) + .font(.caption) + } + } +} + +// MARK: - Logging Settings + +struct LoggingSettingsView: View { + @EnvironmentObject var settingsController: SettingsController + + var body: some View { + Form { + Section("Log-Speicherort") { + HStack { + TextField("Verzeichnis", text: $settingsController.logDirectory) + .textFieldStyle(.roundedBorder) + + Button("Wählen...") { + selectDirectory() + } + } + + Text("Aktueller Pfad: \(settingsController.expandedLogDirectory)") + .font(.caption) + .foregroundColor(.secondary) + } + + Section("Automatisches Speichern") { + Toggle("Log automatisch speichern", isOn: $settingsController.autoSaveLog) + + Text("Speichert QSOs automatisch nach jeder Eingabe.") + .font(.caption) + .foregroundColor(.secondary) + } + + Section("CSV-Format") { + Text("Felder: Call, Datum, Zeit, Frequenz, Mode, RST TX/RX, Name, QTH, Locator, Power, Notizen") + .font(.caption) + .foregroundColor(.secondary) + } + } + .formStyle(.grouped) + .padding() + } + + private func selectDirectory() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.prompt = "Auswählen" + + if panel.runModal() == .OK, let url = panel.url { + settingsController.logDirectory = url.path + } + } +} + +// MARK: - Preview + +#Preview { + SettingsView() + .environmentObject(RadioViewModel()) + .environmentObject(SettingsController()) +} diff --git a/FT991A-Remote/FT991A-Remote/Views/SkeuomorphView/SkeuomorphRadioView.swift b/FT991A-Remote/FT991A-Remote/Views/SkeuomorphView/SkeuomorphRadioView.swift new file mode 100644 index 0000000..566ded4 --- /dev/null +++ b/FT991A-Remote/FT991A-Remote/Views/SkeuomorphView/SkeuomorphRadioView.swift @@ -0,0 +1,484 @@ +// +// SkeuomorphRadioView.swift +// FT991A-Remote +// +// Skeuomorphic FT-991A front panel replica +// + +import SwiftUI + +// MARK: - Skeuomorph Radio View + +struct SkeuomorphRadioView: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + var body: some View { + ZStack { + // Background - dark metal texture + LinearGradient( + colors: [Color(white: 0.15), Color(white: 0.1)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + // Top section - Display + FrontPanelDisplay() + .padding() + + Divider() + .background(Color.gray.opacity(0.3)) + + // Middle section - Main controls + HStack(spacing: 30) { + // Left side controls + VStack(spacing: 20) { + DialKnob(label: "AF GAIN", value: Binding( + get: { Double(radioViewModel.afGain) / 255.0 }, + set: { radioViewModel.setAFGain(Int($0 * 255)) } + )) + + DialKnob(label: "RF GAIN", value: Binding( + get: { Double(radioViewModel.rfGain) / 255.0 }, + set: { radioViewModel.setRFGain(Int($0 * 255)) } + )) + } + .disabled(!radioViewModel.isConnected) + + Spacer() + + // Center - Main VFO dial + MainVFODial() + + Spacer() + + // Right side controls + VStack(spacing: 20) { + DialKnob(label: "SQL", value: Binding( + get: { Double(radioViewModel.squelch) / 255.0 }, + set: { radioViewModel.setSquelch(Int($0 * 255)) } + )) + + DialKnob(label: "MIC", value: Binding( + get: { Double(radioViewModel.micGain) / 100.0 }, + set: { radioViewModel.setMICGain(Int($0 * 100)) } + )) + } + .disabled(!radioViewModel.isConnected) + } + .padding(.horizontal, 40) + .padding(.vertical, 20) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Bottom section - Buttons + FrontPanelButtons() + .padding() + } + } + } +} + +// MARK: - Front Panel Display + +struct FrontPanelDisplay: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + var body: some View { + ZStack { + // LCD background + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + colors: [Color(red: 0.05, green: 0.15, blue: 0.1), Color(red: 0.02, green: 0.1, blue: 0.05)], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.5), lineWidth: 2) + ) + + VStack(spacing: 8) { + // Top row - Status indicators + HStack { + LCDIndicator(label: "VFO-A", isActive: radioViewModel.activeVFO == .a) + LCDIndicator(label: "VFO-B", isActive: radioViewModel.activeVFO == .b) + Spacer() + LCDIndicator(label: radioViewModel.mode.rawValue, isActive: true, color: .cyan) + Spacer() + LCDIndicator(label: "TX", isActive: radioViewModel.isTransmitting, color: .red) + } + .padding(.horizontal) + + // Main frequency display + HStack { + Spacer() + Text(radioViewModel.frequencyDisplay) + .font(.system(size: 56, weight: .bold, design: .monospaced)) + .foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5)) + .shadow(color: Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.5), radius: 10) + Text("Hz") + .font(.system(size: 20, weight: .medium, design: .monospaced)) + .foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.7)) + Spacer() + } + + // S-Meter + LCDSMeter(value: Double(radioViewModel.sMeter) / 255.0) + .padding(.horizontal) + + // Bottom row - Additional info + HStack { + Text("\(radioViewModel.power)W") + .foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.8)) + Spacer() + if let band = radioViewModel.currentBand { + Text(band.rawValue) + .foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.8)) + } + Spacer() + Text(radioViewModel.sMeterDisplay) + .foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.8)) + } + .font(.system(size: 14, design: .monospaced)) + .padding(.horizontal) + } + .padding() + } + .frame(height: 200) + } +} + +// MARK: - LCD Indicator + +struct LCDIndicator: View { + let label: String + let isActive: Bool + var color: Color = .green + + var body: some View { + Text(label) + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundColor(isActive ? color : color.opacity(0.3)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(isActive ? color.opacity(0.2) : Color.clear) + ) + } +} + +// MARK: - LCD S-Meter + +struct LCDSMeter: View { + let value: Double + + var body: some View { + VStack(spacing: 2) { + // Scale labels + HStack { + ForEach([1, 3, 5, 7, 9], id: \.self) { s in + Text("S\(s)") + .font(.system(size: 8, design: .monospaced)) + .foregroundColor(Color(red: 0.3, green: 1.0, blue: 0.5).opacity(0.5)) + if s < 9 { Spacer() } + } + Text("+20") + .font(.system(size: 8, design: .monospaced)) + .foregroundColor(Color.red.opacity(0.5)) + Spacer() + Text("+60") + .font(.system(size: 8, design: .monospaced)) + .foregroundColor(Color.red.opacity(0.5)) + } + + // Bar segments + HStack(spacing: 2) { + ForEach(0..<20, id: \.self) { i in + let threshold = Double(i) / 20.0 + let isLit = value >= threshold + let isRed = i >= 12 // Above S9 + + RoundedRectangle(cornerRadius: 1) + .fill(isLit ? (isRed ? Color.red : Color(red: 0.3, green: 1.0, blue: 0.5)) : Color.gray.opacity(0.2)) + .frame(height: 16) + } + } + } + } +} + +// MARK: - Dial Knob + +struct DialKnob: View { + let label: String + @Binding var value: Double + + @State private var isDragging = false + @State private var lastAngle: Double = 0 + + var body: some View { + VStack(spacing: 8) { + Text(label) + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.gray) + + ZStack { + // Knob base + Circle() + .fill( + LinearGradient( + colors: [Color(white: 0.3), Color(white: 0.15)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + Circle() + .stroke(Color.gray.opacity(0.5), lineWidth: 2) + ) + .shadow(color: .black.opacity(0.5), radius: 5, x: 2, y: 2) + + // Knob texture (ridges) + ForEach(0..<12, id: \.self) { i in + Rectangle() + .fill(Color.white.opacity(0.1)) + .frame(width: 1, height: 25) + .offset(y: -15) + .rotationEffect(.degrees(Double(i) * 30)) + } + + // Indicator line + Rectangle() + .fill(Color.white) + .frame(width: 3, height: 15) + .offset(y: -20) + .rotationEffect(.degrees(value * 270 - 135)) + } + .frame(width: 60, height: 60) + .gesture( + DragGesture() + .onChanged { gesture in + let center = CGPoint(x: 30, y: 30) + let location = gesture.location + let angle = atan2(location.y - center.y, location.x - center.x) + let degrees = angle * 180 / .pi + 90 + + if isDragging { + let delta = (degrees - lastAngle) / 270 + value = min(1, max(0, value + delta)) + } + + lastAngle = degrees + isDragging = true + } + .onEnded { _ in + isDragging = false + } + ) + + Text("\(Int(value * 100))%") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.gray) + } + } +} + +// MARK: - Main VFO Dial + +struct MainVFODial: View { + @EnvironmentObject var radioViewModel: RadioViewModel + @EnvironmentObject var settingsController: SettingsController + + @State private var rotation: Double = 0 + + var body: some View { + VStack(spacing: 12) { + Text("MAIN DIAL") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.gray) + + ZStack { + // Large dial + Circle() + .fill( + LinearGradient( + colors: [Color(white: 0.25), Color(white: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + Circle() + .stroke(Color.gray.opacity(0.5), lineWidth: 3) + ) + .shadow(color: .black.opacity(0.5), radius: 10, x: 4, y: 4) + + // Dial markings + ForEach(0..<36, id: \.self) { i in + Rectangle() + .fill(Color.white.opacity(i % 3 == 0 ? 0.3 : 0.1)) + .frame(width: i % 3 == 0 ? 2 : 1, height: i % 3 == 0 ? 20 : 10) + .offset(y: -65) + .rotationEffect(.degrees(Double(i) * 10 + rotation)) + } + + // Center cap + Circle() + .fill(Color(white: 0.2)) + .frame(width: 40, height: 40) + .overlay( + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .frame(width: 160, height: 160) + .gesture( + DragGesture() + .onChanged { gesture in + let delta = gesture.translation.width / 2 + rotation += delta + + // Convert rotation to frequency change + let steps = Int(delta / 10) + if steps != 0 { + for _ in 0.. 0 { + radioViewModel.incrementFrequency() + } else { + radioViewModel.decrementFrequency() + } + } + } + } + ) + .disabled(!radioViewModel.isConnected) + + // Step indicator + Picker("Step", selection: $settingsController.frequencyStep) { + ForEach(FrequencyStep.allCases, id: \.self) { step in + Text(step.displayName).tag(step) + } + } + .pickerStyle(.menu) + .frame(width: 100) + } + } +} + +// MARK: - Front Panel Buttons + +struct FrontPanelButtons: View { + @EnvironmentObject var radioViewModel: RadioViewModel + + var body: some View { + HStack(spacing: 12) { + // Mode buttons + Group { + PanelButton(label: "LSB", isActive: radioViewModel.mode == .lsb) { + radioViewModel.setMode(.lsb) + } + PanelButton(label: "USB", isActive: radioViewModel.mode == .usb) { + radioViewModel.setMode(.usb) + } + PanelButton(label: "CW", isActive: radioViewModel.mode == .cw) { + radioViewModel.setMode(.cw) + } + PanelButton(label: "FM", isActive: radioViewModel.mode == .fm) { + radioViewModel.setMode(.fm) + } + PanelButton(label: "AM", isActive: radioViewModel.mode == .am) { + radioViewModel.setMode(.am) + } + } + .disabled(!radioViewModel.isConnected) + + Spacer() + + // Function buttons + Group { + PanelButton(label: "NB", isActive: radioViewModel.noiseBlanker) { + radioViewModel.toggleNB() + } + PanelButton(label: "NR", isActive: radioViewModel.noiseReduction) { + radioViewModel.toggleNR() + } + PanelButton(label: "ATU", isActive: false, color: .orange) { + radioViewModel.startATUTune() + } + } + .disabled(!radioViewModel.isConnected) + + Spacer() + + // VFO buttons + Group { + PanelButton(label: "A/B", isActive: false) { + radioViewModel.swapVFO() + } + PanelButton(label: "SPLIT", isActive: radioViewModel.split) { + radioViewModel.toggleSplit() + } + } + .disabled(!radioViewModel.isConnected) + + Spacer() + + // PTT + PanelButton(label: radioViewModel.isTransmitting ? "RX" : "TX", + isActive: radioViewModel.isTransmitting, + color: .red, + size: .large) { + radioViewModel.toggleTransmit() + } + .disabled(!radioViewModel.isConnected) + } + } +} + +// MARK: - Panel Button + +struct PanelButton: View { + let label: String + let isActive: Bool + var color: Color = .green + var size: Size = .normal + let action: () -> Void + + enum Size { + case normal, large + } + + var body: some View { + Button(action: action) { + Text(label) + .font(.system(size: size == .large ? 14 : 11, weight: .bold)) + .foregroundColor(isActive ? .white : .gray) + .frame(width: size == .large ? 60 : 45, height: size == .large ? 40 : 30) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isActive ? color : Color(white: 0.2)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + .shadow(color: isActive ? color.opacity(0.5) : .clear, radius: 5) + ) + } + .buttonStyle(.plain) + } +} + +// MARK: - Preview + +#Preview { + SkeuomorphRadioView() + .environmentObject(RadioViewModel()) + .environmentObject(SettingsController()) + .frame(width: 900, height: 700) +} diff --git a/FT991A-Remote/README.md b/FT991A-Remote/README.md new file mode 100644 index 0000000..b25d953 --- /dev/null +++ b/FT991A-Remote/README.md @@ -0,0 +1,144 @@ +# FT-991A Remote Control App für macOS + +Eine native macOS-Anwendung zur Fernsteuerung des Yaesu FT-991A Amateurfunk-Transceivers über USB (CAT-Protokoll). + +## Features + +### Verbindung +- USB virtueller COM-Port (Silicon Labs CP210x) +- Auto-Reconnect bei Verbindungsabbruch +- Unterstützte Baudraten: 4800, 9600, 19200, 38400 (Standard), 57600, 115200 + +### Benutzeroberfläche +- **Modern View**: Modernes, abstraktes UI-Design +- **Skeuomorph View**: Originalgetreue Nachbildung des FT-991A Frontpanels +- Abdockbare Panels (Log, Debug, Audio, Metering) +- Menüleisten-Betrieb für Hintergrundbetrieb +- Lokalisierung: Deutsch & Englisch + +### Steuerung +- VFO A/B Frequenzsteuerung +- Betriebsarten: LSB, USB, CW, FM, AM, RTTY, DATA, C4FM +- Pegel: AF Gain, RF Gain, Squelch, MIC Gain, Power +- Funktionen: NB, NR, DNF, Contour, ATU, Split, IPO +- S-Meter, Power-Meter, SWR-Meter Anzeige +- PTT-Steuerung (Shift-Taste) + +### Logging +- QSO-Log im CSV-Format +- Felder: Call, Datum, Zeit, Frequenz, Mode, RST TX/RX, Name, QTH, Locator, Power, Notizen +- Wählbarer Speicherort (Standard: ~/Documents/FT991A-Logs/) +- Automatisches Speichern + +### Audio +- BlackHole Integration für digitale Betriebsarten +- Audio-Routing für WSJT-X, fldigi, etc. + +### Tastaturkürzel +| Taste | Funktion | +|-------|----------| +| ⌘K | Verbinden/Trennen | +| Shift (halten) | PTT | +| ↑ | ATU Tune | +| ← / → | Frequenz -/+ | +| ⇧⌘S | VFO A/B tauschen | +| ⇧⌘E | A=B | +| ⌥⌘D | Debug-Panel | +| ⌥⌘L | Log-Panel | + +## Systemanforderungen + +- macOS 15.0 (Sequoia) oder neuer +- Yaesu FT-991A mit USB-Kabel +- Silicon Labs CP210x Treiber (normalerweise automatisch installiert) + +## FT-991A Einstellungen + +Stelle sicher, dass im Radio-Menü folgende Einstellungen aktiv sind: + +``` +Menu → CAT RATE: 38400 bps +Menu → CAT TOT: 100 ms +Menu → CAT RTS: OFF +``` + +## Installation + +1. Projekt in Xcode öffnen +2. Build & Run (⌘R) + +Oder für Release-Build: +1. Product → Archive +2. Distribute App → Copy App + +## Projektstruktur + +``` +FT991A-Remote/ +├── FT991A_RemoteApp.swift # App Entry Point +├── Models/ +│ ├── RadioState.swift # Gerätezustand +│ ├── CATCommand.swift # CAT-Befehle +│ ├── QSOEntry.swift # Log-Einträge +│ └── Settings.swift # Einstellungen +├── Services/ +│ ├── SerialPortManager.swift # USB Serial +│ ├── CATProtocol.swift # CAT Parser +│ ├── CSVManager.swift # Log-Dateien +│ └── AudioRouter.swift # BlackHole +├── ViewModels/ +│ ├── RadioViewModel.swift # Radio-Logik +│ ├── LogViewModel.swift # Log-Logik +│ └── SettingsController.swift # Einstellungen +├── Views/ +│ ├── MainView.swift # Hauptfenster +│ ├── ModernView/ # Moderne UI +│ ├── SkeuomorphView/ # Frontpanel +│ ├── Panels/ # Abdockbare Panels +│ ├── Settings/ # Einstellungen +│ └── MenuBar/ # Menüleiste +└── Utilities/ + ├── Logger.swift # Logging + └── Localization/ # DE/EN +``` + +## CAT-Befehle + +Die App verwendet das Yaesu CAT-Protokoll. Wichtige Befehle: + +| Befehl | Funktion | +|--------|----------| +| FA; | VFO-A Frequenz lesen | +| FA014250000; | VFO-A auf 14.250 MHz setzen | +| MD02; | Mode auf USB setzen | +| TX0; | PTT ein (MIC) | +| RX; | PTT aus | +| SM0; | S-Meter lesen | + +## Entwicklung + +### Phase 1 (aktuell) +- ✅ Projekt-Setup +- ✅ SerialPortManager +- ✅ CAT-Protokoll Parser +- ✅ RadioState Model +- ✅ Debug-UI +- ✅ Logging-System + +### Phase 2-6 (geplant) +- Vollständiger CAT-Befehlssatz +- Erweiterte UI (Skeuomorph-Ansicht) +- QSO-Logging & CSV +- BlackHole Audio-Routing +- Tastaturkürzel +- Testing & Polish + +## Lizenz + +MIT License + +## Autor + +Entwickelt für Amateurfunk-Enthusiasten. + +73!