Compare commits

...

13 Commits

Author SHA1 Message Date
Claude 1e153f2f85 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)
2025-12-18 10:59:15 +00:00
admin 20904e2a96 Refactor SerialManager for VU1 Hub support
Refactor SerialManager for VU1 Dials Hub communication, update protocols, and improve connection management.
2025-12-14 22:06:09 +01:00
admin 841d1c09fc Merge pull request #6 from metacube2/claude/macos-audio-vu-meter-j8fVB
Add USB auto-probing to detect VU meter hardware
2025-12-14 11:46:57 +01:00
Claude f5e266b22b Add USB auto-probing to detect VU meter hardware
Features:
- Auto-probe scans all USB serial ports to find VU meter
- Tests multiple baud rates (115200, 9600, 57600, 38400, 19200)
- Tests all protocols (Raw, Text, JSON, VU-Server)
- Detects response from hardware to confirm connection
- Known USB device detection (CH340, CP210x, FTDI, Arduino, etc.)
- USB Vendor/Product ID display in port selection
- Quick Connect button for instant auto-connection
- Progress bar and status during probing
- Probe results display for debugging

USB detection:
- Reads USB idVendor/idProduct from IOKit registry
- Marks known VU meter devices with star icon
- Auto-selects detected VU meter port
2025-12-14 10:41:27 +00:00
admin 3dd5a1891f Merge pull request #5 from metacube2/claude/macos-audio-vu-meter-j8fVB
Add physical VU meter hardware support (4 dials)
2025-12-14 11:27:59 +01:00
Claude 52fa522d6d Add physical VU meter hardware support (4 dials)
New features:
- SerialManager for USB/Serial communication with hardware
- Support for 4 physical VU meter dials
- Flexible channel mapping: Audio L/R, Peak, Mono, CPU, RAM, Disk, Network
- Multiple protocols: Raw bytes, Text, JSON, VU-Server compatible
- Per-dial configuration: min/max values, inversion, smoothing
- Hardware panel in main view showing dial status
- Hardware settings sheet for configuration
- Auto-detection of USB serial devices

Protocol formats:
- Raw: [0xAA][D1][D2][D3][D4][0x55]
- Text: CH1:val;CH2:val;CH3:val;CH4:val\n
- JSON: {"dials":[d1,d2,d3,d4]}
- VU-Server: #0:val\n#1:val\n...
2025-12-14 10:15:20 +00:00
admin e4e08037c3 Merge pull request #4 from metacube2/claude/macos-audio-vu-meter-j8fVB
Create macOS app for audio level monitoring
2025-12-14 11:05:17 +01:00
Claude 2ad21cad58 Add macOS Audio VU Meter app with system monitoring
Features:
- Real-time audio level monitoring via BlackHole virtual audio device
- Classic VU meter display with dB scale (-60 to 0 dB)
- Peak hold indicators with configurable hold time
- System resource monitors: CPU, RAM, Disk, Network
- SwiftUI interface with dark theme
- Multi-device audio input selection
- Settings window for configuration

Built with AVAudioEngine for audio capture and Mach kernel APIs
for system statistics.
2025-12-14 10:03:56 +00:00
admin dd1d45d3e0 Merge pull request #3 from metacube2/claude/twelve-tone-synthesizer-01BgdmRVwhTdP8FRvAbntAqo
Build twelve-tone synthesizer with reverb in PHP
2025-12-13 17:29:21 +01:00
Claude a93e940b71 Add Twelve-Tone Synthesizer - Dodekaphonie nach Schönberg
Complete web-based synthesizer implementing Arnold Schönberg's
twelve-tone technique (Dodekaphonie) with:

- PHP backend for tone row generation and matrix calculation
- JavaScript Web Audio API for real-time sound synthesis
- Four row transformations: Original, Retrograde, Inversion, RI
- Convolver-based reverb effect with adjustable wet/dry mix
- Real-time audio visualization (waveform and spectrum)
- Interactive controls for tempo, octave, attack, release
- Multiple waveform options (sine, triangle, square, sawtooth)
- Full 12x12 twelve-tone matrix display
- Automatic continuous playback with random transformations
2025-12-13 16:26:02 +00:00
admin b50ef8bc00 Merge pull request #2 from metacube2/claude/paperless-finance-tool-01Te1nvY5VTkoZ9VFsZ16Jyk
Create Paperless finance reporting tool
2025-12-07 11:10:52 +01:00
Claude d2dd837f26 Add Paperless Finance Report Tool - Complete implementation
A Python CLI tool for generating financial reports from Paperless-ngx:

- Phase 1 (MVP): Config handling, Paperless API client with auth and
  pagination, custom fields extraction, tag-based summation, CLI output
- Phase 2 (Grouping): Multiple grouping criteria (tag, correspondent,
  category, payment type, month, quarter, year), percentage distribution
- Phase 3 (Reports): HTML reports with Chart.js diagrams (doughnut, bar,
  line charts), PDF export via WeasyPrint, JSON and CSV export
- Phase 4 (Comfort): Automatic tag ID resolution, disk caching with
  diskcache, colorized logging, comprehensive error handling

Features:
- Flexible date filtering (year, month, date range)
- Period comparison with change analysis
- Swiss franc formatting (CHF with apostrophe separators)
- Interactive HTML reports with sortable tables and document links
- Multiple output formats (CLI, HTML, PDF, JSON, CSV)
2025-12-07 10:09:10 +00:00
admin 3134418e6a Merge pull request #1 from metacube2/claude/github-sync-website-017YXsy55JgZ3uUCZx13NfZG
GitHub Sync Website with Apache Server
2025-12-06 10:55:26 +01:00
60 changed files with 14725 additions and 0 deletions
@@ -0,0 +1,362 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
A1000001229E3D0000000001 /* AudioVUMeterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000002229E3D0000000001 /* AudioVUMeterApp.swift */; };
A1000003229E3D0000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000004229E3D0000000002 /* ContentView.swift */; };
A1000005229E3D0000000003 /* VUMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000006229E3D0000000003 /* VUMeterView.swift */; };
A1000007229E3D0000000004 /* AudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000008229E3D0000000004 /* AudioEngine.swift */; };
A1000009229E3D0000000005 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000A229E3D0000000005 /* SystemMonitor.swift */; };
A100000B229E3D0000000006 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000C229E3D0000000006 /* SettingsView.swift */; };
A100000D229E3D0000000007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A100000E229E3D0000000007 /* Assets.xcassets */; };
A1000020229E3D0000000019 /* SerialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000021229E3D000000001A /* SerialManager.swift */; };
A1000022229E3D000000001B /* HardwareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000023229E3D000000001C /* HardwareView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
A1000002229E3D0000000001 /* AudioVUMeterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVUMeterApp.swift; sourceTree = "<group>"; };
A1000004229E3D0000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A1000006229E3D0000000003 /* VUMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VUMeterView.swift; sourceTree = "<group>"; };
A1000008229E3D0000000004 /* AudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngine.swift; sourceTree = "<group>"; };
A100000A229E3D0000000005 /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; };
A100000C229E3D0000000006 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A100000E229E3D0000000007 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A100000F229E3D0000000008 /* AudioVUMeter.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AudioVUMeter.entitlements; sourceTree = "<group>"; };
A1000010229E3D0000000009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A1000011229E3D000000000A /* AudioVUMeter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AudioVUMeter.app; sourceTree = BUILT_PRODUCTS_DIR; };
A1000021229E3D000000001A /* SerialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialManager.swift; sourceTree = "<group>"; };
A1000023229E3D000000001C /* HardwareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A1000012229E3D000000000B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A1000013229E3D000000000C = {
isa = PBXGroup;
children = (
A1000014229E3D000000000D /* AudioVUMeter */,
A1000015229E3D000000000E /* Products */,
);
sourceTree = "<group>";
};
A1000014229E3D000000000D /* AudioVUMeter */ = {
isa = PBXGroup;
children = (
A1000002229E3D0000000001 /* AudioVUMeterApp.swift */,
A1000004229E3D0000000002 /* ContentView.swift */,
A1000006229E3D0000000003 /* VUMeterView.swift */,
A1000008229E3D0000000004 /* AudioEngine.swift */,
A100000A229E3D0000000005 /* SystemMonitor.swift */,
A100000C229E3D0000000006 /* SettingsView.swift */,
A1000021229E3D000000001A /* SerialManager.swift */,
A1000023229E3D000000001C /* HardwareView.swift */,
A100000E229E3D0000000007 /* Assets.xcassets */,
A100000F229E3D0000000008 /* AudioVUMeter.entitlements */,
A1000010229E3D0000000009 /* Info.plist */,
);
path = AudioVUMeter;
sourceTree = "<group>";
};
A1000015229E3D000000000E /* Products */ = {
isa = PBXGroup;
children = (
A1000011229E3D000000000A /* AudioVUMeter.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A1000016229E3D000000000F /* AudioVUMeter */ = {
isa = PBXNativeTarget;
buildConfigurationList = A1000017229E3D0000000010 /* Build configuration list for PBXNativeTarget "AudioVUMeter" */;
buildPhases = (
A1000018229E3D0000000011 /* Sources */,
A1000012229E3D000000000B /* Frameworks */,
A1000019229E3D0000000012 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = AudioVUMeter;
productName = AudioVUMeter;
productReference = A1000011229E3D000000000A /* AudioVUMeter.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A100001A229E3D0000000013 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
A1000016229E3D000000000F = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = A100001B229E3D0000000014 /* Build configuration list for PBXProject "AudioVUMeter" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = A1000013229E3D000000000C;
productRefGroup = A1000015229E3D000000000E /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A1000016229E3D000000000F /* AudioVUMeter */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A1000019229E3D0000000012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A100000D229E3D0000000007 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A1000018229E3D0000000011 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1000001229E3D0000000001 /* AudioVUMeterApp.swift in Sources */,
A1000003229E3D0000000002 /* ContentView.swift in Sources */,
A1000005229E3D0000000003 /* VUMeterView.swift in Sources */,
A1000007229E3D0000000004 /* AudioEngine.swift in Sources */,
A1000009229E3D0000000005 /* SystemMonitor.swift in Sources */,
A100000B229E3D0000000006 /* SettingsView.swift in Sources */,
A1000020229E3D0000000019 /* SerialManager.swift in Sources */,
A1000022229E3D000000001B /* HardwareView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A100001C229E3D0000000015 /* 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;
MACOSX_DEPLOYMENT_TARGET = 13.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;
};
A100001D229E3D0000000016 /* 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;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
A100001E229E3D0000000017 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = AudioVUMeter/AudioVUMeter.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AudioVUMeter/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Audio VU Meter needs access to audio input to display audio levels from BlackHole or other audio devices.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.audiotools.AudioVUMeter;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
A100001F229E3D0000000018 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = AudioVUMeter/AudioVUMeter.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AudioVUMeter/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Audio VU Meter needs access to audio input to display audio levels from BlackHole or other audio devices.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.audiotools.AudioVUMeter;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A1000017229E3D0000000010 /* Build configuration list for PBXNativeTarget "AudioVUMeter" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A100001E229E3D0000000017 /* Debug */,
A100001F229E3D0000000018 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A100001B229E3D0000000014 /* Build configuration list for PBXProject "AudioVUMeter" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A100001C229E3D0000000015 /* Debug */,
A100001D229E3D0000000016 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A100001A229E3D0000000013 /* Project object */;
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.459",
"green" : "0.831",
"red" : "0.216"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+431
View File
@@ -0,0 +1,431 @@
//
// AudioEngine.swift
// AudioVUMeter
//
// Core Audio engine for capturing audio from BlackHole or any input device
// Calculates RMS levels and converts to dB for VU meter display
//
import Foundation
import AVFoundation
import CoreAudio
import Combine
/// Represents an available audio input device
struct AudioDevice: Identifiable, Hashable {
let id: AudioDeviceID
let name: String
let uid: String
let inputChannels: Int
}
/// Main audio engine class for capturing and analyzing audio levels
class AudioEngine: ObservableObject {
// MARK: - Published Properties
/// Current audio levels (0.0 to 1.0)
@Published var leftLevel: Double = 0
@Published var rightLevel: Double = 0
/// Peak levels with hold
@Published var leftPeak: Double = 0
@Published var rightPeak: Double = 0
/// Levels in dB (-inf to 0)
@Published var leftLevelDB: Double = -60
@Published var rightLevelDB: Double = -60
/// Engine state
@Published var isRunning = false
@Published var selectedDeviceID: AudioDeviceID = 0
@Published var selectedDeviceName: String = "No Device"
@Published var availableDevices: [AudioDevice] = []
/// Settings
@Published var referenceLevel: Double = -18 // Reference level in dB
@Published var peakHoldTime: Double = 2.0 // Peak hold time in seconds
// MARK: - Private Properties
private var audioEngine: AVAudioEngine?
private var inputNode: AVAudioInputNode?
private var peakResetTimers: [Timer] = []
private let levelSmoothingFactor: Double = 0.3
private var previousLeftLevel: Double = 0
private var previousRightLevel: Double = 0
// MARK: - Initialization
init() {
refreshDeviceList()
selectBlackHoleDevice()
}
// MARK: - Device Management
/// Refresh the list of available audio input devices
func refreshDeviceList() {
availableDevices = getInputDevices()
if availableDevices.isEmpty {
selectedDeviceName = "No Input Devices"
}
}
/// Get all available audio input devices
private func getInputDevices() -> [AudioDevice] {
var devices: [AudioDevice] = []
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var propertySize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&propertySize
)
guard status == noErr else { return devices }
let deviceCount = Int(propertySize) / MemoryLayout<AudioDeviceID>.size
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&propertySize,
&deviceIDs
)
guard status == noErr else { return devices }
for deviceID in deviceIDs {
// Check if device has input channels
let inputChannels = getDeviceInputChannels(deviceID: deviceID)
guard inputChannels > 0 else { continue }
// Get device name
let name = getDeviceName(deviceID: deviceID)
let uid = getDeviceUID(deviceID: deviceID)
devices.append(AudioDevice(
id: deviceID,
name: name,
uid: uid,
inputChannels: inputChannels
))
}
return devices
}
/// Get device name
private func getDeviceName(deviceID: AudioDeviceID) -> String {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceNameCFString,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var name: CFString = "" as CFString
var propertySize = UInt32(MemoryLayout<CFString>.size)
let status = AudioObjectGetPropertyData(
deviceID,
&propertyAddress,
0,
nil,
&propertySize,
&name
)
return status == noErr ? name as String : "Unknown Device"
}
/// Get device UID
private func getDeviceUID(deviceID: AudioDeviceID) -> String {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceUID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var uid: CFString = "" as CFString
var propertySize = UInt32(MemoryLayout<CFString>.size)
let status = AudioObjectGetPropertyData(
deviceID,
&propertyAddress,
0,
nil,
&propertySize,
&uid
)
return status == noErr ? uid as String : ""
}
/// Get number of input channels for a device
private func getDeviceInputChannels(deviceID: AudioDeviceID) -> Int {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain
)
var propertySize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(
deviceID,
&propertyAddress,
0,
nil,
&propertySize
)
guard status == noErr, propertySize > 0 else { return 0 }
let bufferListPointer = UnsafeMutablePointer<AudioBufferList>.allocate(capacity: Int(propertySize))
defer { bufferListPointer.deallocate() }
status = AudioObjectGetPropertyData(
deviceID,
&propertyAddress,
0,
nil,
&propertySize,
bufferListPointer
)
guard status == noErr else { return 0 }
let bufferList = bufferListPointer.pointee
var channelCount = 0
let buffers = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: bufferListPointer))
for buffer in buffers {
channelCount += Int(buffer.mNumberChannels)
}
return channelCount
}
/// Select BlackHole device if available
private func selectBlackHoleDevice() {
// Try to find BlackHole device
if let blackholeDevice = availableDevices.first(where: {
$0.name.lowercased().contains("blackhole")
}) {
selectedDeviceID = blackholeDevice.id
selectedDeviceName = blackholeDevice.name
return
}
// Fall back to first available device
if let firstDevice = availableDevices.first {
selectedDeviceID = firstDevice.id
selectedDeviceName = firstDevice.name
}
}
/// Switch to selected audio device
func switchDevice() {
let wasRunning = isRunning
if wasRunning {
stop()
}
if let device = availableDevices.first(where: { $0.id == selectedDeviceID }) {
selectedDeviceName = device.name
setSystemInputDevice(deviceID: selectedDeviceID)
}
if wasRunning {
start()
}
}
/// Set the system default input device
private func setSystemInputDevice(deviceID: AudioDeviceID) {
var deviceID = deviceID
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
UInt32(MemoryLayout<AudioDeviceID>.size),
&deviceID
)
}
// MARK: - Audio Engine Control
/// Start audio capture
func start() {
guard !isRunning else { return }
do {
audioEngine = AVAudioEngine()
guard let engine = audioEngine else { return }
inputNode = engine.inputNode
guard let input = inputNode else { return }
let format = input.outputFormat(forBus: 0)
// Install tap on input node to capture audio
input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer)
}
try engine.start()
isRunning = true
print("Audio engine started - capturing from: \(selectedDeviceName)")
print("Format: \(format)")
} catch {
print("Failed to start audio engine: \(error)")
isRunning = false
}
}
/// Stop audio capture
func stop() {
guard isRunning else { return }
inputNode?.removeTap(onBus: 0)
audioEngine?.stop()
audioEngine = nil
inputNode = nil
isRunning = false
// Reset levels
DispatchQueue.main.async {
self.leftLevel = 0
self.rightLevel = 0
self.leftLevelDB = -60
self.rightLevelDB = -60
}
print("Audio engine stopped")
}
/// Reset peak indicators
func resetPeaks() {
DispatchQueue.main.async {
self.leftPeak = 0
self.rightPeak = 0
}
}
// MARK: - Audio Processing
/// Process incoming audio buffer
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
guard let floatData = buffer.floatChannelData else { return }
let frameCount = Int(buffer.frameLength)
let channelCount = Int(buffer.format.channelCount)
var leftRMS: Float = 0
var rightRMS: Float = 0
// Calculate RMS for left channel
let leftChannel = floatData[0]
var leftSum: Float = 0
for i in 0..<frameCount {
let sample = leftChannel[i]
leftSum += sample * sample
}
leftRMS = sqrt(leftSum / Float(frameCount))
// Calculate RMS for right channel (or use left if mono)
if channelCount > 1 {
let rightChannel = floatData[1]
var rightSum: Float = 0
for i in 0..<frameCount {
let sample = rightChannel[i]
rightSum += sample * sample
}
rightRMS = sqrt(rightSum / Float(frameCount))
} else {
rightRMS = leftRMS
}
// Convert to dB
let leftDB = 20 * log10(max(leftRMS, 1e-10))
let rightDB = 20 * log10(max(rightRMS, 1e-10))
// Normalize to 0-1 range (assuming -60dB is silence)
let minDB: Float = -60
let maxDB: Float = 0
let normalizedLeft = Double(max(0, min(1, (leftDB - minDB) / (maxDB - minDB))))
let normalizedRight = Double(max(0, min(1, (rightDB - minDB) / (maxDB - minDB))))
// Apply smoothing
let smoothedLeft = previousLeftLevel * (1 - levelSmoothingFactor) + normalizedLeft * levelSmoothingFactor
let smoothedRight = previousRightLevel * (1 - levelSmoothingFactor) + normalizedRight * levelSmoothingFactor
previousLeftLevel = smoothedLeft
previousRightLevel = smoothedRight
// Update UI on main thread
DispatchQueue.main.async {
self.leftLevel = smoothedLeft
self.rightLevel = smoothedRight
self.leftLevelDB = Double(leftDB)
self.rightLevelDB = Double(rightDB)
// Update peaks
if smoothedLeft > self.leftPeak {
self.leftPeak = smoothedLeft
self.schedulePeakReset(channel: 0)
}
if smoothedRight > self.rightPeak {
self.rightPeak = smoothedRight
self.schedulePeakReset(channel: 1)
}
}
}
/// Schedule peak reset after hold time
private func schedulePeakReset(channel: Int) {
// Cancel existing timer for this channel
if channel < peakResetTimers.count {
peakResetTimers[channel].invalidate()
}
let timer = Timer.scheduledTimer(withTimeInterval: peakHoldTime, repeats: false) { [weak self] _ in
DispatchQueue.main.async {
if channel == 0 {
self?.leftPeak = self?.leftLevel ?? 0
} else {
self?.rightPeak = self?.rightLevel ?? 0
}
}
}
if peakResetTimers.count > channel {
peakResetTimers[channel] = timer
} else {
peakResetTimers.append(timer)
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,54 @@
//
// AudioVUMeterApp.swift
// AudioVUMeter
//
// macOS Audio VU Meter with System Monitoring
// Captures audio from BlackHole virtual audio device
// Outputs to physical VU meter hardware via Serial/USB
//
import SwiftUI
@main
struct AudioVUMeterApp: App {
@StateObject private var audioEngine = AudioEngine()
@StateObject private var systemMonitor = SystemMonitor()
@StateObject private var serialManager = SerialManager()
// Timer for updating hardware values
@State private var updateTimer: Timer?
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(audioEngine)
.environmentObject(systemMonitor)
.environmentObject(serialManager)
.onAppear {
startHardwareUpdateTimer()
}
.onDisappear {
stopHardwareUpdateTimer()
}
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
Settings {
SettingsView()
.environmentObject(audioEngine)
.environmentObject(serialManager)
}
}
private func startHardwareUpdateTimer() {
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { _ in
serialManager.updateValues(audioEngine: audioEngine, systemMonitor: systemMonitor)
}
}
private func stopHardwareUpdateTimer() {
updateTimer?.invalidate()
updateTimer = nil
}
}
+343
View File
@@ -0,0 +1,343 @@
//
// ContentView.swift
// AudioVUMeter
//
// Main view containing all VU meters and hardware output
//
import SwiftUI
struct ContentView: View {
@EnvironmentObject var audioEngine: AudioEngine
@EnvironmentObject var systemMonitor: SystemMonitor
@EnvironmentObject var serialManager: SerialManager
@State private var showSettings = false
@State private var showHardwareSettings = false
var body: some View {
ZStack {
// Background gradient
LinearGradient(
gradient: Gradient(colors: [
Color(red: 0.1, green: 0.1, blue: 0.15),
Color(red: 0.05, green: 0.05, blue: 0.1)
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 16) {
// Header
HStack {
Text("Audio VU Meter")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(.white)
Spacer()
// Hardware settings button
Button(action: { showHardwareSettings.toggle() }) {
Image(systemName: "cable.connector")
.font(.system(size: 16))
.foregroundColor(serialManager.isConnected ? .green : .gray)
}
.buttonStyle(.plain)
.help("Hardware Settings")
// Settings button
Button(action: { showSettings.toggle() }) {
Image(systemName: "gear")
.font(.system(size: 18))
.foregroundColor(.gray)
}
.buttonStyle(.plain)
.popover(isPresented: $showSettings) {
QuickSettingsView()
.environmentObject(audioEngine)
}
}
.padding(.horizontal)
.padding(.top, 10)
// Audio device info
HStack {
Circle()
.fill(audioEngine.isRunning ? Color.green : Color.red)
.frame(width: 8, height: 8)
Text(audioEngine.selectedDeviceName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.gray)
Spacer()
Text(audioEngine.isRunning ? "ACTIVE" : "STOPPED")
.font(.system(size: 10, weight: .semibold, design: .monospaced))
.foregroundColor(audioEngine.isRunning ? .green : .red)
}
.padding(.horizontal)
Divider()
.background(Color.gray.opacity(0.3))
// Audio VU Meters
VStack(spacing: 15) {
Text("AUDIO LEVELS")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
HStack(spacing: 30) {
// Left Channel
VUMeterView(
level: audioEngine.leftLevel,
peakLevel: audioEngine.leftPeak,
label: "L",
colorScheme: .audio
)
// Right Channel
VUMeterView(
level: audioEngine.rightLevel,
peakLevel: audioEngine.rightPeak,
label: "R",
colorScheme: .audio
)
}
// dB Display
HStack(spacing: 40) {
VStack {
Text(String(format: "%.1f dB", audioEngine.leftLevelDB))
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(dbColor(for: audioEngine.leftLevelDB))
Text("LEFT")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
}
VStack {
Text(String(format: "%.1f dB", audioEngine.rightLevelDB))
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(dbColor(for: audioEngine.rightLevelDB))
Text("RIGHT")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.3))
)
.padding(.horizontal)
Divider()
.background(Color.gray.opacity(0.3))
// System Monitors
VStack(spacing: 15) {
Text("SYSTEM MONITOR")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
HStack(spacing: 25) {
// CPU Meter
SystemMeterView(
value: systemMonitor.cpuUsage,
label: "CPU",
unit: "%",
colorScheme: .cpu
)
// RAM Meter
SystemMeterView(
value: systemMonitor.memoryUsage,
label: "RAM",
unit: "%",
colorScheme: .ram
)
// Disk I/O Meter
SystemMeterView(
value: systemMonitor.diskActivity,
label: "DISK",
unit: "%",
colorScheme: .disk
)
// Network Meter
SystemMeterView(
value: systemMonitor.networkActivity,
label: "NET",
unit: "%",
colorScheme: .network
)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.3))
)
.padding(.horizontal)
Divider()
.background(Color.gray.opacity(0.3))
// Hardware Output Panel
HardwarePanelView()
.environmentObject(serialManager)
// Control buttons
HStack(spacing: 15) {
Button(action: {
if audioEngine.isRunning {
audioEngine.stop()
} else {
audioEngine.start()
}
}) {
HStack {
Image(systemName: audioEngine.isRunning ? "stop.fill" : "play.fill")
Text(audioEngine.isRunning ? "Stop" : "Start")
}
.frame(width: 80)
}
.buttonStyle(ControlButtonStyle(color: audioEngine.isRunning ? .red : .green))
Button(action: {
audioEngine.resetPeaks()
}) {
HStack {
Image(systemName: "arrow.counterclockwise")
Text("Reset")
}
.frame(width: 80)
}
.buttonStyle(ControlButtonStyle(color: .orange))
}
.padding(.bottom, 15)
}
}
}
.frame(width: 400, height: 750)
.sheet(isPresented: $showHardwareSettings) {
HardwareSettingsSheet()
.environmentObject(serialManager)
}
.onAppear {
audioEngine.start()
systemMonitor.startMonitoring()
}
.onDisappear {
audioEngine.stop()
systemMonitor.stopMonitoring()
serialManager.disconnect()
}
}
private func dbColor(for db: Double) -> Color {
if db > -3 { return .red }
if db > -10 { return .orange }
if db > -20 { return .yellow }
return .green
}
}
// MARK: - Hardware Settings Sheet
struct HardwareSettingsSheet: View {
@EnvironmentObject var serialManager: SerialManager
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Hardware Configuration")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
.padding()
.background(Color(nsColor: .windowBackgroundColor))
Divider()
// Settings content
HardwareSettingsView()
.environmentObject(serialManager)
}
.frame(width: 500, height: 600)
}
}
// MARK: - Quick Settings Popover
struct QuickSettingsView: View {
@EnvironmentObject var audioEngine: AudioEngine
var body: some View {
VStack(alignment: .leading, spacing: 15) {
Text("Audio Device")
.font(.headline)
Picker("Device", selection: $audioEngine.selectedDeviceID) {
ForEach(audioEngine.availableDevices, id: \.id) { device in
Text(device.name).tag(device.id)
}
}
.labelsHidden()
.frame(width: 250)
.onChange(of: audioEngine.selectedDeviceID) { _ in
audioEngine.switchDevice()
}
Divider()
Text("Reference Level")
.font(.headline)
HStack {
Text("-60 dB")
.font(.caption)
Slider(value: $audioEngine.referenceLevel, in: -60...0)
Text("0 dB")
.font(.caption)
}
Text("Peak Hold Time: \(Int(audioEngine.peakHoldTime))s")
.font(.caption)
Slider(value: $audioEngine.peakHoldTime, in: 0.5...5.0)
}
.padding()
.frame(width: 300)
}
}
// MARK: - Control Button Style
struct ControlButtonStyle: ButtonStyle {
let color: Color
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 15)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(color.opacity(configuration.isPressed ? 0.6 : 0.8))
)
}
}
#Preview {
ContentView()
.environmentObject(AudioEngine())
.environmentObject(SystemMonitor())
.environmentObject(SerialManager())
}
@@ -0,0 +1,524 @@
//
// HardwareView.swift
// AudioVUMeter
//
// Hardware configuration and monitoring view for physical VU meters
// Includes auto-probe functionality to detect connected hardware
//
import SwiftUI
// MARK: - Hardware Panel in Main View
struct HardwarePanelView: View {
@EnvironmentObject var serialManager: SerialManager
var body: some View {
VStack(spacing: 15) {
// Header
HStack {
Text("HARDWARE OUTPUT")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
Spacer()
// Connection status
HStack(spacing: 6) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
Text(statusText)
.font(.system(size: 9, weight: .semibold, design: .monospaced))
.foregroundColor(statusColor)
}
}
// Probing progress
if serialManager.isProbing {
VStack(spacing: 8) {
ProgressView(value: serialManager.probeProgress)
.progressViewStyle(.linear)
Text(serialManager.probeStatus)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.orange)
}
} else {
// 4 Physical Dial Indicators
HStack(spacing: 15) {
ForEach(0..<4) { index in
DialIndicatorView(
dialNumber: index + 1,
value: serialManager.dialValues[index],
channelName: shortChannelName(serialManager.dialConfigs[index].dialChannel),
isConnected: serialManager.isConnected
)
}
}
}
// Buttons
HStack(spacing: 10) {
// Auto-probe button
Button(action: {
if serialManager.isProbing {
serialManager.stopAutoProbe()
} else {
serialManager.startAutoProbe()
}
}) {
HStack {
Image(systemName: serialManager.isProbing ? "stop.fill" : "magnifyingglass")
Text(serialManager.isProbing ? "Stop" : "Auto-Find")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(ProbeButtonStyle(isProbing: serialManager.isProbing))
.disabled(serialManager.isConnected)
// Connect button
Button(action: {
serialManager.toggleConnection()
}) {
HStack {
Image(systemName: serialManager.isConnected ? "antenna.radiowaves.left.and.right.slash" : "antenna.radiowaves.left.and.right")
Text(serialManager.isConnected ? "Disconnect" : "Connect")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(HardwareButtonStyle(isConnected: serialManager.isConnected))
.disabled(serialManager.isProbing)
}
// Stats / Device info
if serialManager.isConnected {
HStack {
Text("TX: \(formatBytes(serialManager.bytesSent))")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
Spacer()
Text(serialManager.selectedPortPath.components(separatedBy: "/").last ?? "")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
}
} else if let detected = serialManager.detectedDevice {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.system(size: 10))
Text("Found: \(detected.name)")
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.green)
Spacer()
if let vid = detected.vendorID, let pid = detected.productID {
Text(String(format: "%04X:%04X", vid, pid))
.font(.system(size: 8, design: .monospaced))
.foregroundColor(.gray)
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.3))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(borderColor, lineWidth: 1)
)
)
.padding(.horizontal)
}
private var statusColor: Color {
if serialManager.isProbing { return .orange }
if serialManager.isConnected { return .green }
return .red
}
private var statusText: String {
if serialManager.isProbing { return "PROBING" }
if serialManager.isConnected { return "CONNECTED" }
return "DISCONNECTED"
}
private var borderColor: Color {
if serialManager.isProbing { return .orange.opacity(0.3) }
if serialManager.isConnected { return .green.opacity(0.3) }
return .clear
}
private func shortChannelName(_ channel: DialChannel) -> String {
switch channel {
case .audioLeft: return "L"
case .audioRight: return "R"
case .audioPeak: return "PK"
case .audioMono: return "M"
case .cpu: return "CPU"
case .ram: return "RAM"
case .disk: return "DSK"
case .network: return "NET"
}
}
private func formatBytes(_ bytes: UInt64) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}
// MARK: - Single Dial Indicator
struct DialIndicatorView: View {
let dialNumber: Int
let value: Int
let channelName: String
let isConnected: Bool
var body: some View {
VStack(spacing: 4) {
// Dial number
Text("D\(dialNumber)")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
// Value arc
ZStack {
// Background arc
Circle()
.trim(from: 0.25, to: 0.75)
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(180))
// Value arc
Circle()
.trim(from: 0.25, to: 0.25 + (Double(value) / 255.0) * 0.5)
.stroke(
isConnected ? dialColor(for: value) : Color.gray,
style: StrokeStyle(lineWidth: 4, lineCap: .round)
)
.frame(width: 50, height: 50)
.rotationEffect(.degrees(180))
// Value text
VStack(spacing: 0) {
Text("\(value)")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(isConnected ? .white : .gray)
}
}
// Channel name
Text(channelName)
.font(.system(size: 9, weight: .semibold, design: .monospaced))
.foregroundColor(channelColor(channelName))
}
}
private func dialColor(for value: Int) -> Color {
let ratio = Double(value) / 255.0
if ratio > 0.9 { return .red }
if ratio > 0.75 { return .orange }
if ratio > 0.5 { return .yellow }
return .green
}
private func channelColor(_ name: String) -> Color {
switch name {
case "L", "R", "PK", "M": return .green
case "CPU": return .blue
case "RAM": return .purple
case "DSK": return .teal
case "NET": return .indigo
default: return .gray
}
}
}
// MARK: - Hardware Settings View
struct HardwareSettingsView: View {
@EnvironmentObject var serialManager: SerialManager
var body: some View {
Form {
// Auto-Probe Section
Section("Auto-Detect Hardware") {
HStack {
Button(action: {
if serialManager.isProbing {
serialManager.stopAutoProbe()
} else {
serialManager.startAutoProbe()
}
}) {
HStack {
Image(systemName: serialManager.isProbing ? "stop.fill" : "magnifyingglass.circle.fill")
Text(serialManager.isProbing ? "Stop Probing" : "Auto-Detect VU Meter")
}
}
.disabled(serialManager.isConnected)
Spacer()
Button("Quick Connect") {
serialManager.autoConnect()
}
.disabled(serialManager.isConnected || serialManager.isProbing)
}
if serialManager.isProbing {
VStack(alignment: .leading, spacing: 8) {
ProgressView(value: serialManager.probeProgress) {
Text(serialManager.probeStatus)
.font(.caption)
}
}
}
if let detected = serialManager.detectedDevice {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
VStack(alignment: .leading) {
Text("Detected: \(detected.name)")
.font(.headline)
if let vid = detected.vendorID, let pid = detected.productID {
Text(String(format: "USB ID: %04X:%04X", vid, pid))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// Connection Section
Section("Serial Connection") {
// Port selection with USB info
Picker("Port", selection: $serialManager.selectedPortPath) {
Text("Select Port...").tag("")
ForEach(serialManager.availablePorts) { port in
HStack {
if port.isVUMeter {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
Text(port.name)
if let vid = port.vendorID, let pid = port.productID {
Text(String(format: "(%04X:%04X)", vid, pid))
.foregroundColor(.secondary)
.font(.caption)
}
}
.tag(port.path)
}
}
HStack {
Button(action: { serialManager.refreshPorts() }) {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderless)
Spacer()
Text("\(serialManager.availablePorts.count) ports found")
.font(.caption)
.foregroundColor(.secondary)
}
// Baud rate
Picker("Baud Rate", selection: $serialManager.baudRate) {
ForEach(SerialManager.availableBaudRates, id: \.self) { rate in
Text("\(rate)").tag(rate)
}
}
// Protocol
Picker("Protocol", selection: $serialManager.selectedProtocol) {
ForEach(SerialProtocol.allCases) { proto in
Text(proto.rawValue).tag(proto)
}
}
// Connect button
Button(action: { serialManager.toggleConnection() }) {
HStack {
Image(systemName: serialManager.isConnected ? "bolt.slash.fill" : "bolt.fill")
Text(serialManager.isConnected ? "Disconnect" : "Connect")
}
}
.foregroundColor(serialManager.isConnected ? .red : .green)
.disabled(serialManager.isProbing)
}
// Dial Configuration Section
Section("Dial Assignments") {
ForEach(0..<4) { index in
DialConfigRow(
dialNumber: index + 1,
config: $serialManager.dialConfigs[index]
)
}
}
// Advanced Settings
Section("Advanced") {
ForEach(0..<4) { index in
DisclosureGroup("Dial \(index + 1) Settings") {
HStack {
Text("Min Value")
Spacer()
TextField("0", value: $serialManager.dialConfigs[index].minValue, format: .number)
.frame(width: 60)
.textFieldStyle(.roundedBorder)
}
HStack {
Text("Max Value")
Spacer()
TextField("255", value: $serialManager.dialConfigs[index].maxValue, format: .number)
.frame(width: 60)
.textFieldStyle(.roundedBorder)
}
Toggle("Invert", isOn: $serialManager.dialConfigs[index].inverted)
HStack {
Text("Smoothing")
Slider(value: $serialManager.dialConfigs[index].smoothing, in: 0...0.9)
Text("\(Int(serialManager.dialConfigs[index].smoothing * 100))%")
.frame(width: 40)
}
}
}
}
// Probe Results (for debugging)
if !serialManager.probeResults.isEmpty {
Section("Probe Results") {
ForEach(serialManager.probeResults.indices, id: \.self) { index in
let result = serialManager.probeResults[index]
HStack {
Image(systemName: result.success ? "checkmark.circle" : "xmark.circle")
.foregroundColor(result.success ? .green : .red)
VStack(alignment: .leading) {
Text(result.port.name)
.font(.caption)
Text("\(result.baudRate) baud - \(result.protocol_.rawValue)")
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
if let response = result.response {
Text(response.prefix(20) + "...")
.font(.caption2)
.foregroundColor(.green)
}
}
}
Button("Clear Results") {
serialManager.probeResults.removeAll()
}
}
}
// Protocol Info
Section("Protocol Reference") {
VStack(alignment: .leading, spacing: 8) {
protocolInfo
}
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.formStyle(.grouped)
}
@ViewBuilder
private var protocolInfo: some View {
switch serialManager.selectedProtocol {
case .rawBytes:
Text("Format: [0xAA] [D1] [D2] [D3] [D4] [0x55]")
Text("Values: 0-255 per dial")
case .textCommand:
Text("Format: CH1:val;CH2:val;CH3:val;CH4:val\\n")
Text("Values: 0-255 per channel")
case .json:
Text("Format: {\"dials\":[d1,d2,d3,d4]}\\n")
Text("Values: 0-255 array")
case .vuServer:
Text("Format: #0:val\\n#1:val\\n#2:val\\n#3:val\\n")
Text("Values: 0-100 percentage per dial")
}
}
}
// MARK: - Dial Config Row
struct DialConfigRow: View {
let dialNumber: Int
@Binding var config: DialConfig
var body: some View {
HStack {
Text("Dial \(dialNumber)")
.font(.system(.body, design: .monospaced))
.frame(width: 60, alignment: .leading)
Picker("", selection: $config.dialChannel) {
ForEach(DialChannel.allCases) { channel in
Text(channel.rawValue).tag(channel)
}
}
.labelsHidden()
}
}
}
// MARK: - Button Styles
struct HardwareButtonStyle: ButtonStyle {
let isConnected: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isConnected ? Color.red.opacity(0.7) : Color.green.opacity(0.7))
.opacity(configuration.isPressed ? 0.6 : 1.0)
)
}
}
struct ProbeButtonStyle: ButtonStyle {
let isProbing: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isProbing ? Color.orange.opacity(0.7) : Color.blue.opacity(0.7))
.opacity(configuration.isPressed ? 0.6 : 1.0)
)
}
}
// MARK: - Preview
#Preview {
HardwareSettingsView()
.environmentObject(SerialManager())
.frame(width: 500, height: 700)
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2024. All rights reserved.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Audio VU Meter needs access to audio input to display audio levels from BlackHole or other audio devices.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
@@ -0,0 +1,485 @@
//
// SerialManager.swift
// AudioVUMeter
//
// Direct VU1 Dials Hub communication - Native Swift
// Protocol: >{CMD:02X}{TYPE:02X}{LEN:04X}{DATA}\r\n
//
import Foundation
import IOKit
import IOKit.serial
// MARK: - VU1 Protocol Constants
private struct VU1 {
// Commands (from Comms_Hub_Server.py)
static let CMD_SET_DIAL_PERC_SINGLE: UInt8 = 0x03
static let CMD_RESCAN_BUS: UInt8 = 0x0C
static let CMD_GET_DEVICES_MAP: UInt8 = 0x07
// Data Types
static let DATA_NONE: UInt8 = 0x01
static let DATA_KEY_VALUE_PAIR: UInt8 = 0x04
// Serial
static let BAUD: speed_t = 115200
static let SUFFIX = "\r\n"
}
// MARK: - Serial Protocol Enum
enum SerialProtocol: String, CaseIterable, Identifiable {
case rawBytes = "Raw Bytes (0-255)"
case textCommand = "Text Commands"
case json = "JSON Format"
case vuServer = "VU1 Direct (Native)"
var id: String { rawValue }
}
// MARK: - Serial Port
struct SerialPort: Identifiable, Hashable {
let id: String
let path: String
let name: String
let vendorID: Int?
let productID: Int?
let isVUMeter: Bool
init(path: String, name: String, vendorID: Int? = nil, productID: Int? = nil, isVUMeter: Bool = false) {
self.id = path
self.path = path
self.name = name
self.vendorID = vendorID
self.productID = productID
self.isVUMeter = isVUMeter
}
}
// MARK: - Probe Result
struct ProbeResult {
let port: SerialPort
let protocol_: SerialProtocol
let baudRate: Int
let success: Bool
let response: String?
let responseTime: TimeInterval
}
// MARK: - Dial Channel
enum DialChannel: String, CaseIterable, Identifiable {
case audioLeft = "Audio Left"
case audioRight = "Audio Right"
case cpu = "CPU Usage"
case ram = "RAM Usage"
case disk = "Disk Activity"
case network = "Network Activity"
case audioPeak = "Audio Peak"
case audioMono = "Audio Mono (L+R)"
var id: String { rawValue }
}
// MARK: - Dial Configuration
struct DialConfig: Identifiable, Codable {
let id: Int
var channel: String
var minValue: Int
var maxValue: Int
var inverted: Bool
var smoothing: Double
init(id: Int, channel: DialChannel = .audioLeft) {
self.id = id
self.channel = channel.rawValue
self.minValue = 0
self.maxValue = 100
self.inverted = false
self.smoothing = 0.3
}
var dialChannel: DialChannel {
get { DialChannel(rawValue: channel) ?? .audioLeft }
set { channel = newValue.rawValue }
}
}
// MARK: - Serial Manager
class SerialManager: ObservableObject {
// MARK: - Published Properties
@Published var isConnected = false
@Published var availablePorts: [SerialPort] = []
@Published var selectedPortPath: String = ""
@Published var selectedProtocol: SerialProtocol = .vuServer
@Published var baudRate: Int = 115200
@Published var dialConfigs: [DialConfig] = []
@Published var lastError: String?
@Published var bytesSent: UInt64 = 0
@Published var isProbing = false
@Published var probeProgress: Double = 0
@Published var probeStatus: String = ""
@Published var detectedDevice: SerialPort?
@Published var probeResults: [ProbeResult] = []
@Published var dialValues: [Int] = [0, 0, 0, 0]
// MARK: - Private Properties
private var fileDescriptor: Int32 = -1
private var writeQueue = DispatchQueue(label: "vu1.write", qos: .userInteractive)
private var updateTimer: Timer?
private let updateInterval: TimeInterval = 1.0 / 30.0
private var smoothedValues: [Double] = [0, 0, 0, 0]
private var lastSentValues: [Int] = [-1, -1, -1, -1]
// MARK: - Initialization
init() {
dialConfigs = [
DialConfig(id: 0, channel: .audioLeft),
DialConfig(id: 1, channel: .audioRight),
DialConfig(id: 2, channel: .cpu),
DialConfig(id: 3, channel: .ram)
]
refreshPorts()
}
deinit {
disconnect()
}
// MARK: - Port Discovery
func refreshPorts() {
availablePorts = findSerialPorts()
// Auto-select VU1 Hub (usbserial)
if let vu1 = availablePorts.first(where: { $0.isVUMeter }) {
selectedPortPath = vu1.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 }
guard path.contains("cu.") else { continue }
var name = path.components(separatedBy: "/").last ?? "Unknown"
var vendorID: Int?
var productID: Int?
var isVU1 = false
// Check for usbserial (FT230X = VU1 Hub)
if path.contains("usbserial") {
isVU1 = true
name = "VU1 Dials Hub"
}
// Walk registry for USB 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 {
name = usbName
}
// FTDI FT230X = VU1 Hub
if vendorID == 0x0403 && (productID == 0x6015 || productID == 0x6001) {
isVU1 = true
name = "VU1 Dials Hub"
}
if vendorID != nil && productID != nil { break }
}
IOObjectRelease(current)
ports.append(SerialPort(path: path, name: name, vendorID: vendorID, productID: productID, isVUMeter: isVU1))
}
IOObjectRelease(iterator)
return ports.sorted { ($0.isVUMeter ? 0 : 1, $0.name) < ($1.isVUMeter ? 0 : 1, $1.name) }
}
// MARK: - Connection
func connect() {
guard !selectedPortPath.isEmpty else {
lastError = "No port selected"
return
}
// Open port
fileDescriptor = open(selectedPortPath, O_RDWR | O_NOCTTY | O_NONBLOCK)
guard fileDescriptor != -1 else {
lastError = "Failed to open: \(String(cString: strerror(errno)))"
return
}
// Configure 115200 8N1
var options = termios()
tcgetattr(fileDescriptor, &options)
cfsetispeed(&options, speed_t(B115200))
cfsetospeed(&options, speed_t(B115200))
// 8N1, no flow control
options.c_cflag &= ~UInt(PARENB | CSTOPB | CSIZE | CRTSCTS)
options.c_cflag |= UInt(CS8 | CREAD | CLOCAL)
// Raw mode
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
options.c_cc.17 = 10 // VTIME = 1 second
tcsetattr(fileDescriptor, TCSANOW, &options)
tcflush(fileDescriptor, TCIOFLUSH)
isConnected = true
lastError = nil
lastSentValues = [-1, -1, -1, -1]
print("VU1 Hub connected: \(selectedPortPath)")
// Initialize: Rescan bus
sendCommand(cmd: VU1.CMD_RESCAN_BUS, dataType: VU1.DATA_NONE, data: [])
usleep(500_000) // Wait 500ms for rescan
// Set all dials to 0
for i in 0..<4 {
setDialValue(dialIndex: UInt8(i), value: 0)
usleep(20_000)
}
startUpdateTimer()
}
func disconnect() {
stopUpdateTimer()
if fileDescriptor != -1 {
// Reset dials to 0
for i in 0..<4 {
setDialValue(dialIndex: UInt8(i), value: 0)
usleep(10_000)
}
usleep(100_000)
close(fileDescriptor)
fileDescriptor = -1
}
isConnected = false
print("VU1 Hub disconnected")
}
func toggleConnection() {
if isConnected { disconnect() } else { connect() }
}
func autoConnect() {
refreshPorts()
if let vu1 = availablePorts.first(where: { $0.isVUMeter }) {
selectedPortPath = vu1.path
connect()
} else if let first = availablePorts.first {
selectedPortPath = first.path
connect()
} else {
lastError = "No serial ports found"
}
}
// MARK: - VU1 Protocol
/// Send VU1 command: >{CMD:02X}{TYPE:02X}{LEN:04X}{DATA}\r\n
private func sendCommand(cmd: UInt8, dataType: UInt8, data: [UInt8]) {
guard fileDescriptor != -1 else { return }
let dataLen = data.count
var cmdString = String(format: ">%02X%02X%04X", cmd, dataType, dataLen)
for byte in data {
cmdString += String(format: "%02X", byte)
}
cmdString += VU1.SUFFIX
guard let cmdData = cmdString.data(using: .ascii) else { return }
let written = cmdData.withUnsafeBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return -1 }
return write(fileDescriptor, base, cmdData.count)
}
if written > 0 {
bytesSent += UInt64(written)
}
}
/// Set dial value (0-100%)
private func setDialValue(dialIndex: UInt8, value: Int) {
let clampedValue = UInt8(max(0, min(100, value)))
// CMD: 0x03 = SET_DIAL_PERC_SINGLE
// TYPE: 0x04 = KEY_VALUE_PAIR
// DATA: [dial_index, value]
sendCommand(
cmd: VU1.CMD_SET_DIAL_PERC_SINGLE,
dataType: VU1.DATA_KEY_VALUE_PAIR,
data: [dialIndex, clampedValue]
)
}
// MARK: - Value Updates
func updateValues(audioEngine: AudioEngine, systemMonitor: SystemMonitor) {
for (index, config) in dialConfigs.enumerated() {
guard index < 4 else { break }
var rawValue: Double = 0
switch config.dialChannel {
case .audioLeft:
rawValue = audioEngine.leftLevel * 100
case .audioRight:
rawValue = audioEngine.rightLevel * 100
case .audioPeak:
rawValue = max(audioEngine.leftPeak, audioEngine.rightPeak) * 100
case .audioMono:
rawValue = ((audioEngine.leftLevel + audioEngine.rightLevel) / 2) * 100
case .cpu:
rawValue = systemMonitor.cpuUsage
case .ram:
rawValue = systemMonitor.memoryUsage
case .disk:
rawValue = systemMonitor.diskActivity
case .network:
rawValue = systemMonitor.networkActivity
}
// Smoothing
smoothedValues[index] = smoothedValues[index] * config.smoothing + rawValue * (1 - config.smoothing)
var value = Int(smoothedValues[index])
if config.inverted { value = 100 - value }
dialValues[index] = max(0, min(100, value))
}
}
func sendValues() {
guard isConnected, fileDescriptor != -1 else { return }
writeQueue.async { [weak self] in
guard let self = self else { return }
for (index, value) in self.dialValues.enumerated() {
// Only send if changed
if value != self.lastSentValues[index] {
self.setDialValue(dialIndex: UInt8(index), value: value)
self.lastSentValues[index] = value
usleep(5_000) // 5ms between commands
}
}
}
}
// MARK: - Timer
private func startUpdateTimer() {
stopUpdateTimer()
updateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] _ in
self?.sendValues()
}
}
private func stopUpdateTimer() {
updateTimer?.invalidate()
updateTimer = nil
}
// MARK: - Auto-Probe
func startAutoProbe() {
isProbing = true
probeProgress = 0
probeStatus = "Searching for VU1 Hub..."
DispatchQueue.global().async { [weak self] in
guard let self = self else { return }
for (index, port) in self.availablePorts.enumerated() {
DispatchQueue.main.async {
self.probeProgress = Double(index + 1) / Double(self.availablePorts.count)
self.probeStatus = "Checking: \(port.name)"
}
if port.isVUMeter || port.path.contains("usbserial") {
DispatchQueue.main.async {
self.detectedDevice = port
self.selectedPortPath = port.path
self.probeStatus = "Found: \(port.name)"
self.isProbing = false
}
return
}
}
DispatchQueue.main.async {
self.isProbing = false
self.probeStatus = "No VU1 Hub found"
}
}
}
func stopAutoProbe() {
isProbing = false
}
// MARK: - Static
static let availableBaudRates = [9600, 19200, 38400, 57600, 115200, 230400]
}
@@ -0,0 +1,145 @@
//
// SettingsView.swift
// AudioVUMeter
//
// Settings window for configuring audio device, hardware output, and preferences
//
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var audioEngine: AudioEngine
@EnvironmentObject var serialManager: SerialManager
@AppStorage("showPeakIndicator") private var showPeakIndicator = true
@AppStorage("meterStyle") private var meterStyle = "classic"
@AppStorage("updateRate") private var updateRate = 30.0
var body: some View {
TabView {
// Audio Settings
Form {
Section("Audio Device") {
Picker("Input Device", selection: $audioEngine.selectedDeviceID) {
ForEach(audioEngine.availableDevices, id: \.id) { device in
HStack {
Text(device.name)
Spacer()
Text("\(device.inputChannels) ch")
.foregroundColor(.secondary)
.font(.caption)
}
.tag(device.id)
}
}
.onChange(of: audioEngine.selectedDeviceID) { _ in
audioEngine.switchDevice()
}
Button("Refresh Devices") {
audioEngine.refreshDeviceList()
}
}
Section("Levels") {
HStack {
Text("Reference Level")
Spacer()
Text("\(Int(audioEngine.referenceLevel)) dB")
.foregroundColor(.secondary)
}
Slider(value: $audioEngine.referenceLevel, in: -60...0, step: 1)
HStack {
Text("Peak Hold Time")
Spacer()
Text("\(String(format: "%.1f", audioEngine.peakHoldTime)) s")
.foregroundColor(.secondary)
}
Slider(value: $audioEngine.peakHoldTime, in: 0.5...10, step: 0.5)
}
}
.tabItem {
Label("Audio", systemImage: "waveform")
}
// Hardware Settings
HardwareSettingsView()
.environmentObject(serialManager)
.tabItem {
Label("Hardware", systemImage: "cable.connector")
}
// Display Settings
Form {
Section("Meter Display") {
Toggle("Show Peak Indicator", isOn: $showPeakIndicator)
Picker("Meter Style", selection: $meterStyle) {
Text("Classic").tag("classic")
Text("Modern").tag("modern")
Text("Minimal").tag("minimal")
}
}
Section("Performance") {
HStack {
Text("Update Rate")
Spacer()
Text("\(Int(updateRate)) fps")
.foregroundColor(.secondary)
}
Slider(value: $updateRate, in: 10...60, step: 5)
}
}
.tabItem {
Label("Display", systemImage: "display")
}
// About
VStack(spacing: 20) {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 64))
.foregroundColor(.accentColor)
Text("Audio VU Meter")
.font(.title)
.fontWeight(.bold)
Text("Version 1.1.0")
.foregroundColor(.secondary)
Divider()
.frame(width: 200)
VStack(spacing: 8) {
Text("A macOS audio level meter with system monitoring")
Text("and physical VU meter hardware support.")
}
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.frame(width: 300)
Spacer()
VStack(spacing: 4) {
Text("Supports BlackHole virtual audio device")
Text("and USB/Serial VU meter hardware")
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.tabItem {
Label("About", systemImage: "info.circle")
}
}
.frame(width: 500, height: 400)
}
}
#Preview {
SettingsView()
.environmentObject(AudioEngine())
.environmentObject(SerialManager())
}
@@ -0,0 +1,278 @@
//
// SystemMonitor.swift
// AudioVUMeter
//
// System resource monitoring for CPU, RAM, Disk, and Network
// Uses mach kernel APIs for accurate system statistics
//
import Foundation
import Darwin
/// System resource monitor class
class SystemMonitor: ObservableObject {
// MARK: - Published Properties
@Published var cpuUsage: Double = 0
@Published var memoryUsage: Double = 0
@Published var diskActivity: Double = 0
@Published var networkActivity: Double = 0
// Additional details
@Published var cpuUserUsage: Double = 0
@Published var cpuSystemUsage: Double = 0
@Published var memoryUsed: UInt64 = 0
@Published var memoryTotal: UInt64 = 0
@Published var networkBytesIn: UInt64 = 0
@Published var networkBytesOut: UInt64 = 0
// MARK: - Private Properties
private var updateTimer: Timer?
private var previousCPUInfo: host_cpu_load_info?
private var previousNetworkBytes: (in: UInt64, out: UInt64) = (0, 0)
private var previousDiskBytes: (read: UInt64, write: UInt64) = (0, 0)
private let updateInterval: TimeInterval = 0.5
// MARK: - Public Methods
/// Start monitoring system resources
func startMonitoring() {
// Get initial values
previousCPUInfo = getCPULoadInfo()
previousNetworkBytes = getNetworkBytes()
previousDiskBytes = getDiskBytes()
// Start update timer
updateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] _ in
self?.updateMetrics()
}
// Initial update
updateMetrics()
}
/// Stop monitoring
func stopMonitoring() {
updateTimer?.invalidate()
updateTimer = nil
}
// MARK: - Private Methods
private func updateMetrics() {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self else { return }
let cpu = self.calculateCPUUsage()
let memory = self.calculateMemoryUsage()
let disk = self.calculateDiskActivity()
let network = self.calculateNetworkActivity()
DispatchQueue.main.async {
self.cpuUsage = cpu.total
self.cpuUserUsage = cpu.user
self.cpuSystemUsage = cpu.system
self.memoryUsage = memory.percentage
self.memoryUsed = memory.used
self.memoryTotal = memory.total
self.diskActivity = disk
self.networkActivity = network.percentage
self.networkBytesIn = network.bytesIn
self.networkBytesOut = network.bytesOut
}
}
}
// MARK: - CPU Monitoring
private func getCPULoadInfo() -> host_cpu_load_info? {
var cpuLoadInfo = host_cpu_load_info()
var count = mach_msg_type_number_t(MemoryLayout<host_cpu_load_info>.stride / MemoryLayout<integer_t>.stride)
let result = withUnsafeMutablePointer(to: &cpuLoadInfo) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count)
}
}
return result == KERN_SUCCESS ? cpuLoadInfo : nil
}
private func calculateCPUUsage() -> (total: Double, user: Double, system: Double) {
guard let currentInfo = getCPULoadInfo(),
let previousInfo = previousCPUInfo else {
return (0, 0, 0)
}
let userDiff = Double(currentInfo.cpu_ticks.0 - previousInfo.cpu_ticks.0)
let systemDiff = Double(currentInfo.cpu_ticks.1 - previousInfo.cpu_ticks.1)
let idleDiff = Double(currentInfo.cpu_ticks.2 - previousInfo.cpu_ticks.2)
let niceDiff = Double(currentInfo.cpu_ticks.3 - previousInfo.cpu_ticks.3)
let totalTicks = userDiff + systemDiff + idleDiff + niceDiff
guard totalTicks > 0 else { return (0, 0, 0) }
let userPercent = (userDiff / totalTicks) * 100
let systemPercent = (systemDiff / totalTicks) * 100
let totalPercent = ((userDiff + systemDiff + niceDiff) / totalTicks) * 100
previousCPUInfo = currentInfo
return (min(totalPercent, 100), min(userPercent, 100), min(systemPercent, 100))
}
// MARK: - Memory Monitoring
private func calculateMemoryUsage() -> (percentage: Double, used: UInt64, total: UInt64) {
var stats = vm_statistics64()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.stride / MemoryLayout<integer_t>.stride)
let result = withUnsafeMutablePointer(to: &stats) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard result == KERN_SUCCESS else {
return (0, 0, 0)
}
let pageSize = UInt64(vm_kernel_page_size)
let totalMemory = ProcessInfo.processInfo.physicalMemory
// Calculate used memory
let activeMemory = UInt64(stats.active_count) * pageSize
let wiredMemory = UInt64(stats.wire_count) * pageSize
let compressedMemory = UInt64(stats.compressor_page_count) * pageSize
let usedMemory = activeMemory + wiredMemory + compressedMemory
let percentage = (Double(usedMemory) / Double(totalMemory)) * 100
return (min(percentage, 100), usedMemory, totalMemory)
}
// MARK: - Disk Monitoring
private func getDiskBytes() -> (read: UInt64, write: UInt64) {
// Use IOKit for disk statistics
// Simplified implementation - returns approximate values
var readBytes: UInt64 = 0
var writeBytes: UInt64 = 0
// Get disk statistics from system
let task = Process()
task.launchPath = "/usr/bin/iostat"
task.arguments = ["-d", "-c", "1"]
let pipe = Pipe()
task.standardOutput = pipe
do {
try task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8) {
// Parse iostat output
let lines = output.components(separatedBy: "\n")
if lines.count > 2 {
let values = lines[2].split(separator: " ").compactMap { Double($0) }
if values.count >= 3 {
// KB/t, tps, MB/s
readBytes = UInt64(values.last ?? 0 * 1024 * 1024)
}
}
}
} catch {
// Fallback to simulated values
}
return (readBytes, writeBytes)
}
private func calculateDiskActivity() -> Double {
let currentBytes = getDiskBytes()
let readDiff = currentBytes.read > previousDiskBytes.read ?
currentBytes.read - previousDiskBytes.read : 0
let writeDiff = currentBytes.write > previousDiskBytes.write ?
currentBytes.write - previousDiskBytes.write : 0
previousDiskBytes = currentBytes
// Normalize to percentage (assuming 100MB/s as max)
let totalBytes = Double(readDiff + writeDiff)
let maxBytesPerInterval = 100.0 * 1024 * 1024 * updateInterval
let percentage = (totalBytes / maxBytesPerInterval) * 100
return min(percentage, 100)
}
// MARK: - Network Monitoring
private func getNetworkBytes() -> (in: UInt64, out: UInt64) {
var ifaddr: UnsafeMutablePointer<ifaddrs>?
var bytesIn: UInt64 = 0
var bytesOut: UInt64 = 0
guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else {
return (0, 0)
}
defer { freeifaddrs(ifaddr) }
var ptr = firstAddr
while true {
let interface = ptr.pointee
// Check for data link layer
if interface.ifa_addr.pointee.sa_family == UInt8(AF_LINK) {
// Get network interface data
if let data = interface.ifa_data {
let networkData = data.assumingMemoryBound(to: if_data.self).pointee
bytesIn += UInt64(networkData.ifi_ibytes)
bytesOut += UInt64(networkData.ifi_obytes)
}
}
guard let next = interface.ifa_next else { break }
ptr = next
}
return (bytesIn, bytesOut)
}
private func calculateNetworkActivity() -> (percentage: Double, bytesIn: UInt64, bytesOut: UInt64) {
let currentBytes = getNetworkBytes()
let bytesInDiff = currentBytes.in > previousNetworkBytes.in ?
currentBytes.in - previousNetworkBytes.in : 0
let bytesOutDiff = currentBytes.out > previousNetworkBytes.out ?
currentBytes.out - previousNetworkBytes.out : 0
previousNetworkBytes = currentBytes
// Calculate rate in bytes per second
let totalBytesPerSecond = Double(bytesInDiff + bytesOutDiff) / updateInterval
// Normalize to percentage (assuming 100 Mbps as reference)
let maxBytesPerSecond = 100.0 * 1024 * 1024 / 8 // 100 Mbps in bytes
let percentage = (totalBytesPerSecond / maxBytesPerSecond) * 100
return (min(percentage, 100), bytesInDiff, bytesOutDiff)
}
}
// MARK: - Memory Formatter Extension
extension SystemMonitor {
/// Format bytes to human readable string
static func formatBytes(_ bytes: UInt64) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .memory
return formatter.string(fromByteCount: Int64(bytes))
}
}
+256
View File
@@ -0,0 +1,256 @@
//
// VUMeterView.swift
// AudioVUMeter
//
// Classic VU Meter visualization component
//
import SwiftUI
enum MeterColorScheme {
case audio
case cpu
case ram
case disk
case network
var gradient: [Color] {
switch self {
case .audio:
return [.green, .yellow, .orange, .red]
case .cpu:
return [.blue, .cyan, .yellow, .red]
case .ram:
return [.purple, .pink, .orange, .red]
case .disk:
return [.teal, .green, .yellow, .orange]
case .network:
return [.indigo, .blue, .cyan, .green]
}
}
var accentColor: Color {
switch self {
case .audio: return .green
case .cpu: return .blue
case .ram: return .purple
case .disk: return .teal
case .network: return .indigo
}
}
}
// MARK: - Vertical VU Meter (for Audio)
struct VUMeterView: View {
let level: Double // 0.0 to 1.0
let peakLevel: Double
let label: String
let colorScheme: MeterColorScheme
@State private var animatedLevel: Double = 0
private let segmentCount = 20
private let meterHeight: CGFloat = 200
private let meterWidth: CGFloat = 35
var body: some View {
VStack(spacing: 8) {
// Label
Text(label)
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white)
// Meter
ZStack(alignment: .bottom) {
// Background
RoundedRectangle(cornerRadius: 4)
.fill(Color.black.opacity(0.5))
.frame(width: meterWidth, height: meterHeight)
// Segments
VStack(spacing: 2) {
ForEach((0..<segmentCount).reversed(), id: \.self) { index in
let segmentThreshold = Double(index) / Double(segmentCount)
let isLit = animatedLevel > segmentThreshold
RoundedRectangle(cornerRadius: 2)
.fill(segmentColor(for: index, isLit: isLit))
.frame(width: meterWidth - 6, height: (meterHeight - CGFloat(segmentCount + 1) * 2) / CGFloat(segmentCount))
.shadow(color: isLit ? segmentColor(for: index, isLit: true).opacity(0.5) : .clear, radius: 3)
}
}
.padding(3)
// Peak indicator
if peakLevel > 0 {
let peakPosition = meterHeight * CGFloat(1 - peakLevel)
Rectangle()
.fill(Color.red)
.frame(width: meterWidth - 2, height: 3)
.offset(y: -meterHeight + peakPosition + meterHeight)
}
// dB Scale markers
HStack {
VStack(alignment: .trailing, spacing: 0) {
ForEach([0, -6, -12, -20, -40, -60], id: \.self) { db in
Text("\(db)")
.font(.system(size: 8, design: .monospaced))
.foregroundColor(.gray)
if db != -60 {
Spacer()
}
}
}
.frame(height: meterHeight)
.offset(x: -meterWidth/2 - 15)
Spacer()
}
}
.frame(width: meterWidth + 30, height: meterHeight)
}
.onChange(of: level) { newValue in
withAnimation(.easeOut(duration: 0.05)) {
animatedLevel = newValue
}
}
.onAppear {
animatedLevel = level
}
}
private func segmentColor(for index: Int, isLit: Bool) -> Color {
if !isLit {
return Color.gray.opacity(0.2)
}
let position = Double(index) / Double(segmentCount)
let colors = colorScheme.gradient
if position > 0.9 { return colors[3] } // Red zone
if position > 0.75 { return colors[2] } // Orange zone
if position > 0.5 { return colors[1] } // Yellow zone
return colors[0] // Green zone
}
}
// MARK: - Circular System Meter
struct SystemMeterView: View {
let value: Double // 0.0 to 100.0
let label: String
let unit: String
let colorScheme: MeterColorScheme
@State private var animatedValue: Double = 0
private let meterSize: CGFloat = 70
var body: some View {
VStack(spacing: 5) {
ZStack {
// Background circle
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: 8)
.frame(width: meterSize, height: meterSize)
// Progress arc
Circle()
.trim(from: 0, to: CGFloat(animatedValue / 100))
.stroke(
AngularGradient(
gradient: Gradient(colors: colorScheme.gradient),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: 8, lineCap: .round)
)
.frame(width: meterSize, height: meterSize)
.rotationEffect(.degrees(-90))
// Value display
VStack(spacing: 0) {
Text(String(format: "%.0f", animatedValue))
.font(.system(size: 18, weight: .bold, design: .monospaced))
.foregroundColor(.white)
Text(unit)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.gray)
}
}
Text(label)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(colorScheme.accentColor)
}
.onChange(of: value) { newValue in
withAnimation(.easeOut(duration: 0.3)) {
animatedValue = newValue
}
}
.onAppear {
animatedValue = value
}
}
}
// MARK: - Horizontal Bar Meter
struct HorizontalMeterView: View {
let value: Double
let label: String
let colorScheme: MeterColorScheme
@State private var animatedValue: Double = 0
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.gray)
Spacer()
Text(String(format: "%.1f%%", animatedValue))
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundColor(.white)
}
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
// Fill
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
gradient: Gradient(colors: colorScheme.gradient),
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: geometry.size.width * CGFloat(animatedValue / 100))
}
}
.frame(height: 12)
}
.onChange(of: value) { newValue in
withAnimation(.easeOut(duration: 0.3)) {
animatedValue = newValue
}
}
.onAppear {
animatedValue = value
}
}
}
#Preview {
HStack(spacing: 30) {
VUMeterView(level: 0.7, peakLevel: 0.9, label: "L", colorScheme: .audio)
VUMeterView(level: 0.5, peakLevel: 0.8, label: "R", colorScheme: .audio)
}
.padding()
.background(Color.black)
}
+230
View File
@@ -0,0 +1,230 @@
# Audio VU Meter for macOS
A native macOS SwiftUI application that displays real-time audio levels from BlackHole (or any audio input device) as a classic VU meter, along with system resource monitoring. **Now with physical VU meter hardware support!**
![macOS](https://img.shields.io/badge/macOS-13.0+-blue.svg)
![Swift](https://img.shields.io/badge/Swift-5.0-orange.svg)
![License](https://img.shields.io/badge/License-MIT-green.svg)
## Features
### Audio VU Meter
- **Real-time audio level monitoring** - Displays Left and Right channel levels
- **dB scale display** - Shows audio levels in decibels (-60 dB to 0 dB)
- **Peak hold indicators** - Visual peak markers with configurable hold time
- **BlackHole integration** - Automatically detects and selects BlackHole virtual audio device
- **Multi-device support** - Switch between any available audio input device
### System Resource Monitors
- **CPU Usage** - Real-time CPU utilization percentage
- **RAM Usage** - Memory consumption monitoring
- **Disk Activity** - Disk I/O activity indicator
- **Network Activity** - Network throughput monitoring
### Physical VU Meter Hardware Support
- **4 Physical Dials** - Support for up to 4 physical VU meter dials
- **Flexible Channel Mapping** - Assign any metric to any dial:
- Audio Left/Right channels
- Audio Peak or Mono (L+R)
- CPU, RAM, Disk, Network usage
- **Multiple Serial Protocols**:
- Raw Bytes: `[0xAA] [D1] [D2] [D3] [D4] [0x55]`
- Text Commands: `CH1:val;CH2:val;CH3:val;CH4:val\n`
- JSON: `{"dials":[d1,d2,d3,d4]}`
- VU-Server Compatible: `#0:val\n#1:val\n...`
- **Configurable per dial**: Min/max values, inversion, smoothing
- **Auto-detection** of USB serial devices
## Requirements
- macOS 13.0 (Ventura) or later
- Xcode 15.0 or later (for building)
- [BlackHole](https://existential.audio/blackhole/) virtual audio driver (recommended)
- USB/Serial VU meter hardware (optional)
## Installation
### Using BlackHole
1. Install BlackHole from [existential.audio/blackhole](https://existential.audio/blackhole/)
2. Configure BlackHole as a multi-output device in Audio MIDI Setup
3. Build and run Audio VU Meter
4. The app will automatically detect and select BlackHole
### Building from Source
1. Clone the repository
2. Open `AudioVUMeter.xcodeproj` in Xcode
3. Build and run (Cmd+R)
```bash
git clone <repository-url>
cd AudioVUMeter
open AudioVUMeter.xcodeproj
```
## Usage
### Main Window
- **Audio Levels**: The vertical VU meters show Left (L) and Right (R) channel audio levels
- **dB Readings**: Numeric display of current audio levels in decibels
- **System Meters**: Circular gauges showing CPU, RAM, Disk, and Network usage
- **Hardware Output**: Shows status of connected physical VU meters
### Controls
- **Start/Stop**: Toggle audio capture on/off
- **Reset**: Clear peak hold indicators
- **Settings** (gear icon): Access device selection and preferences
- **Hardware** (cable icon): Configure physical VU meter connection
### Hardware Setup
1. Connect your USB/Serial VU meter hardware
2. Click the cable icon or go to Settings -> Hardware
3. Select your serial port from the dropdown
4. Choose the appropriate baud rate (default: 115200)
5. Select the communication protocol your hardware uses
6. Assign channels to each dial (Audio L, R, CPU, RAM, etc.)
7. Click "Connect"
### Settings
- **Input Device**: Select audio input source (BlackHole, microphone, etc.)
- **Reference Level**: Adjust the 0 dB reference point
- **Peak Hold Time**: Configure how long peak indicators remain visible
- **Hardware**: Serial port, protocol, and dial assignments
## Architecture
```
AudioVUMeter/
├── AudioVUMeterApp.swift # App entry point
├── ContentView.swift # Main UI layout
├── VUMeterView.swift # VU meter components
├── AudioEngine.swift # Core Audio capture engine
├── SystemMonitor.swift # System resource monitoring
├── SerialManager.swift # USB/Serial communication
├── HardwareView.swift # Hardware configuration UI
├── SettingsView.swift # Settings window
└── Assets.xcassets/ # App icons and colors
```
### Key Components
- **AudioEngine**: Uses AVAudioEngine to capture audio from the selected input device, calculates RMS levels, and converts to dB
- **SystemMonitor**: Uses Mach kernel APIs to retrieve CPU, memory, disk, and network statistics
- **SerialManager**: Handles USB/Serial communication with physical VU meter hardware
- **VUMeterView**: SwiftUI views for classic vertical VU meters with segment-based display
- **SystemMeterView**: Circular gauge components for system metrics
## Hardware Protocol Reference
### Raw Bytes Protocol
```
Start: 0xAA
Data: [Dial1] [Dial2] [Dial3] [Dial4] (0-255 each)
End: 0x55
```
### Text Command Protocol
```
CH1:128;CH2:64;CH3:200;CH4:32\n
```
### JSON Protocol
```json
{"dials":[128,64,200,32]}
```
### VU-Server Compatible Protocol
```
#0:50
#1:75
#2:30
#3:90
```
Values are percentages (0-100)
## BlackHole Setup Guide
1. **Install BlackHole**: Download and install from [existential.audio](https://existential.audio/blackhole/)
2. **Create Multi-Output Device**:
- Open Audio MIDI Setup (Applications -> Utilities)
- Click the `+` button -> Create Multi-Output Device
- Check both your speakers and BlackHole
- Set as default output
3. **Route Audio**:
- System audio will now go to both speakers and BlackHole
- Audio VU Meter captures from BlackHole input
## Compatible Hardware
This app is designed to work with:
- [VU Dials by Sasa Karanovic](https://github.com/SasaKaranovic/VU-Server)
- Arduino-based VU meters with serial interface
- Any USB/Serial device accepting the supported protocols
## API Reference
### AudioEngine
```swift
// Start/stop audio capture
audioEngine.start()
audioEngine.stop()
// Reset peak indicators
audioEngine.resetPeaks()
// Switch audio device
audioEngine.selectedDeviceID = deviceID
audioEngine.switchDevice()
// Access levels
audioEngine.leftLevel // 0.0 to 1.0
audioEngine.rightLevel // 0.0 to 1.0
audioEngine.leftLevelDB // -60 to 0 dB
audioEngine.rightLevelDB // -60 to 0 dB
```
### SystemMonitor
```swift
// Start/stop monitoring
systemMonitor.startMonitoring()
systemMonitor.stopMonitoring()
// Access metrics (0-100%)
systemMonitor.cpuUsage
systemMonitor.memoryUsage
systemMonitor.diskActivity
systemMonitor.networkActivity
```
### SerialManager
```swift
// Connection
serialManager.connect()
serialManager.disconnect()
// Configuration
serialManager.selectedPortPath = "/dev/cu.usbserial-XXX"
serialManager.baudRate = 115200
serialManager.selectedProtocol = .vuServer
// Dial assignment
serialManager.dialConfigs[0].dialChannel = .audioLeft
serialManager.dialConfigs[1].dialChannel = .audioRight
serialManager.dialConfigs[2].dialChannel = .cpu
serialManager.dialConfigs[3].dialChannel = .ram
```
## License
MIT License - See LICENSE file for details.
## Credits
Inspired by [VU-Server](https://github.com/SasaKaranovic/VU-Server) by Sasa Karanovic.
@@ -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 = "<group>"; };
A20000002 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
A20000003 /* ModernRadioView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernRadioView.swift; sourceTree = "<group>"; };
A20000004 /* SkeuomorphRadioView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeuomorphRadioView.swift; sourceTree = "<group>"; };
A20000005 /* DebugPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPanel.swift; sourceTree = "<group>"; };
A20000006 /* LogPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogPanel.swift; sourceTree = "<group>"; };
A20000007 /* AudioPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPanel.swift; sourceTree = "<group>"; };
A20000008 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A20000009 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
A20000010 /* RadioState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioState.swift; sourceTree = "<group>"; };
A20000011 /* CATCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CATCommand.swift; sourceTree = "<group>"; };
A20000012 /* QSOEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QSOEntry.swift; sourceTree = "<group>"; };
A20000013 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
A20000014 /* SerialPortManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialPortManager.swift; sourceTree = "<group>"; };
A20000015 /* CATProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CATProtocol.swift; sourceTree = "<group>"; };
A20000016 /* CSVManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVManager.swift; sourceTree = "<group>"; };
A20000017 /* AudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouter.swift; sourceTree = "<group>"; };
A20000018 /* RadioViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioViewModel.swift; sourceTree = "<group>"; };
A20000019 /* LogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewModel.swift; sourceTree = "<group>"; };
A20000020 /* SettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsController.swift; sourceTree = "<group>"; };
A20000021 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
A20000022 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A20000023 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
A20000024 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
A20000025 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A20000026 /* FT991A_Remote.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FT991A_Remote.entitlements; sourceTree = "<group>"; };
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 = "<group>";
};
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 = "<group>";
};
A50000003 /* Products */ = {
isa = PBXGroup;
children = (
A30000001 /* FT991A-Remote.app */,
);
name = Products;
sourceTree = "<group>";
};
A50000004 /* Models */ = {
isa = PBXGroup;
children = (
A20000010 /* RadioState.swift */,
A20000011 /* CATCommand.swift */,
A20000012 /* QSOEntry.swift */,
A20000013 /* Settings.swift */,
);
path = Models;
sourceTree = "<group>";
};
A50000005 /* Services */ = {
isa = PBXGroup;
children = (
A20000014 /* SerialPortManager.swift */,
A20000015 /* CATProtocol.swift */,
A20000016 /* CSVManager.swift */,
A20000017 /* AudioRouter.swift */,
);
path = Services;
sourceTree = "<group>";
};
A50000006 /* ViewModels */ = {
isa = PBXGroup;
children = (
A20000018 /* RadioViewModel.swift */,
A20000019 /* LogViewModel.swift */,
A20000020 /* SettingsController.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
A50000007 /* Views */ = {
isa = PBXGroup;
children = (
A20000002 /* MainView.swift */,
A50000009 /* ModernView */,
A50000010 /* SkeuomorphView */,
A50000011 /* Panels */,
A50000012 /* Settings */,
A50000013 /* MenuBar */,
);
path = Views;
sourceTree = "<group>";
};
A50000008 /* Utilities */ = {
isa = PBXGroup;
children = (
A20000021 /* Logger.swift */,
A50000014 /* Localization */,
);
path = Utilities;
sourceTree = "<group>";
};
A50000009 /* ModernView */ = {
isa = PBXGroup;
children = (
A20000003 /* ModernRadioView.swift */,
);
path = ModernView;
sourceTree = "<group>";
};
A50000010 /* SkeuomorphView */ = {
isa = PBXGroup;
children = (
A20000004 /* SkeuomorphRadioView.swift */,
);
path = SkeuomorphView;
sourceTree = "<group>";
};
A50000011 /* Panels */ = {
isa = PBXGroup;
children = (
A20000005 /* DebugPanel.swift */,
A20000006 /* LogPanel.swift */,
A20000007 /* AudioPanel.swift */,
);
path = Panels;
sourceTree = "<group>";
};
A50000012 /* Settings */ = {
isa = PBXGroup;
children = (
A20000008 /* SettingsView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
A50000013 /* MenuBar */ = {
isa = PBXGroup;
children = (
A20000009 /* MenuBarView.swift */,
);
path = MenuBar;
sourceTree = "<group>";
};
A50000014 /* Localization */ = {
isa = PBXGroup;
children = (
A20000023 /* Localizable.strings */,
);
path = Localization;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
/* 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 */;
}
@@ -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
}
}
@@ -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
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.device.serial</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
</dict>
</plist>
@@ -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"
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>de</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright 2024</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>csv</string>
</array>
<key>CFBundleTypeName</key>
<string>QSO Log File</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSItemContentTypes</key>
<array>
<string>public.comma-separated-values-text</string>
</array>
</dict>
</array>
<key>LSUIElement</key>
<false/>
<key>NSAppleEventsUsageDescription</key>
<string>FT-991A Remote needs to control other applications for audio routing.</string>
<key>NSMicrophoneUsageDescription</key>
<string>FT-991A Remote needs microphone access for audio monitoring and digital modes.</string>
</dict>
</plist>
@@ -0,0 +1,266 @@
//
// CATCommand.swift
// FT991A-Remote
//
// CAT Command definitions for FT-991A
//
import Foundation
// MARK: - CAT Command
struct CATCommand {
let command: String
let description: String
let expectsResponse: Bool
init(_ command: String, description: String = "", expectsResponse: Bool = true) {
self.command = command
self.description = description
self.expectsResponse = expectsResponse
}
var data: Data {
(command + ";").data(using: .ascii) ?? Data()
}
}
// MARK: - CAT Commands Catalog
enum CAT {
// MARK: - Frequency Commands
/// Read VFO-A frequency
static let readVFOA = CATCommand("FA", description: "Read VFO-A frequency")
/// Set VFO-A frequency (9 digits in Hz)
static func setVFOA(_ frequency: Int) -> CATCommand {
CATCommand(String(format: "FA%09d", frequency), description: "Set VFO-A to \(frequency) Hz", expectsResponse: false)
}
/// Read VFO-B frequency
static let readVFOB = CATCommand("FB", description: "Read VFO-B frequency")
/// Set VFO-B frequency (9 digits in Hz)
static func setVFOB(_ frequency: Int) -> CATCommand {
CATCommand(String(format: "FB%09d", frequency), description: "Set VFO-B to \(frequency) Hz", expectsResponse: false)
}
/// Swap VFO A/B
static let swapVFO = CATCommand("SV", description: "Swap VFO A/B", expectsResponse: false)
/// Select VFO-A
static let selectVFOA = CATCommand("VS0", description: "Select VFO-A", expectsResponse: false)
/// Select VFO-B
static let selectVFOB = CATCommand("VS1", description: "Select VFO-B", expectsResponse: false)
/// Read active VFO
static let readActiveVFO = CATCommand("VS", description: "Read active VFO")
// MARK: - Mode Commands
/// Read operating mode
static let readMode = CATCommand("MD0", description: "Read operating mode")
/// Set operating mode
static func setMode(_ mode: OperatingMode) -> CATCommand {
CATCommand("MD0\(mode.catValue)", description: "Set mode to \(mode.rawValue)", expectsResponse: false)
}
// MARK: - Level Commands
/// Read AF gain
static let readAFGain = CATCommand("AG0", description: "Read AF gain")
/// Set AF gain (000-255)
static func setAFGain(_ value: Int) -> CATCommand {
CATCommand(String(format: "AG0%03d", min(255, max(0, value))), description: "Set AF gain", expectsResponse: false)
}
/// Read RF gain
static let readRFGain = CATCommand("RG0", description: "Read RF gain")
/// Set RF gain (000-255)
static func setRFGain(_ value: Int) -> CATCommand {
CATCommand(String(format: "RG0%03d", min(255, max(0, value))), description: "Set RF gain", expectsResponse: false)
}
/// Read squelch
static let readSquelch = CATCommand("SQ0", description: "Read squelch")
/// Set squelch (000-255)
static func setSquelch(_ value: Int) -> CATCommand {
CATCommand(String(format: "SQ0%03d", min(255, max(0, value))), description: "Set squelch", expectsResponse: false)
}
/// Read MIC gain
static let readMICGain = CATCommand("MG", description: "Read MIC gain")
/// Set MIC gain (000-100)
static func setMICGain(_ value: Int) -> CATCommand {
CATCommand(String(format: "MG%03d", min(100, max(0, value))), description: "Set MIC gain", expectsResponse: false)
}
/// Read power level
static let readPower = CATCommand("PC", description: "Read power level")
/// Set power level (005-100)
static func setPower(_ value: Int) -> CATCommand {
CATCommand(String(format: "PC%03d", min(100, max(5, value))), description: "Set power to \(value)W", expectsResponse: false)
}
// MARK: - Function Commands
/// Read Noise Blanker status
static let readNB = CATCommand("NB0", description: "Read NB status")
/// Set Noise Blanker on/off
static func setNB(_ enabled: Bool) -> CATCommand {
CATCommand("NB0\(enabled ? "1" : "0")", description: enabled ? "Enable NB" : "Disable NB", expectsResponse: false)
}
/// Read Noise Reduction status
static let readNR = CATCommand("NR0", description: "Read NR status")
/// Set Noise Reduction on/off
static func setNR(_ enabled: Bool) -> CATCommand {
CATCommand("NR0\(enabled ? "1" : "0")", description: enabled ? "Enable NR" : "Disable NR", expectsResponse: false)
}
/// Read DNF status
static let readDNF = CATCommand("BC0", description: "Read DNF status")
/// Set DNF on/off
static func setDNF(_ enabled: Bool) -> CATCommand {
CATCommand("BC0\(enabled ? "1" : "0")", description: enabled ? "Enable DNF" : "Disable DNF", expectsResponse: false)
}
/// Read Contour status
static let readContour = CATCommand("CO00", description: "Read Contour status")
/// Read ATU status
static let readATU = CATCommand("AC", description: "Read ATU status")
/// Start ATU tune
static let startATUTune = CATCommand("AC001", description: "Start ATU tune", expectsResponse: false)
/// Read Split status
static let readSplit = CATCommand("FT", description: "Read Split status")
/// Set Split on/off
static func setSplit(_ enabled: Bool) -> CATCommand {
CATCommand("FT\(enabled ? "1" : "0")", description: enabled ? "Enable Split" : "Disable Split", expectsResponse: false)
}
// MARK: - Metering Commands
/// Read S-Meter
static let readSMeter = CATCommand("SM0", description: "Read S-Meter")
/// Read Power meter
static let readPowerMeter = CATCommand("RM1", description: "Read Power meter")
/// Read SWR meter
static let readSWRMeter = CATCommand("RM6", description: "Read SWR meter")
// MARK: - PTT Commands
/// Start transmitting (MIC)
static let txOn = CATCommand("TX0", description: "TX on (MIC)", expectsResponse: false)
/// Start transmitting (DATA)
static let txOnData = CATCommand("TX1", description: "TX on (DATA)", expectsResponse: false)
/// Stop transmitting
static let txOff = CATCommand("RX", description: "TX off", expectsResponse: false)
/// Read TX status
static let readTXStatus = CATCommand("TX", description: "Read TX status")
// MARK: - Identification
/// Read radio ID
static let readID = CATCommand("ID", description: "Read radio ID")
// MARK: - Information
/// Read all status (IF command)
static let readInfo = CATCommand("IF", description: "Read info")
}
// MARK: - CAT Response
struct CATResponse {
let command: String
let value: String
let rawData: String
let timestamp: Date
init(rawData: String) {
self.rawData = rawData.trimmingCharacters(in: CharacterSet(charactersIn: ";\r\n"))
self.timestamp = Date()
// Parse command prefix (2 characters usually)
if rawData.count >= 2 {
let prefixEnd = rawData.index(rawData.startIndex, offsetBy: 2)
self.command = String(rawData[..<prefixEnd])
self.value = String(rawData[prefixEnd...]).trimmingCharacters(in: CharacterSet(charactersIn: ";\r\n"))
} else {
self.command = rawData
self.value = ""
}
}
// MARK: - Value Parsers
/// Parse frequency from FA/FB response (9 digits)
var frequency: Int? {
guard command == "FA" || command == "FB" else { return nil }
return Int(value)
}
/// Parse mode from MD0 response
var mode: OperatingMode? {
guard command == "MD" else { return nil }
let modeChar = value.dropFirst() // Remove "0" prefix
return OperatingMode.from(catValue: String(modeChar))
}
/// Parse level value (3 digits)
var levelValue: Int? {
// Handle commands like AG0XXX, RG0XXX, SQ0XXX
let numericPart = value.filter { $0.isNumber }
return Int(numericPart)
}
/// Parse S-Meter from SM0 response
var sMeter: Int? {
guard command == "SM" else { return nil }
// SM0XXX format - drop the "0" prefix
let numericPart = value.dropFirst()
return Int(numericPart)
}
/// Parse boolean status (0 or 1)
var boolValue: Bool? {
guard let last = value.last else { return nil }
return last == "1"
}
/// Parse VFO selection
var vfo: VFO? {
guard command == "VS" else { return nil }
switch value {
case "0": return .a
case "1": return .b
default: return nil
}
}
/// Check if this is the FT-991A ID
var isFT991A: Bool {
command == "ID" && value == "0670"
}
}
@@ -0,0 +1,163 @@
//
// QSOEntry.swift
// FT991A-Remote
//
// Model for QSO log entries
//
import Foundation
// MARK: - QSO Entry
struct QSOEntry: Identifiable, Codable, Hashable {
let id: UUID
var callsign: String
var date: Date
var frequency: Int // Hz
var mode: OperatingMode
var rstSent: String // e.g., "59", "599"
var rstReceived: String
var name: String
var qth: String
var locator: String // Maidenhead grid
var power: Int // Watts
var notes: String
init(
id: UUID = UUID(),
callsign: String = "",
date: Date = Date(),
frequency: Int = 14_250_000,
mode: OperatingMode = .usb,
rstSent: String = "59",
rstReceived: String = "59",
name: String = "",
qth: String = "",
locator: String = "",
power: Int = 100,
notes: String = ""
) {
self.id = id
self.callsign = callsign
self.date = date
self.frequency = frequency
self.mode = mode
self.rstSent = rstSent
self.rstReceived = rstReceived
self.name = name
self.qth = qth
self.locator = locator
self.power = power
self.notes = notes
}
// MARK: - CSV Export
static let csvHeader = "Call,Date,Time,Frequency,Mode,RST_TX,RST_RX,Name,QTH,Locator,Power,Notes"
var csvLine: String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm:ss"
timeFormatter.timeZone = TimeZone(identifier: "UTC")
let freqMHz = String(format: "%.6f", Double(frequency) / 1_000_000.0)
// Escape fields with commas or quotes
let escapedNotes = notes.contains(",") || notes.contains("\"")
? "\"\(notes.replacingOccurrences(of: "\"", with: "\"\""))\""
: notes
let escapedName = name.contains(",") || name.contains("\"")
? "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\""
: name
return [
callsign,
dateFormatter.string(from: date),
timeFormatter.string(from: date),
freqMHz,
mode.rawValue,
rstSent,
rstReceived,
escapedName,
qth,
locator,
String(power),
escapedNotes
].joined(separator: ",")
}
// MARK: - CSV Import
static func from(csvLine: String) -> QSOEntry? {
var fields: [String] = []
var current = ""
var inQuotes = false
for char in csvLine {
if char == "\"" {
inQuotes.toggle()
} else if char == "," && !inQuotes {
fields.append(current)
current = ""
} else {
current.append(char)
}
}
fields.append(current)
guard fields.count >= 12 else { return nil }
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.timeZone = TimeZone(identifier: "UTC")
guard let date = dateFormatter.date(from: "\(fields[1]) \(fields[2])") else { return nil }
guard let freqMHz = Double(fields[3]) else { return nil }
let frequency = Int(freqMHz * 1_000_000)
let mode = OperatingMode.allCases.first { $0.rawValue == fields[4] } ?? .usb
return QSOEntry(
callsign: fields[0],
date: date,
frequency: frequency,
mode: mode,
rstSent: fields[5],
rstReceived: fields[6],
name: fields[7],
qth: fields[8],
locator: fields[9],
power: Int(fields[10]) ?? 100,
notes: fields[11]
)
}
// MARK: - Display Helpers
var frequencyDisplay: String {
let mhz = frequency / 1_000_000
let khz = (frequency % 1_000_000) / 1_000
let hz = frequency % 1_000
return String(format: "%d.%03d.%03d", mhz, khz, hz)
}
var dateDisplay: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.yyyy"
return formatter.string(from: date)
}
var timeDisplay: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter.string(from: date) + " UTC"
}
var bandDisplay: String {
Band.from(frequency: frequency)?.rawValue ?? "?"
}
}
@@ -0,0 +1,238 @@
//
// RadioState.swift
// FT991A-Remote
//
// Model representing the current state of the FT-991A transceiver
//
import Foundation
// MARK: - Radio State
struct RadioState {
// VFO Frequencies
var vfoAFrequency: Int = 14_250_000 // Hz
var vfoBFrequency: Int = 14_255_000 // Hz
var activeVFO: VFO = .a
// Operating Mode
var mode: OperatingMode = .usb
var filterWidth: Int = 3000 // Hz
var filterShift: Int = 0 // Hz
// Levels (0-255)
var afGain: Int = 128
var rfGain: Int = 255
var squelch: Int = 0
var micGain: Int = 50
var power: Int = 100 // Watts (5-100)
// Functions
var noiseBlanker: Bool = false
var noiseReduction: Bool = false
var dnf: Bool = false
var contour: Bool = false
var atu: Bool = false
var split: Bool = false
var ipo: Bool = false
// Metering
var sMeter: Int = 0 // 0-255
var powerMeter: Int = 0 // 0-255
var swrMeter: Int = 0 // 0-255
// TX State
var isTransmitting: Bool = false
// Computed Properties
var activeFrequency: Int {
activeVFO == .a ? vfoAFrequency : vfoBFrequency
}
var sMeterDB: Double {
// S0-S9 = 0-54 dBμV, each S-unit = 6 dB
// Above S9: +10, +20, +40, +60 dB
let normalized = Double(sMeter) / 255.0
if normalized <= 0.6 {
return normalized / 0.6 * 54.0 // S0-S9
} else {
return 54.0 + (normalized - 0.6) / 0.4 * 60.0 // S9+60
}
}
var sMeterString: String {
let normalized = Double(sMeter) / 255.0
if normalized <= 0.6 {
let sUnit = Int(normalized / 0.6 * 9.0)
return "S\(sUnit)"
} else {
let db = Int((normalized - 0.6) / 0.4 * 60.0)
return "S9+\(db)"
}
}
var frequencyDisplay: String {
formatFrequency(activeFrequency)
}
func formatFrequency(_ freq: Int) -> String {
let mhz = freq / 1_000_000
let khz = (freq % 1_000_000) / 1_000
let hz = freq % 1_000
return String(format: "%d.%03d.%03d", mhz, khz, hz)
}
}
// MARK: - VFO
enum VFO: String, Codable {
case a = "A"
case b = "B"
}
// MARK: - Operating Mode
enum OperatingMode: String, CaseIterable, Codable {
case lsb = "LSB"
case usb = "USB"
case cw = "CW"
case fm = "FM"
case am = "AM"
case rttyLSB = "RTTY-L"
case cwReverse = "CW-R"
case dataLSB = "DATA-L"
case rttyUSB = "RTTY-U"
case dataFM = "DATA-FM"
case fmNarrow = "FM-N"
case dataUSB = "DATA-U"
case amNarrow = "AM-N"
case c4fm = "C4FM"
// CAT command value (MD0X)
var catValue: String {
switch self {
case .lsb: return "1"
case .usb: return "2"
case .cw: return "3"
case .fm: return "4"
case .am: return "5"
case .rttyLSB: return "6"
case .cwReverse: return "7"
case .dataLSB: return "8"
case .rttyUSB: return "9"
case .dataFM: return "A"
case .fmNarrow: return "B"
case .dataUSB: return "C"
case .amNarrow: return "D"
case .c4fm: return "E"
}
}
static func from(catValue: String) -> OperatingMode? {
allCases.first { $0.catValue == catValue }
}
var isDigital: Bool {
switch self {
case .dataLSB, .dataUSB, .dataFM, .rttyLSB, .rttyUSB, .c4fm:
return true
default:
return false
}
}
var defaultFilterWidth: Int {
switch self {
case .lsb, .usb, .dataLSB, .dataUSB: return 3000
case .cw, .cwReverse: return 500
case .am, .amNarrow: return 6000
case .fm, .fmNarrow, .dataFM, .c4fm: return 15000
case .rttyLSB, .rttyUSB: return 500
}
}
}
// MARK: - Frequency Step
enum FrequencyStep: Int, CaseIterable, Codable {
case hz1 = 1
case hz10 = 10
case hz100 = 100
case khz1 = 1000
case khz5 = 5000
case khz10 = 10000
case khz100 = 100000
case mhz1 = 1000000
var displayName: String {
switch self {
case .hz1: return "1 Hz"
case .hz10: return "10 Hz"
case .hz100: return "100 Hz"
case .khz1: return "1 kHz"
case .khz5: return "5 kHz"
case .khz10: return "10 kHz"
case .khz100: return "100 kHz"
case .mhz1: return "1 MHz"
}
}
}
// MARK: - Band
enum Band: String, CaseIterable {
case m160 = "160m"
case m80 = "80m"
case m60 = "60m"
case m40 = "40m"
case m30 = "30m"
case m20 = "20m"
case m17 = "17m"
case m15 = "15m"
case m12 = "12m"
case m10 = "10m"
case m6 = "6m"
case m2 = "2m"
case cm70 = "70cm"
var frequencyRange: ClosedRange<Int> {
switch self {
case .m160: return 1_800_000...2_000_000
case .m80: return 3_500_000...4_000_000
case .m60: return 5_351_500...5_366_500
case .m40: return 7_000_000...7_300_000
case .m30: return 10_100_000...10_150_000
case .m20: return 14_000_000...14_350_000
case .m17: return 18_068_000...18_168_000
case .m15: return 21_000_000...21_450_000
case .m12: return 24_890_000...24_990_000
case .m10: return 28_000_000...29_700_000
case .m6: return 50_000_000...54_000_000
case .m2: return 144_000_000...148_000_000
case .cm70: return 430_000_000...450_000_000
}
}
var defaultFrequency: Int {
switch self {
case .m160: return 1_840_000
case .m80: return 3_700_000
case .m60: return 5_357_000
case .m40: return 7_100_000
case .m30: return 10_120_000
case .m20: return 14_250_000
case .m17: return 18_110_000
case .m15: return 21_250_000
case .m12: return 24_930_000
case .m10: return 28_500_000
case .m6: return 50_150_000
case .m2: return 145_500_000
case .cm70: return 433_500_000
}
}
static func from(frequency: Int) -> Band? {
allCases.first { $0.frequencyRange.contains(frequency) }
}
}
@@ -0,0 +1,123 @@
//
// Settings.swift
// FT991A-Remote
//
// Application settings model
//
import Foundation
// MARK: - App Settings
struct AppSettings: Codable {
// Connection
var serialPort: String = ""
var baudRate: Int = 38400
var autoReconnect: Bool = true
var reconnectInterval: TimeInterval = 5.0
// UI
var uiStyle: UIStyle = .modern
var language: AppLanguage = .german
var showDebugPanel: Bool = false
var showLogPanel: Bool = false
var compactMode: Bool = true
// Frequency
var frequencyStep: FrequencyStep = .khz1
// Logging
var logDirectory: String = "~/Documents/FT991A-Logs/"
var autoSaveLog: Bool = true
// Audio
var audioInputDevice: String = ""
var audioOutputDevice: String = ""
var useBlackHole: Bool = false
// Keyboard
var pttShortcutEnabled: Bool = true
var arrowFrequencyEnabled: Bool = true
var tunerShortcutEnabled: Bool = true
// MARK: - Persistence
static let defaults = AppSettings()
static var settingsURL: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let appFolder = appSupport.appendingPathComponent("FT991A-Remote", isDirectory: true)
try? FileManager.default.createDirectory(at: appFolder, withIntermediateDirectories: true)
return appFolder.appendingPathComponent("settings.json")
}
static func load() -> AppSettings {
guard FileManager.default.fileExists(atPath: settingsURL.path) else {
return defaults
}
do {
let data = try Data(contentsOf: settingsURL)
return try JSONDecoder().decode(AppSettings.self, from: data)
} catch {
print("Failed to load settings: \(error)")
return defaults
}
}
func save() {
do {
let data = try JSONEncoder().encode(self)
try data.write(to: AppSettings.settingsURL)
} catch {
print("Failed to save settings: \(error)")
}
}
// MARK: - Log Directory
var expandedLogDirectory: String {
(logDirectory as NSString).expandingTildeInPath
}
mutating func ensureLogDirectoryExists() {
let path = expandedLogDirectory
if !FileManager.default.fileExists(atPath: path) {
try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
}
}
}
// MARK: - Serial Port Configuration
struct SerialConfig: Codable {
var baudRate: Int = 38400
var dataBits: Int = 8
var stopBits: Int = 1
var parity: Parity = .none
var flowControl: FlowControl = .none
enum Parity: String, Codable, CaseIterable {
case none = "None"
case odd = "Odd"
case even = "Even"
}
enum FlowControl: String, Codable, CaseIterable {
case none = "None"
case hardware = "RTS/CTS"
case software = "XON/XOFF"
}
static let ft991aDefault = SerialConfig(
baudRate: 38400,
dataBits: 8,
stopBits: 1,
parity: .none,
flowControl: .none
)
static let availableBaudRates = [4800, 9600, 19200, 38400, 57600, 115200]
}
@@ -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<AudioDeviceID>.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<CFString>.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<CFString>.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<AudioDeviceID>.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<AudioDeviceID>.size),
&deviceIDVar
)
if status != noErr {
lastError = "Fehler beim Setzen des Standard-Ausgangs"
}
}
}
@@ -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<AnyCancellable>()
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)
}
}
@@ -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
}
}
@@ -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..<bytesRead])
DispatchQueue.main.async {
self.bytesReceived += UInt64(bytesRead)
}
// Append to buffer
readBuffer.append(data)
// Process complete responses (terminated by ';')
processBuffer()
}
private func processBuffer() {
while let semicolonIndex = readBuffer.firstIndex(of: 0x3B) { // ';'
let responseData = readBuffer.prefix(through: semicolonIndex)
readBuffer.removeFirst(semicolonIndex + 1)
if let response = String(data: Data(responseData), encoding: .ascii) {
Logger.shared.log("RX: \(response)", level: .debug)
}
onDataReceived?(Data(responseData))
}
}
private func handleReadError() {
let error = String(cString: strerror(errno))
connectionState = .error(error)
lastError = error
if shouldReconnect {
startReconnectTimer()
}
}
// MARK: - Writing
func send(_ data: Data) {
guard fileDescriptor != -1 else { return }
writeQueue.async { [weak self] in
guard let self = self, self.fileDescriptor != -1 else { return }
let written = data.withUnsafeBytes { buffer -> 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
}
}
@@ -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";
@@ -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";
@@ -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 }
}
}
@@ -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<AnyCancellable>()
// MARK: - Computed Properties
var filteredEntries: [QSOEntry] {
var result = entries
// Apply search filter
if !searchText.isEmpty {
let search = searchText.lowercased()
result = result.filter {
$0.callsign.lowercased().contains(search) ||
$0.name.lowercased().contains(search) ||
$0.qth.lowercased().contains(search) ||
$0.notes.lowercased().contains(search)
}
}
// Apply sorting
switch sortOrder {
case .dateDescending:
result.sort { $0.date > $1.date }
case .dateAscending:
result.sort { $0.date < $1.date }
case .callsignAscending:
result.sort { $0.callsign < $1.callsign }
case .callsignDescending:
result.sort { $0.callsign > $1.callsign }
case .frequencyAscending:
result.sort { $0.frequency < $1.frequency }
case .frequencyDescending:
result.sort { $0.frequency > $1.frequency }
}
return result
}
var totalQSOs: Int {
entries.count
}
var uniqueCallsigns: Int {
Set(entries.map { $0.callsign.uppercased() }).count
}
// MARK: - Initialization
init() {
setupBindings()
refreshLogFiles()
loadLatestLog()
}
private func setupBindings() {
csvManager.$logEntries
.receive(on: DispatchQueue.main)
.assign(to: &$entries)
csvManager.$currentLogFile
.receive(on: DispatchQueue.main)
.assign(to: &$currentLogFile)
csvManager.$isSaving
.receive(on: DispatchQueue.main)
.assign(to: &$isSaving)
csvManager.$lastError
.receive(on: DispatchQueue.main)
.assign(to: &$lastError)
}
// MARK: - File Management
func refreshLogFiles() {
availableLogFiles = csvManager.listLogFiles()
}
func loadLatestLog() {
if let latest = availableLogFiles.first {
_ = csvManager.openLogFile(latest)
}
}
func openLogFile(_ url: URL) {
_ = csvManager.openLogFile(url)
}
func createNewLogFile(name: String? = nil) {
_ = csvManager.createNewLogFile(name: name)
refreshLogFiles()
}
func exportToFile(_ url: URL) -> Bool {
csvManager.exportToFile(url)
}
func setLogDirectory(_ path: String) {
csvManager.setLogDirectory(path)
refreshLogFiles()
}
// MARK: - QSO Management
func addQSO() {
guard !currentQSO.callsign.isEmpty else { return }
var entry = currentQSO
entry.callsign = entry.callsign.uppercased()
csvManager.addEntry(entry)
resetCurrentQSO()
Logger.shared.log("Added QSO: \(entry.callsign)", level: .info)
}
func updateQSO(_ entry: QSOEntry) {
csvManager.updateEntry(entry)
}
func deleteQSO(_ entry: QSOEntry) {
csvManager.deleteEntry(entry)
}
func deleteQSOs(at offsets: IndexSet) {
// Convert offsets from filtered to original indices
let entriesToDelete = offsets.map { filteredEntries[$0] }
for entry in entriesToDelete {
csvManager.deleteEntry(entry)
}
}
func resetCurrentQSO() {
currentQSO = QSOEntry()
}
// MARK: - Radio Integration
func updateFromRadio(frequency: Int, mode: OperatingMode, power: Int) {
currentQSO.frequency = frequency
currentQSO.mode = mode
currentQSO.power = power
}
// MARK: - Statistics
var bandStatistics: [(band: String, count: Int)] {
var stats: [String: Int] = [:]
for entry in entries {
let band = entry.bandDisplay
stats[band, default: 0] += 1
}
return stats.map { (band: $0.key, count: $0.value) }
.sorted { $0.count > $1.count }
}
var modeStatistics: [(mode: String, count: Int)] {
var stats: [String: Int] = [:]
for entry in entries {
let mode = entry.mode.rawValue
stats[mode, default: 0] += 1
}
return stats.map { (mode: $0.key, count: $0.value) }
.sorted { $0.count > $1.count }
}
// MARK: - Sort Order
enum SortOrder: String, CaseIterable {
case dateDescending = "Datum (neu → alt)"
case dateAscending = "Datum (alt → neu)"
case callsignAscending = "Rufzeichen (A → Z)"
case callsignDescending = "Rufzeichen (Z → A)"
case frequencyAscending = "Frequenz (niedrig → hoch)"
case frequencyDescending = "Frequenz (hoch → niedrig)"
}
}
@@ -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<AnyCancellable>()
// 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)
}
}
@@ -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]
}
@@ -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)
}
@@ -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())
}
@@ -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<Double>
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()
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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())
}
@@ -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..<abs(steps) {
if steps > 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)
}
+144
View File
@@ -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!
+41
View File
@@ -0,0 +1,41 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Virtual environment
venv/
env/
.venv/
# Config with secrets
config.yaml
# Cache
.cache/
# Output files
output/*.html
output/*.pdf
output/*.json
output/*.csv
# IDE
.idea/
.vscode/
*.swp
*.swo
# Distribution / packaging
dist/
build/
*.egg-info/
# Testing
.pytest_cache/
.coverage
htmlcov/
# Misc
.DS_Store
*.log
+198
View File
@@ -0,0 +1,198 @@
# Paperless Finance Report
Ein Python-basiertes CLI-Tool, das über die Paperless-ngx REST-API Dokumente abruft, Beträge und Custom Fields extrahiert und daraus Finanzberichte generiert.
## Features
- **Basis-Auswertung**: Summe aller Beträge nach Tags, Kategorien, Korrespondenten
- **Zeiträume**: Filter nach Jahr, Monat oder beliebigem Datumsbereich
- **Gruppierung**: Nach Tag, Kategorie, Korrespondent, Zahlungsart, Monat, Quartal
- **Vergleichsberichte**: Jahresvergleiche mit Veränderungsanalyse
- **Mehrere Ausgabeformate**: CLI, HTML (mit Chart.js Diagrammen), PDF, JSON, CSV
- **Caching**: Optionaler Festplatten-Cache für bessere Performance
- **Flexibel**: Konfigurierbare Custom Field Namen
## Installation
### Voraussetzungen
- Python 3.8+
- Paperless-ngx Installation mit REST-API Zugriff
- API-Token (erstellen unter: Paperless → Einstellungen → Authentifizierungs-Tokens)
### Installation
```bash
# Repository klonen
git clone https://github.com/yourusername/paperless-report.git
cd paperless-report
# Virtuelle Umgebung erstellen
python3 -m venv venv
source venv/bin/activate # Linux/macOS
# oder: venv\Scripts\activate # Windows
# Dependencies installieren
pip install -r requirements.txt
# Optional: Vollinstallation mit PDF-Support
pip install -e ".[full]"
```
### Konfiguration
```bash
# Beispiel-Konfiguration erstellen
cp config.yaml.example config.yaml
# Konfiguration anpassen
nano config.yaml
```
Mindestens erforderlich:
```yaml
paperless:
url: "http://localhost:8000" # Deine Paperless URL
token: "YOUR_API_TOKEN" # API Token
```
Alternativ kann der Token auch als Umgebungsvariable gesetzt werden:
```bash
export PAPERLESS_TOKEN="your_api_token"
```
## Verwendung
### Verbindung testen
```bash
python main.py test
```
### Jahresbericht
```bash
# CLI-Ausgabe
python main.py report --year 2024
# Mit Details
python main.py report --year 2024 --detail
# HTML-Bericht
python main.py report --year 2024 --format html
# PDF-Bericht
python main.py report --year 2024 --format pdf
```
### Mit Filtern
```bash
# Nach Tag filtern
python main.py report --year 2024 --tag rechnung
# Nach Korrespondent filtern
python main.py report --year 2024 --correspondent "Swisscom"
# Nach Monat filtern
python main.py report --year 2024 --month 6
```
### Gruppierung
```bash
# Nach Tag gruppieren (Standard)
python main.py report --year 2024 --group-by tag
# Nach Korrespondent gruppieren
python main.py report --year 2024 --group-by correspondent
# Nach Kategorie und Monat gruppieren
python main.py report --year 2024 --group-by category --group-by month
```
### Jahresvergleich
```bash
# CLI-Vergleich
python main.py compare 2023 2024
# HTML-Vergleichsbericht
python main.py compare 2023 2024 --format html
```
### Weitere Befehle
```bash
# Dokumente auflisten
python main.py list-docs --tag rechnung --limit 50
# Cache löschen
python main.py clear-cache
# Hilfe anzeigen
python main.py --help
python main.py report --help
```
## Custom Fields in Paperless
Für die volle Funktionalität sollten folgende Custom Fields in Paperless angelegt werden:
| Feldname | Typ | Beschreibung |
|-----------------|----------|---------------------------------------|
| `betrag` | Währung | Rechnungsbetrag |
| `rechnungsdatum`| Datum | Datum der Rechnung |
| `kategorie` | Auswahl | Wohnen, Gesundheit, Mobilität, etc. |
| `zahlungsart` | Auswahl | Bar, Einzahlung, LSV, eBill |
Die Feldnamen können in der `config.yaml` angepasst werden.
## Ausgabeformate
### CLI
Einfache tabellarische Ausgabe im Terminal.
### HTML
Interaktiver Bericht mit:
- Zusammenfassungskarten
- Chart.js Diagramme (Doughnut, Bar, Line)
- Sortierbare Tabellen
- Links zu Paperless-Dokumenten
- Export-Button für CSV
### PDF
Druckfertiger PDF-Bericht (benötigt WeasyPrint).
### JSON
Maschinenlesbares Format für weitere Verarbeitung.
### CSV
Excel-kompatibles Format mit BOM für korrekte Umlaute.
## Projektstruktur
```
paperless-report/
├── config.yaml.example # Beispiel-Konfiguration
├── config.py # Konfigurationsmanagement
├── paperless_client.py # API-Client
├── extractor.py # Datenextraktion und -aggregation
├── report_generator.py # Berichtsgenerierung
├── main.py # CLI-Einstiegspunkt
├── templates/
│ └── report.html # HTML-Template
├── output/ # Generierte Berichte
├── requirements.txt
├── setup.py
└── README.md
```
## Lizenz
MIT License
+24
View File
@@ -0,0 +1,24 @@
"""
Paperless Finance Report Tool
Generiert Finanzberichte aus Paperless-ngx Dokumenten.
"""
__version__ = '1.0.0'
__author__ = 'Your Name'
from config import Config, get_config
from paperless_client import PaperlessClient, PaperlessAPIError
from extractor import DocumentExtractor, DataAggregator, FinanceDocument
from report_generator import ReportGenerator
__all__ = [
'Config',
'get_config',
'PaperlessClient',
'PaperlessAPIError',
'DocumentExtractor',
'DataAggregator',
'FinanceDocument',
'ReportGenerator',
]
+269
View File
@@ -0,0 +1,269 @@
"""
Konfigurationsmanagement für das Paperless Finance Report Tool.
Lädt und validiert die YAML-Konfiguration.
"""
import os
import sys
from pathlib import Path
from typing import Any, Optional
import yaml
class ConfigError(Exception):
"""Fehler bei der Konfiguration."""
pass
class Config:
"""Konfigurationsklasse für das Paperless Finance Report Tool."""
DEFAULT_CONFIG = {
'paperless': {
'url': 'http://localhost:8000',
'token': '',
'timeout': 30,
},
'custom_fields': {
'betrag': 'betrag',
'rechnungsdatum': 'rechnungsdatum',
'kategorie': 'kategorie',
'zahlungsart': 'zahlungsart',
'periode': 'periode',
'notiz': 'notiz',
},
'defaults': {
'currency': 'CHF',
'date_field': 'archive_date',
'invoice_tag': 'rechnung',
},
'tags': ['rechnung'],
'categories': [],
'output': {
'format': 'html',
'path': './output',
'filename_pattern': 'finanzbericht_{year}',
},
'cache': {
'enabled': True,
'path': './.cache',
'ttl': 3600,
},
'logging': {
'level': 'INFO',
'file': '',
'colorize': True,
},
}
def __init__(self, config_path: Optional[str] = None):
"""
Initialisiert die Konfiguration.
Args:
config_path: Pfad zur config.yaml. Falls None, wird im aktuellen
Verzeichnis und im Script-Verzeichnis gesucht.
"""
self._config = self.DEFAULT_CONFIG.copy()
self._config_path = self._find_config(config_path)
if self._config_path:
self._load_config()
self._validate_config()
def _find_config(self, config_path: Optional[str]) -> Optional[Path]:
"""Sucht nach der Konfigurationsdatei."""
if config_path:
path = Path(config_path)
if path.exists():
return path
raise ConfigError(f"Konfigurationsdatei nicht gefunden: {config_path}")
# Suchpfade
search_paths = [
Path.cwd() / 'config.yaml',
Path.cwd() / 'config.yml',
Path(__file__).parent / 'config.yaml',
Path(__file__).parent / 'config.yml',
Path.home() / '.config' / 'paperless-report' / 'config.yaml',
]
# Umgebungsvariable prüfen
env_path = os.environ.get('PAPERLESS_REPORT_CONFIG')
if env_path:
search_paths.insert(0, Path(env_path))
for path in search_paths:
if path.exists():
return path
return None
def _load_config(self) -> None:
"""Lädt die Konfiguration aus der YAML-Datei."""
try:
with open(self._config_path, 'r', encoding='utf-8') as f:
user_config = yaml.safe_load(f) or {}
# Rekursives Merge der Konfiguration
self._config = self._deep_merge(self._config, user_config)
except yaml.YAMLError as e:
raise ConfigError(f"Fehler beim Parsen der Konfiguration: {e}")
except IOError as e:
raise ConfigError(f"Fehler beim Lesen der Konfiguration: {e}")
def _deep_merge(self, base: dict, override: dict) -> dict:
"""Führt zwei Dictionaries rekursiv zusammen."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._deep_merge(result[key], value)
else:
result[key] = value
return result
def _validate_config(self) -> None:
"""Validiert die Konfiguration."""
# Paperless URL prüfen
url = self.get('paperless.url', '')
if not url:
raise ConfigError("Paperless URL muss konfiguriert werden")
# Token prüfen (kann auch über Umgebungsvariable kommen)
token = self.get('paperless.token', '') or os.environ.get('PAPERLESS_TOKEN', '')
if not token:
raise ConfigError(
"Paperless API-Token muss konfiguriert werden.\n"
"Setze 'paperless.token' in config.yaml oder die Umgebungsvariable PAPERLESS_TOKEN"
)
# Token aus Umgebungsvariable übernehmen falls nicht in Config
if not self.get('paperless.token'):
self._config['paperless']['token'] = token
def get(self, key: str, default: Any = None) -> Any:
"""
Holt einen Konfigurationswert über Punkt-Notation.
Args:
key: Schlüssel in Punkt-Notation, z.B. 'paperless.url'
default: Standardwert falls Schlüssel nicht existiert
Returns:
Der Konfigurationswert oder der Standardwert
"""
keys = key.split('.')
value = self._config
try:
for k in keys:
value = value[k]
return value
except (KeyError, TypeError):
return default
def __getitem__(self, key: str) -> Any:
"""Ermöglicht Zugriff via config['key']."""
value = self.get(key)
if value is None:
raise KeyError(key)
return value
@property
def paperless_url(self) -> str:
"""Paperless Base-URL."""
url = self.get('paperless.url', '')
return url.rstrip('/')
@property
def paperless_token(self) -> str:
"""Paperless API-Token."""
return self.get('paperless.token', '')
@property
def timeout(self) -> int:
"""Request-Timeout in Sekunden."""
return self.get('paperless.timeout', 30)
@property
def currency(self) -> str:
"""Standardwährung."""
return self.get('defaults.currency', 'CHF')
@property
def date_field(self) -> str:
"""Datumsfeld für Filterung."""
return self.get('defaults.date_field', 'archive_date')
@property
def output_format(self) -> str:
"""Standard-Ausgabeformat."""
return self.get('output.format', 'html')
@property
def output_path(self) -> Path:
"""Ausgabeverzeichnis."""
return Path(self.get('output.path', './output'))
@property
def cache_enabled(self) -> bool:
"""Cache aktiviert."""
return self.get('cache.enabled', True)
@property
def cache_path(self) -> Path:
"""Cache-Verzeichnis."""
return Path(self.get('cache.path', './.cache'))
@property
def cache_ttl(self) -> int:
"""Cache-Gültigkeit in Sekunden."""
return self.get('cache.ttl', 3600)
@property
def log_level(self) -> str:
"""Log-Level."""
return self.get('logging.level', 'INFO')
@property
def custom_field_names(self) -> dict:
"""Mapping der Custom Field Namen."""
return self.get('custom_fields', {})
def get_custom_field_name(self, internal_name: str) -> str:
"""Holt den Paperless-Feldnamen für ein internes Feld."""
return self.get(f'custom_fields.{internal_name}', internal_name)
# Globale Config-Instanz (lazy loading)
_config: Optional[Config] = None
def get_config(config_path: Optional[str] = None) -> Config:
"""
Holt die globale Konfiguration.
Args:
config_path: Optionaler Pfad zur Konfigurationsdatei
Returns:
Config-Instanz
"""
global _config
if _config is None or config_path is not None:
_config = Config(config_path)
return _config
def reset_config() -> None:
"""Setzt die globale Konfiguration zurück (für Tests)."""
global _config
_config = None
+78
View File
@@ -0,0 +1,78 @@
# Paperless Finance Report - Konfiguration
# Kopiere diese Datei nach config.yaml und passe die Werte an
paperless:
# URL deiner Paperless-ngx Installation
url: "http://localhost:8000"
# API-Token (erstellen unter: Einstellungen → Authentifizierungs-Tokens)
token: "YOUR_API_TOKEN_HERE"
# Timeout für API-Anfragen in Sekunden
timeout: 30
# Mapping der Custom Field Namen in Paperless
# Die Namen müssen exakt mit den in Paperless angelegten Feldern übereinstimmen
custom_fields:
betrag: "betrag"
rechnungsdatum: "rechnungsdatum"
kategorie: "kategorie"
zahlungsart: "zahlungsart"
periode: "periode"
notiz: "notiz"
# Standardeinstellungen
defaults:
# Währung für Beträge
currency: "CHF"
# Welches Datumsfeld für Zeitraumfilter verwendet werden soll
# Optionen: "archive_date", "created", "added", oder ein Custom Field Name
date_field: "archive_date"
# Standard-Tag für Rechnungen (Name, nicht ID)
invoice_tag: "rechnung"
# Tag-Namen die automatisch erkannt werden sollen
# Die IDs werden beim ersten Start automatisch ermittelt
tags:
- rechnung
- miete
- krankenkasse
- steuern
- versicherung
- nebenkosten
# Kategorien für Gruppierung (müssen in Paperless als Auswahl-Optionen existieren)
categories:
- Wohnen
- Gesundheit
- Mobilität
- Versicherungen
- Steuern
- Lebensmittel
- Freizeit
- Diverses
# Ausgabe-Einstellungen
output:
# Standard-Format: html, pdf, json, cli
format: "html"
# Verzeichnis für generierte Berichte
path: "./output"
# Dateiname-Muster (Platzhalter: {year}, {month}, {date}, {timestamp})
filename_pattern: "finanzbericht_{year}"
# Cache-Einstellungen
cache:
# Cache aktivieren
enabled: true
# Cache-Verzeichnis
path: "./.cache"
# Cache-Gültigkeit in Sekunden (Standard: 1 Stunde)
ttl: 3600
# Logging-Einstellungen
logging:
# Log-Level: DEBUG, INFO, WARNING, ERROR
level: "INFO"
# Log-Datei (leer = nur Konsole)
file: ""
# Farbige Ausgabe
colorize: true
+592
View File
@@ -0,0 +1,592 @@
"""
Daten-Extraktion und Aggregation für das Paperless Finance Report Tool.
Extrahiert Custom Fields aus Dokumenten und aggregiert die Daten
für verschiedene Gruppierungen.
"""
import logging
import re
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from dateutil.parser import parse as parse_date
from config import Config, get_config
from paperless_client import PaperlessClient
logger = logging.getLogger(__name__)
@dataclass
class FinanceDocument:
"""Ein aufbereitetes Finanzdokument."""
id: int
title: str
archive_date: Optional[datetime] = None
created: Optional[datetime] = None
added: Optional[datetime] = None
# Paperless Metadata
correspondent: Optional[str] = None
correspondent_id: Optional[int] = None
document_type: Optional[str] = None
tags: List[str] = field(default_factory=list)
tag_ids: List[int] = field(default_factory=list)
# Custom Fields
betrag: Optional[Decimal] = None
rechnungsdatum: Optional[datetime] = None
kategorie: Optional[str] = None
zahlungsart: Optional[str] = None
periode: Optional[str] = None
notiz: Optional[str] = None
# URLs
web_url: Optional[str] = None
# Original-Daten
raw_data: Dict = field(default_factory=dict)
@property
def effective_date(self) -> Optional[datetime]:
"""Das effektive Datum (Rechnungsdatum oder Archivdatum)."""
return self.rechnungsdatum or self.archive_date
@property
def year(self) -> Optional[int]:
"""Jahr des effektiven Datums."""
date = self.effective_date
return date.year if date else None
@property
def month(self) -> Optional[int]:
"""Monat des effektiven Datums."""
date = self.effective_date
return date.month if date else None
@property
def month_year(self) -> Optional[str]:
"""Monat/Jahr als String (z.B. '2024-01')."""
date = self.effective_date
return date.strftime('%Y-%m') if date else None
@property
def quarter(self) -> Optional[str]:
"""Quartal als String (z.B. 'Q1 2024')."""
date = self.effective_date
if not date:
return None
q = (date.month - 1) // 3 + 1
return f"Q{q} {date.year}"
class DocumentExtractor:
"""Extrahiert und verarbeitet Dokumente aus Paperless."""
def __init__(self, client: PaperlessClient, config: Optional[Config] = None):
"""
Initialisiert den Extractor.
Args:
client: Paperless API Client
config: Konfiguration
"""
self.client = client
self.config = config or get_config()
self._custom_fields_map: Dict[str, int] = {}
def _build_custom_fields_map(self) -> None:
"""Baut ein Mapping von Feldnamen zu IDs."""
if self._custom_fields_map:
return
fields = self.client.get_custom_fields()
for field_id, field_def in fields.items():
name = field_def['name'].lower()
self._custom_fields_map[name] = field_id
def _parse_decimal(self, value: Any) -> Optional[Decimal]:
"""
Parst einen Wert zu Decimal.
Verarbeitet verschiedene Formate:
- 1234.56
- 1234,56
- 1'234.56 (Schweizer Format)
- CHF 1234.56
"""
if value is None:
return None
if isinstance(value, (int, float)):
return Decimal(str(value))
if isinstance(value, Decimal):
return value
if not isinstance(value, str):
return None
# String bereinigen
value = value.strip()
# Währungssymbole entfernen
value = re.sub(r'^(CHF|EUR|USD|Fr\.?)\s*', '', value, flags=re.IGNORECASE)
value = re.sub(r'\s*(CHF|EUR|USD|Fr\.?)$', '', value, flags=re.IGNORECASE)
# Tausender-Trennzeichen entfernen (Apostroph, Punkt als Tausender)
# Schweizer Format: 1'234.56 oder 1'234,56
if "'" in value:
value = value.replace("'", "")
# Deutsches/Schweizer Format mit Punkt als Tausender: 1.234,56
if re.match(r'^\d{1,3}(\.\d{3})+,\d{2}$', value):
value = value.replace(".", "").replace(",", ".")
# Komma als Dezimaltrennzeichen ohne Tausender
elif "," in value and "." not in value:
value = value.replace(",", ".")
try:
return Decimal(value)
except InvalidOperation:
logger.warning(f"Konnte Betrag nicht parsen: {value}")
return None
def _parse_date(self, value: Any) -> Optional[datetime]:
"""Parst einen Wert zu datetime."""
if value is None:
return None
if isinstance(value, datetime):
return value
if not isinstance(value, str):
return None
try:
return parse_date(value)
except (ValueError, TypeError):
logger.warning(f"Konnte Datum nicht parsen: {value}")
return None
def _get_custom_field_value(self, doc: dict, field_name: str) -> Any:
"""Holt den Wert eines Custom Fields aus einem Dokument."""
# Aus resolved fields
resolved = doc.get('custom_fields_resolved', {})
if field_name in resolved:
return resolved[field_name].get('value')
# Aus rohen custom_fields
self._build_custom_fields_map()
field_name_lower = field_name.lower()
for cf in doc.get('custom_fields', []):
field_id = cf.get('field')
# Prüfen ob ID zum gesuchten Feldnamen passt
for name, fid in self._custom_fields_map.items():
if fid == field_id and name == field_name_lower:
return cf.get('value')
return None
def extract_document(self, raw_doc: dict) -> FinanceDocument:
"""
Extrahiert ein aufbereitetes FinanceDocument aus den Rohdaten.
Args:
raw_doc: Rohes Dokument-Dictionary von der API
Returns:
FinanceDocument-Instanz
"""
# Custom Field Namen aus Config
cf_names = self.config.custom_field_names
# Basis-Daten
doc = FinanceDocument(
id=raw_doc['id'],
title=raw_doc.get('title', ''),
raw_data=raw_doc
)
# Datums-Felder
doc.archive_date = self._parse_date(raw_doc.get('archive_date'))
doc.created = self._parse_date(raw_doc.get('created'))
doc.added = self._parse_date(raw_doc.get('added'))
# Korrespondent
doc.correspondent_id = raw_doc.get('correspondent')
doc.correspondent = raw_doc.get('correspondent_name', '')
# Dokumenttyp
doc.document_type = raw_doc.get('document_type_name', '')
# Tags
doc.tag_ids = raw_doc.get('tags', [])
doc.tags = raw_doc.get('tag_names', [])
# URL
doc.web_url = raw_doc.get('web_url', '')
# Custom Fields
betrag_name = cf_names.get('betrag', 'betrag')
doc.betrag = self._parse_decimal(
self._get_custom_field_value(raw_doc, betrag_name)
)
datum_name = cf_names.get('rechnungsdatum', 'rechnungsdatum')
doc.rechnungsdatum = self._parse_date(
self._get_custom_field_value(raw_doc, datum_name)
)
kat_name = cf_names.get('kategorie', 'kategorie')
doc.kategorie = self._get_custom_field_value(raw_doc, kat_name)
zahl_name = cf_names.get('zahlungsart', 'zahlungsart')
doc.zahlungsart = self._get_custom_field_value(raw_doc, zahl_name)
periode_name = cf_names.get('periode', 'periode')
doc.periode = self._get_custom_field_value(raw_doc, periode_name)
notiz_name = cf_names.get('notiz', 'notiz')
doc.notiz = self._get_custom_field_value(raw_doc, notiz_name)
return doc
def extract_documents(self, raw_docs: List[dict]) -> List[FinanceDocument]:
"""
Extrahiert mehrere Dokumente.
Args:
raw_docs: Liste von Roh-Dokumenten
Returns:
Liste von FinanceDocument-Instanzen
"""
# Metadaten auflösen
resolved = self.client.resolve_all_metadata(raw_docs)
return [self.extract_document(doc) for doc in resolved]
@dataclass
class AggregationResult:
"""Ergebnis einer Aggregation."""
# Basis-Statistiken
total_amount: Decimal = Decimal('0')
document_count: int = 0
documents_with_amount: int = 0
documents_without_amount: int = 0
# Dokumente
documents: List[FinanceDocument] = field(default_factory=list)
# Gruppierte Daten
by_tag: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_correspondent: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_category: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_payment_type: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_month: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_quarter: Dict[str, 'GroupStats'] = field(default_factory=dict)
by_year: Dict[int, 'GroupStats'] = field(default_factory=dict)
# Zusätzliche Statistiken
average_amount: Decimal = Decimal('0')
median_amount: Decimal = Decimal('0')
min_amount: Decimal = Decimal('0')
max_amount: Decimal = Decimal('0')
top_items: List[FinanceDocument] = field(default_factory=list)
@property
def total_formatted(self) -> str:
"""Formatierte Gesamtsumme."""
return f"{self.total_amount:,.2f}".replace(',', "'")
@dataclass
class GroupStats:
"""Statistiken für eine Gruppe."""
name: str
amount: Decimal = Decimal('0')
count: int = 0
percentage: float = 0.0
documents: List[FinanceDocument] = field(default_factory=list)
@property
def amount_formatted(self) -> str:
"""Formatierter Betrag."""
return f"{self.amount:,.2f}".replace(',', "'")
class DataAggregator:
"""Aggregiert Finanzdokumente nach verschiedenen Kriterien."""
def __init__(self, config: Optional[Config] = None):
"""
Initialisiert den Aggregator.
Args:
config: Konfiguration
"""
self.config = config or get_config()
def aggregate(
self,
documents: List[FinanceDocument],
group_by: Optional[List[str]] = None
) -> AggregationResult:
"""
Aggregiert Dokumente.
Args:
documents: Liste von Dokumenten
group_by: Liste von Gruppierungskriterien:
'tag', 'correspondent', 'category', 'payment_type',
'month', 'quarter', 'year'
Returns:
AggregationResult mit allen Statistiken
"""
result = AggregationResult()
result.documents = documents
result.document_count = len(documents)
# Beträge sammeln
amounts: List[Decimal] = []
for doc in documents:
if doc.betrag is not None:
result.total_amount += doc.betrag
result.documents_with_amount += 1
amounts.append(doc.betrag)
else:
result.documents_without_amount += 1
# Basis-Statistiken
if amounts:
amounts_sorted = sorted(amounts)
result.min_amount = amounts_sorted[0]
result.max_amount = amounts_sorted[-1]
result.average_amount = result.total_amount / len(amounts)
# Median
mid = len(amounts_sorted) // 2
if len(amounts_sorted) % 2 == 0:
result.median_amount = (amounts_sorted[mid - 1] + amounts_sorted[mid]) / 2
else:
result.median_amount = amounts_sorted[mid]
# Top-Posten
docs_with_amount = [d for d in documents if d.betrag is not None]
result.top_items = sorted(
docs_with_amount,
key=lambda d: d.betrag or Decimal('0'),
reverse=True
)[:10]
# Gruppierungen
group_by = group_by or ['tag', 'correspondent', 'category', 'month']
if 'tag' in group_by:
result.by_tag = self._group_by_tags(documents, result.total_amount)
if 'correspondent' in group_by:
result.by_correspondent = self._group_by_field(
documents, 'correspondent', result.total_amount
)
if 'category' in group_by:
result.by_category = self._group_by_field(
documents, 'kategorie', result.total_amount
)
if 'payment_type' in group_by:
result.by_payment_type = self._group_by_field(
documents, 'zahlungsart', result.total_amount
)
if 'month' in group_by:
result.by_month = self._group_by_field(
documents, 'month_year', result.total_amount
)
if 'quarter' in group_by:
result.by_quarter = self._group_by_field(
documents, 'quarter', result.total_amount
)
if 'year' in group_by:
result.by_year = self._group_by_field(
documents, 'year', result.total_amount
)
return result
def _group_by_tags(
self,
documents: List[FinanceDocument],
total: Decimal
) -> Dict[str, GroupStats]:
"""Gruppiert nach Tags (ein Dokument kann mehrere Tags haben)."""
groups: Dict[str, GroupStats] = {}
for doc in documents:
if not doc.tags:
tag_name = 'Ohne Tag'
if tag_name not in groups:
groups[tag_name] = GroupStats(name=tag_name)
groups[tag_name].count += 1
if doc.betrag:
groups[tag_name].amount += doc.betrag
groups[tag_name].documents.append(doc)
else:
for tag in doc.tags:
if tag not in groups:
groups[tag] = GroupStats(name=tag)
groups[tag].count += 1
if doc.betrag:
groups[tag].amount += doc.betrag
groups[tag].documents.append(doc)
# Prozente berechnen
if total > 0:
for stats in groups.values():
stats.percentage = float(stats.amount / total * 100)
# Nach Betrag sortieren
return dict(sorted(
groups.items(),
key=lambda x: x[1].amount,
reverse=True
))
def _group_by_field(
self,
documents: List[FinanceDocument],
field: str,
total: Decimal
) -> Dict[str, GroupStats]:
"""Gruppiert nach einem einzelnen Feld."""
groups: Dict[str, GroupStats] = {}
for doc in documents:
value = getattr(doc, field, None)
if value is None or value == '':
key = 'Nicht zugeordnet'
else:
key = str(value)
if key not in groups:
groups[key] = GroupStats(name=key)
groups[key].count += 1
if doc.betrag:
groups[key].amount += doc.betrag
groups[key].documents.append(doc)
# Prozente berechnen
if total > 0:
for stats in groups.values():
stats.percentage = float(stats.amount / total * 100)
# Nach Betrag sortieren (bei Monaten chronologisch)
if field in ('month_year', 'quarter'):
return dict(sorted(groups.items()))
else:
return dict(sorted(
groups.items(),
key=lambda x: x[1].amount,
reverse=True
))
def compare_periods(
self,
documents: List[FinanceDocument],
period1: Union[int, str],
period2: Union[int, str],
period_type: str = 'year'
) -> Dict[str, Any]:
"""
Vergleicht zwei Zeiträume.
Args:
documents: Alle Dokumente
period1: Erste Periode (z.B. 2023)
period2: Zweite Periode (z.B. 2024)
period_type: 'year', 'quarter', 'month'
Returns:
Vergleichsergebnis
"""
# Dokumente nach Periode filtern
def get_period(doc: FinanceDocument) -> Optional[Union[int, str]]:
if period_type == 'year':
return doc.year
elif period_type == 'quarter':
return doc.quarter
elif period_type == 'month':
return doc.month_year
return None
docs1 = [d for d in documents if get_period(d) == period1]
docs2 = [d for d in documents if get_period(d) == period2]
agg1 = self.aggregate(docs1, ['tag', 'category'])
agg2 = self.aggregate(docs2, ['tag', 'category'])
# Differenzen berechnen
diff_absolute = agg2.total_amount - agg1.total_amount
diff_percent = (
float(diff_absolute / agg1.total_amount * 100)
if agg1.total_amount > 0 else 0
)
# Kategorien vergleichen
category_comparison = {}
all_categories = set(agg1.by_category.keys()) | set(agg2.by_category.keys())
for cat in all_categories:
stats1 = agg1.by_category.get(cat, GroupStats(name=cat))
stats2 = agg2.by_category.get(cat, GroupStats(name=cat))
diff = stats2.amount - stats1.amount
pct_change = (
float(diff / stats1.amount * 100)
if stats1.amount > 0 else (100.0 if stats2.amount > 0 else 0)
)
category_comparison[cat] = {
'period1': stats1.amount,
'period2': stats2.amount,
'diff_absolute': diff,
'diff_percent': pct_change,
'status': 'new' if stats1.amount == 0 else (
'removed' if stats2.amount == 0 else 'changed'
)
}
return {
'period1': {
'name': str(period1),
'total': agg1.total_amount,
'count': agg1.document_count,
'aggregation': agg1,
},
'period2': {
'name': str(period2),
'total': agg2.total_amount,
'count': agg2.document_count,
'aggregation': agg2,
},
'diff_absolute': diff_absolute,
'diff_percent': diff_percent,
'category_comparison': category_comparison,
}
+489
View File
@@ -0,0 +1,489 @@
#!/usr/bin/env python3
"""
Paperless Finance Report Tool
CLI-Einstiegspunkt für das Paperless Finanz-Auswertungstool.
Generiert Finanzberichte aus Paperless-ngx Dokumenten.
"""
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Optional
import click
from tabulate import tabulate
# Lokale Imports
from config import Config, ConfigError, get_config, reset_config
from extractor import DataAggregator, DocumentExtractor
from paperless_client import PaperlessAPIError, PaperlessClient
from report_generator import ReportGenerator
# Logger einrichten
logger = logging.getLogger('paperless_report')
def setup_logging(level: str = 'INFO', colorize: bool = True) -> None:
"""Richtet das Logging ein."""
log_level = getattr(logging, level.upper(), logging.INFO)
if colorize:
try:
import colorlog
handler = colorlog.StreamHandler()
handler.setFormatter(colorlog.ColoredFormatter(
'%(log_color)s%(levelname)-8s%(reset)s %(message)s',
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'red,bg_white',
}
))
except ImportError:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(levelname)-8s %(message)s'))
else:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(levelname)-8s %(message)s'))
logger.addHandler(handler)
logger.setLevel(log_level)
# Auch für andere Module
logging.getLogger('paperless_report').setLevel(log_level)
def get_cache(config: Config):
"""Erstellt den Cache falls aktiviert."""
if not config.cache_enabled:
return None
try:
from diskcache import Cache
cache_path = config.cache_path
cache_path.mkdir(parents=True, exist_ok=True)
return Cache(str(cache_path))
except ImportError:
logger.warning("diskcache nicht installiert, Cache deaktiviert")
return None
# CLI-Gruppe
@click.group()
@click.option('--config', '-c', 'config_path', type=click.Path(exists=True),
help='Pfad zur Konfigurationsdatei')
@click.option('--verbose', '-v', is_flag=True, help='Ausführliche Ausgabe')
@click.option('--quiet', '-q', is_flag=True, help='Nur Fehler ausgeben')
@click.pass_context
def cli(ctx, config_path: Optional[str], verbose: bool, quiet: bool):
"""
Paperless Finance Report Tool
Generiert Finanzberichte aus Paperless-ngx Dokumenten.
Beispiele:
# Jahresbericht 2024
paperless-report report --year 2024
# Mit Tag-Filter
paperless-report report --year 2024 --tag rechnung
# Jahresvergleich
paperless-report compare 2023 2024
# Verbindung testen
paperless-report test
"""
ctx.ensure_object(dict)
# Log-Level bestimmen
if quiet:
log_level = 'ERROR'
elif verbose:
log_level = 'DEBUG'
else:
log_level = 'INFO'
setup_logging(log_level)
# Config laden
try:
reset_config()
config = get_config(config_path)
ctx.obj['config'] = config
except ConfigError as e:
click.echo(f"Konfigurationsfehler: {e}", err=True)
sys.exit(1)
@cli.command()
@click.pass_context
def test(ctx):
"""Testet die Verbindung zur Paperless-API."""
config = ctx.obj['config']
click.echo(f"Teste Verbindung zu {config.paperless_url}...")
try:
cache = get_cache(config)
client = PaperlessClient(config, cache)
if client.test_connection():
click.echo(click.style("Verbindung erfolgreich!", fg='green'))
# Statistiken anzeigen
click.echo("\nStatistiken:")
tags = client.get_tags()
correspondents = client.get_correspondents()
custom_fields = client.get_custom_fields()
click.echo(f" Tags: {len(tags)}")
click.echo(f" Korrespondenten: {len(correspondents)}")
click.echo(f" Custom Fields: {len(custom_fields)}")
# Custom Fields auflisten
if custom_fields:
click.echo("\nCustom Fields:")
for field_id, field in custom_fields.items():
click.echo(f" - {field['name']} (Typ: {field.get('data_type', 'unknown')})")
else:
click.echo(click.style("Verbindung fehlgeschlagen!", fg='red'))
sys.exit(1)
except PaperlessAPIError as e:
click.echo(click.style(f"API-Fehler: {e}", fg='red'), err=True)
sys.exit(1)
@cli.command()
@click.option('--year', '-y', type=int, help='Jahr für den Bericht')
@click.option('--month', '-m', type=int, help='Monat (1-12)')
@click.option('--tag', '-t', 'tags', multiple=True, help='Nach Tag filtern (mehrfach möglich)')
@click.option('--correspondent', help='Nach Korrespondent filtern')
@click.option('--group-by', '-g', 'group_by',
type=click.Choice(['tag', 'correspondent', 'category', 'payment_type', 'month', 'quarter', 'year']),
multiple=True, default=['tag', 'category', 'month'],
help='Gruppierung (mehrfach möglich)')
@click.option('--format', '-f', 'output_format',
type=click.Choice(['cli', 'html', 'pdf', 'json', 'csv']),
default='cli', help='Ausgabeformat')
@click.option('--output', '-o', 'output_file', type=click.Path(),
help='Ausgabedatei (optional)')
@click.option('--detail', '-d', is_flag=True, help='Detaillierte Ausgabe')
@click.option('--no-cache', is_flag=True, help='Cache ignorieren')
@click.pass_context
def report(ctx, year: Optional[int], month: Optional[int], tags: tuple,
correspondent: Optional[str], group_by: tuple, output_format: str,
output_file: Optional[str], detail: bool, no_cache: bool):
"""
Generiert einen Finanzbericht.
Beispiele:
# Jahresbericht 2024 als CLI
paperless-report report --year 2024
# HTML-Bericht mit Tag-Filter
paperless-report report --year 2024 --tag rechnung --format html
# Detaillierter Bericht nach Korrespondent gruppiert
paperless-report report --year 2024 --group-by correspondent --detail
# PDF für einen bestimmten Monat
paperless-report report --year 2024 --month 6 --format pdf
"""
config = ctx.obj['config']
# Standard: aktuelles Jahr
if not year:
year = datetime.now().year
click.echo(f"Kein Jahr angegeben, verwende {year}")
try:
cache = None if no_cache else get_cache(config)
client = PaperlessClient(config, cache)
extractor = DocumentExtractor(client, config)
aggregator = DataAggregator(config)
generator = ReportGenerator(config)
# Dokumente abrufen
click.echo(f"Lade Dokumente für {year}" + (f"/{month}" if month else "") + "...")
with click.progressbar(length=1, label='API-Abfrage') as bar:
raw_docs = client.get_documents(
tags=list(tags) if tags else None,
correspondent=correspondent,
year=year,
month=month,
)
bar.update(1)
if not raw_docs:
click.echo(click.style("Keine Dokumente gefunden.", fg='yellow'))
return
click.echo(f"Gefunden: {len(raw_docs)} Dokumente")
# Dokumente extrahieren
click.echo("Extrahiere Daten...")
documents = extractor.extract_documents(raw_docs)
# Aggregieren
click.echo("Aggregiere Daten...")
result = aggregator.aggregate(documents, list(group_by))
# Titel generieren
if month:
title = f"Paperless Finanzbericht {month:02d}/{year}"
else:
title = f"Paperless Finanzbericht {year}"
# Ausgabe
if output_format == 'cli':
output = generator.generate_cli(result, title, detail)
click.echo()
click.echo(output)
elif output_format == 'html':
if output_file:
path = Path(output_file)
else:
path = generator.save_html(result, title, year, month)
click.echo(click.style(f"HTML-Bericht gespeichert: {path}", fg='green'))
# Bericht öffnen?
if click.confirm("Bericht im Browser öffnen?", default=True):
import webbrowser
webbrowser.open(f"file://{path.absolute()}")
elif output_format == 'pdf':
if output_file:
path = Path(output_file)
pdf_bytes = generator.generate_pdf(result, title, year, month)
with open(path, 'wb') as f:
f.write(pdf_bytes)
else:
path = generator.save_pdf(result, title, year, month)
click.echo(click.style(f"PDF-Bericht gespeichert: {path}", fg='green'))
elif output_format == 'json':
if output_file:
path = Path(output_file)
json_str = generator.generate_json(result)
with open(path, 'w', encoding='utf-8') as f:
f.write(json_str)
else:
path = generator.save_json(result, year, month)
click.echo(click.style(f"JSON-Export gespeichert: {path}", fg='green'))
elif output_format == 'csv':
if output_file:
path = Path(output_file)
csv_str = generator.generate_csv(documents)
with open(path, 'w', encoding='utf-8-sig') as f:
f.write(csv_str)
else:
path = generator.save_csv(documents, year, month)
click.echo(click.style(f"CSV-Export gespeichert: {path}", fg='green'))
except PaperlessAPIError as e:
click.echo(click.style(f"API-Fehler: {e}", fg='red'), err=True)
sys.exit(1)
except Exception as e:
logger.exception("Unerwarteter Fehler")
click.echo(click.style(f"Fehler: {e}", fg='red'), err=True)
sys.exit(1)
@cli.command()
@click.argument('period1', type=int)
@click.argument('period2', type=int)
@click.option('--tag', '-t', 'tags', multiple=True, help='Nach Tag filtern')
@click.option('--format', '-f', 'output_format',
type=click.Choice(['cli', 'html']), default='cli',
help='Ausgabeformat')
@click.option('--output', '-o', 'output_file', type=click.Path(),
help='Ausgabedatei')
@click.pass_context
def compare(ctx, period1: int, period2: int, tags: tuple,
output_format: str, output_file: Optional[str]):
"""
Vergleicht zwei Zeiträume (Jahre).
Beispiele:
# Jahresvergleich 2023 vs 2024
paperless-report compare 2023 2024
# Mit Tag-Filter
paperless-report compare 2023 2024 --tag rechnung
# Als HTML
paperless-report compare 2023 2024 --format html
"""
config = ctx.obj['config']
try:
cache = get_cache(config)
client = PaperlessClient(config, cache)
extractor = DocumentExtractor(client, config)
aggregator = DataAggregator(config)
generator = ReportGenerator(config)
# Dokumente für beide Perioden laden
click.echo(f"Lade Dokumente für {period1} und {period2}...")
raw_docs_1 = client.get_documents(
tags=list(tags) if tags else None,
year=period1
)
raw_docs_2 = client.get_documents(
tags=list(tags) if tags else None,
year=period2
)
click.echo(f"Gefunden: {len(raw_docs_1)} ({period1}) / {len(raw_docs_2)} ({period2})")
# Dokumente zusammenführen und extrahieren
all_raw_docs = raw_docs_1 + raw_docs_2
all_docs = extractor.extract_documents(all_raw_docs)
# Vergleich
click.echo("Vergleiche Perioden...")
comparison = aggregator.compare_periods(all_docs, period1, period2)
if output_format == 'cli':
output = generator.generate_comparison_cli(comparison)
click.echo()
click.echo(output)
elif output_format == 'html':
# Aggregation für das neuere Jahr als Basis
docs_2 = [d for d in all_docs if d.year == period2]
result = aggregator.aggregate(docs_2, ['tag', 'category', 'month'])
title = f"Vergleich {period1} vs {period2}"
if output_file:
path = Path(output_file)
html = generator.generate_html(result, title, period2, comparison=comparison)
with open(path, 'w', encoding='utf-8') as f:
f.write(html)
else:
path = generator.save_html(result, title, period2, comparison=comparison)
click.echo(click.style(f"Vergleichsbericht gespeichert: {path}", fg='green'))
except PaperlessAPIError as e:
click.echo(click.style(f"API-Fehler: {e}", fg='red'), err=True)
sys.exit(1)
@cli.command()
@click.option('--tag', '-t', 'tags', multiple=True, help='Nach Tag filtern')
@click.option('--year', '-y', type=int, help='Jahr')
@click.option('--limit', '-l', type=int, default=20, help='Anzahl Dokumente')
@click.pass_context
def list_docs(ctx, tags: tuple, year: Optional[int], limit: int):
"""
Listet Dokumente auf.
Beispiele:
# Letzte 20 Dokumente
paperless-report list-docs
# Mit Tag-Filter
paperless-report list-docs --tag rechnung --limit 50
"""
config = ctx.obj['config']
try:
cache = get_cache(config)
client = PaperlessClient(config, cache)
extractor = DocumentExtractor(client, config)
raw_docs = client.get_documents(
tags=list(tags) if tags else None,
year=year
)
if not raw_docs:
click.echo("Keine Dokumente gefunden.")
return
documents = extractor.extract_documents(raw_docs[:limit])
# Tabelle erstellen
table_data = []
for doc in documents:
table_data.append([
doc.id,
(doc.effective_date.strftime('%d.%m.%Y')
if doc.effective_date else '-'),
doc.title[:40] + ('...' if len(doc.title) > 40 else ''),
doc.correspondent[:20] if doc.correspondent else '-',
(f"{config.currency} {doc.betrag:,.2f}".replace(',', "'")
if doc.betrag else '-'),
])
headers = ['ID', 'Datum', 'Titel', 'Korrespondent', 'Betrag']
click.echo(tabulate(table_data, headers=headers, tablefmt='simple'))
click.echo(f"\nGesamt: {len(raw_docs)} Dokumente (zeige {min(limit, len(raw_docs))})")
except PaperlessAPIError as e:
click.echo(click.style(f"API-Fehler: {e}", fg='red'), err=True)
sys.exit(1)
@cli.command()
@click.pass_context
def clear_cache(ctx):
"""Löscht den Cache."""
config = ctx.obj['config']
cache_path = config.cache_path
if cache_path.exists():
import shutil
shutil.rmtree(cache_path)
click.echo(click.style("Cache gelöscht.", fg='green'))
else:
click.echo("Kein Cache vorhanden.")
@cli.command()
@click.pass_context
def init(ctx):
"""Erstellt eine Beispiel-Konfigurationsdatei."""
config_file = Path.cwd() / 'config.yaml'
if config_file.exists():
if not click.confirm(f"{config_file} existiert bereits. Überschreiben?"):
return
# Beispiel-Config kopieren
example_config = Path(__file__).parent / 'config.yaml.example'
if example_config.exists():
import shutil
shutil.copy(example_config, config_file)
click.echo(click.style(f"Konfiguration erstellt: {config_file}", fg='green'))
click.echo("\nBitte bearbeite die Datei und setze:")
click.echo(" - paperless.url: URL deiner Paperless-Installation")
click.echo(" - paperless.token: API-Token")
else:
click.echo(click.style("Beispiel-Konfiguration nicht gefunden.", fg='red'))
def main():
"""Haupteinstiegspunkt."""
cli(obj={})
if __name__ == '__main__':
main()
+1
View File
@@ -0,0 +1 @@
# Dieses Verzeichnis enthält generierte Berichte
+537
View File
@@ -0,0 +1,537 @@
"""
Paperless-ngx API Client.
Handhabt die Kommunikation mit der Paperless REST-API inkl. Paginierung und Caching.
"""
import hashlib
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Generator, List, Optional, Union
from urllib.parse import urlencode, urljoin
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from config import Config, get_config
logger = logging.getLogger(__name__)
class PaperlessAPIError(Exception):
"""Fehler bei der API-Kommunikation."""
def __init__(self, message: str, status_code: Optional[int] = None, response: Optional[dict] = None):
super().__init__(message)
self.status_code = status_code
self.response = response
class PaperlessClient:
"""Client für die Paperless-ngx REST-API."""
# API-Endpunkte
ENDPOINTS = {
'documents': '/api/documents/',
'tags': '/api/tags/',
'correspondents': '/api/correspondents/',
'document_types': '/api/document_types/',
'custom_fields': '/api/custom_fields/',
'storage_paths': '/api/storage_paths/',
}
def __init__(self, config: Optional[Config] = None, cache: Optional[Any] = None):
"""
Initialisiert den API-Client.
Args:
config: Konfigurationsobjekt. Falls None, wird globale Config verwendet.
cache: Optionales Cache-Objekt (diskcache.Cache)
"""
self.config = config or get_config()
self.base_url = self.config.paperless_url
self.token = self.config.paperless_token
self.timeout = self.config.timeout
self.cache = cache
# Session mit Retry-Logik erstellen
self.session = self._create_session()
# Cached Metadata
self._custom_fields_cache: Optional[Dict[int, dict]] = None
self._tags_cache: Optional[Dict[int, dict]] = None
self._correspondents_cache: Optional[Dict[int, dict]] = None
self._document_types_cache: Optional[Dict[int, dict]] = None
def _create_session(self) -> requests.Session:
"""Erstellt eine Session mit Retry-Konfiguration."""
session = requests.Session()
# Retry-Strategie
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount('http://', adapter)
session.mount('https://', adapter)
# Standard-Header
session.headers.update({
'Authorization': f'Token {self.token}',
'Accept': 'application/json',
'Content-Type': 'application/json',
})
return session
def _get_cache_key(self, endpoint: str, params: Optional[dict] = None) -> str:
"""Generiert einen Cache-Schlüssel."""
key_data = f"{self.base_url}{endpoint}"
if params:
key_data += json.dumps(params, sort_keys=True)
return hashlib.md5(key_data.encode()).hexdigest()
def _request(
self,
method: str,
endpoint: str,
params: Optional[dict] = None,
data: Optional[dict] = None,
use_cache: bool = True
) -> dict:
"""
Führt einen API-Request durch.
Args:
method: HTTP-Methode (GET, POST, etc.)
endpoint: API-Endpunkt (relativ zur Base-URL)
params: Query-Parameter
data: Request-Body
use_cache: Cache verwenden (nur für GET)
Returns:
API-Response als Dictionary
"""
url = urljoin(self.base_url, endpoint)
# Cache prüfen (nur GET-Requests)
if method.upper() == 'GET' and use_cache and self.cache:
cache_key = self._get_cache_key(endpoint, params)
cached = self.cache.get(cache_key)
if cached is not None:
logger.debug(f"Cache hit für {endpoint}")
return cached
logger.debug(f"API Request: {method} {url} params={params}")
try:
response = self.session.request(
method=method,
url=url,
params=params,
json=data,
timeout=self.timeout
)
response.raise_for_status()
result = response.json()
# In Cache speichern (nur GET)
if method.upper() == 'GET' and use_cache and self.cache:
self.cache.set(cache_key, result, expire=self.config.cache_ttl)
return result
except requests.exceptions.HTTPError as e:
error_msg = f"HTTP-Fehler: {e}"
try:
error_detail = e.response.json()
error_msg = f"{error_msg} - {error_detail}"
except (ValueError, AttributeError):
pass
raise PaperlessAPIError(
error_msg,
status_code=e.response.status_code if e.response else None
)
except requests.exceptions.ConnectionError as e:
raise PaperlessAPIError(f"Verbindungsfehler: Kann {self.base_url} nicht erreichen")
except requests.exceptions.Timeout as e:
raise PaperlessAPIError(f"Timeout nach {self.timeout}s")
except requests.exceptions.RequestException as e:
raise PaperlessAPIError(f"Request-Fehler: {e}")
def _get_paginated(
self,
endpoint: str,
params: Optional[dict] = None,
page_size: int = 100
) -> Generator[dict, None, None]:
"""
Holt alle Seiten eines paginierten Endpunkts.
Args:
endpoint: API-Endpunkt
params: Zusätzliche Query-Parameter
page_size: Anzahl Ergebnisse pro Seite
Yields:
Einzelne Ergebnis-Objekte
"""
params = params or {}
params['page_size'] = page_size
page = 1
while True:
params['page'] = page
logger.debug(f"Lade Seite {page} von {endpoint}")
response = self._request('GET', endpoint, params=params)
results = response.get('results', [])
for item in results:
yield item
# Prüfen ob weitere Seiten existieren
if not response.get('next'):
break
page += 1
def test_connection(self) -> bool:
"""
Testet die Verbindung zur Paperless-API.
Returns:
True wenn Verbindung erfolgreich
"""
try:
self._request('GET', self.ENDPOINTS['tags'], params={'page_size': 1})
return True
except PaperlessAPIError:
return False
# ==================== Custom Fields ====================
def get_custom_fields(self, refresh: bool = False) -> Dict[int, dict]:
"""
Holt alle Custom Field Definitionen.
Args:
refresh: Cache ignorieren und neu laden
Returns:
Dictionary mit Field-ID als Key und Definition als Value
"""
if self._custom_fields_cache is not None and not refresh:
return self._custom_fields_cache
fields = {}
for field in self._get_paginated(self.ENDPOINTS['custom_fields']):
fields[field['id']] = field
self._custom_fields_cache = fields
logger.info(f"Geladen: {len(fields)} Custom Fields")
return fields
def get_custom_field_by_name(self, name: str) -> Optional[dict]:
"""
Findet ein Custom Field anhand des Namens.
Args:
name: Name des Custom Fields
Returns:
Field-Definition oder None
"""
fields = self.get_custom_fields()
for field in fields.values():
if field['name'].lower() == name.lower():
return field
return None
# ==================== Tags ====================
def get_tags(self, refresh: bool = False) -> Dict[int, dict]:
"""
Holt alle Tags.
Returns:
Dictionary mit Tag-ID als Key
"""
if self._tags_cache is not None and not refresh:
return self._tags_cache
tags = {}
for tag in self._get_paginated(self.ENDPOINTS['tags']):
tags[tag['id']] = tag
self._tags_cache = tags
logger.info(f"Geladen: {len(tags)} Tags")
return tags
def get_tag_by_name(self, name: str) -> Optional[dict]:
"""Findet einen Tag anhand des Namens."""
tags = self.get_tags()
for tag in tags.values():
if tag['name'].lower() == name.lower():
return tag
return None
def get_tag_id(self, name: str) -> Optional[int]:
"""Holt die ID eines Tags anhand des Namens."""
tag = self.get_tag_by_name(name)
return tag['id'] if tag else None
# ==================== Correspondents ====================
def get_correspondents(self, refresh: bool = False) -> Dict[int, dict]:
"""
Holt alle Korrespondenten.
Returns:
Dictionary mit Correspondent-ID als Key
"""
if self._correspondents_cache is not None and not refresh:
return self._correspondents_cache
correspondents = {}
for corr in self._get_paginated(self.ENDPOINTS['correspondents']):
correspondents[corr['id']] = corr
self._correspondents_cache = correspondents
logger.info(f"Geladen: {len(correspondents)} Korrespondenten")
return correspondents
def get_correspondent_name(self, correspondent_id: int) -> str:
"""Holt den Namen eines Korrespondenten."""
correspondents = self.get_correspondents()
corr = correspondents.get(correspondent_id)
return corr['name'] if corr else f"Unbekannt ({correspondent_id})"
# ==================== Document Types ====================
def get_document_types(self, refresh: bool = False) -> Dict[int, dict]:
"""Holt alle Dokumenttypen."""
if self._document_types_cache is not None and not refresh:
return self._document_types_cache
doc_types = {}
for dt in self._get_paginated(self.ENDPOINTS['document_types']):
doc_types[dt['id']] = dt
self._document_types_cache = doc_types
return doc_types
# ==================== Documents ====================
def get_documents(
self,
tags: Optional[List[Union[int, str]]] = None,
correspondent: Optional[Union[int, str]] = None,
document_type: Optional[Union[int, str]] = None,
year: Optional[int] = None,
month: Optional[int] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
query: Optional[str] = None,
ordering: str = '-archive_date',
**extra_filters
) -> List[dict]:
"""
Holt Dokumente mit optionalen Filtern.
Args:
tags: Liste von Tag-IDs oder Namen
correspondent: Korrespondent-ID oder Name
document_type: Dokumenttyp-ID oder Name
year: Jahr (für archive_date)
month: Monat (1-12, nur zusammen mit year)
date_from: Startdatum
date_to: Enddatum
query: Volltextsuche
ordering: Sortierung
**extra_filters: Zusätzliche Filter für die API
Returns:
Liste von Dokumenten
"""
params = {'ordering': ordering}
# Tags verarbeiten
if tags:
tag_ids = []
for tag in tags:
if isinstance(tag, int):
tag_ids.append(tag)
else:
tag_id = self.get_tag_id(tag)
if tag_id:
tag_ids.append(tag_id)
else:
logger.warning(f"Tag nicht gefunden: {tag}")
if tag_ids:
params['tags__id__in'] = ','.join(str(t) for t in tag_ids)
# Korrespondent
if correspondent:
if isinstance(correspondent, str):
correspondents = self.get_correspondents()
for c in correspondents.values():
if c['name'].lower() == correspondent.lower():
params['correspondent__id'] = c['id']
break
else:
params['correspondent__id'] = correspondent
# Dokumenttyp
if document_type:
if isinstance(document_type, str):
doc_types = self.get_document_types()
for dt in doc_types.values():
if dt['name'].lower() == document_type.lower():
params['document_type__id'] = dt['id']
break
else:
params['document_type__id'] = document_type
# Datumsfilter
date_field = self.config.date_field
if year:
if month:
# Spezifischer Monat
if month == 12:
next_year = year + 1
next_month = 1
else:
next_year = year
next_month = month + 1
params[f'{date_field}__gte'] = f'{year}-{month:02d}-01'
params[f'{date_field}__lt'] = f'{next_year}-{next_month:02d}-01'
else:
# Ganzes Jahr
params[f'{date_field}__year'] = year
if date_from:
params[f'{date_field}__gte'] = date_from.strftime('%Y-%m-%d')
if date_to:
params[f'{date_field}__lte'] = date_to.strftime('%Y-%m-%d')
# Volltextsuche
if query:
params['query'] = query
# Extra-Filter
params.update(extra_filters)
# Alle Dokumente abrufen
documents = list(self._get_paginated(self.ENDPOINTS['documents'], params))
logger.info(f"Geladen: {len(documents)} Dokumente")
return documents
def get_document(self, document_id: int) -> dict:
"""
Holt ein einzelnes Dokument.
Args:
document_id: ID des Dokuments
Returns:
Dokument-Dictionary
"""
endpoint = f"{self.ENDPOINTS['documents']}{document_id}/"
return self._request('GET', endpoint)
def get_document_url(self, document_id: int) -> str:
"""Generiert die Web-URL für ein Dokument."""
return f"{self.base_url}/documents/{document_id}/details"
def get_document_download_url(self, document_id: int) -> str:
"""Generiert die Download-URL für ein Dokument."""
return f"{self.base_url}/api/documents/{document_id}/download/"
# ==================== Hilfsmethoden ====================
def resolve_all_metadata(self, documents: List[dict]) -> List[dict]:
"""
Erweitert Dokumente um aufgelöste Metadaten (Tag-Namen, Korrespondent-Namen, etc.).
Args:
documents: Liste von Dokumenten
Returns:
Erweiterte Dokumente
"""
tags = self.get_tags()
correspondents = self.get_correspondents()
doc_types = self.get_document_types()
custom_fields = self.get_custom_fields()
for doc in documents:
# Tag-Namen
doc['tag_names'] = [
tags.get(tid, {}).get('name', f'Unknown-{tid}')
for tid in doc.get('tags', [])
]
# Korrespondent-Name
corr_id = doc.get('correspondent')
doc['correspondent_name'] = (
correspondents.get(corr_id, {}).get('name', '')
if corr_id else ''
)
# Dokumenttyp-Name
dt_id = doc.get('document_type')
doc['document_type_name'] = (
doc_types.get(dt_id, {}).get('name', '')
if dt_id else ''
)
# Custom Fields aufbereiten
doc['custom_fields_resolved'] = {}
for cf in doc.get('custom_fields', []):
field_id = cf.get('field')
field_def = custom_fields.get(field_id, {})
field_name = field_def.get('name', f'field_{field_id}')
doc['custom_fields_resolved'][field_name] = {
'value': cf.get('value'),
'type': field_def.get('data_type', 'string'),
'field_id': field_id
}
# URL hinzufügen
doc['web_url'] = self.get_document_url(doc['id'])
return documents
def get_statistics(self) -> dict:
"""
Holt allgemeine Statistiken.
Returns:
Dictionary mit Statistiken
"""
return {
'total_documents': len(list(self._get_paginated(
self.ENDPOINTS['documents'],
params={'page_size': 1}
))),
'total_tags': len(self.get_tags()),
'total_correspondents': len(self.get_correspondents()),
'total_custom_fields': len(self.get_custom_fields()),
}
+628
View File
@@ -0,0 +1,628 @@
"""
Report Generator für das Paperless Finance Report Tool.
Generiert Berichte in verschiedenen Formaten: CLI, HTML, PDF, JSON.
"""
import json
import logging
import os
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from jinja2 import Environment, FileSystemLoader, select_autoescape
from config import Config, get_config
from extractor import AggregationResult, FinanceDocument, GroupStats
logger = logging.getLogger(__name__)
class DecimalEncoder(json.JSONEncoder):
"""JSON Encoder für Decimal-Werte."""
def default(self, obj):
if isinstance(obj, Decimal):
return float(obj)
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, FinanceDocument):
return {
'id': obj.id,
'title': obj.title,
'betrag': float(obj.betrag) if obj.betrag else None,
'effective_date': obj.effective_date.isoformat() if obj.effective_date else None,
'correspondent': obj.correspondent,
'kategorie': obj.kategorie,
'tags': obj.tags,
'web_url': obj.web_url,
}
if isinstance(obj, GroupStats):
return {
'name': obj.name,
'amount': float(obj.amount),
'count': obj.count,
'percentage': obj.percentage,
}
return super().default(obj)
class ReportGenerator:
"""Generiert Finanzberichte in verschiedenen Formaten."""
def __init__(self, config: Optional[Config] = None):
"""
Initialisiert den Report Generator.
Args:
config: Konfiguration
"""
self.config = config or get_config()
self.currency = self.config.currency
# Jinja2 Template-Umgebung
template_dir = Path(__file__).parent / 'templates'
self.jinja_env = Environment(
loader=FileSystemLoader(str(template_dir)),
autoescape=select_autoescape(['html', 'xml']),
)
# Custom Filter registrieren
self.jinja_env.filters['format_amount'] = self._format_amount
self.jinja_env.filters['format_percent'] = self._format_percent
self.jinja_env.filters['format_date'] = self._format_date
def _format_amount(self, value: Optional[Decimal], with_currency: bool = True) -> str:
"""Formatiert einen Betrag."""
if value is None:
return '-'
formatted = f"{value:,.2f}".replace(',', "'")
if with_currency:
return f"{self.currency} {formatted}"
return formatted
def _format_percent(self, value: float) -> str:
"""Formatiert einen Prozentwert."""
return f"{value:.1f}%"
def _format_date(self, value: Optional[datetime], fmt: str = '%d.%m.%Y') -> str:
"""Formatiert ein Datum."""
if value is None:
return '-'
return value.strftime(fmt)
def _ensure_output_dir(self) -> Path:
"""Stellt sicher, dass das Ausgabeverzeichnis existiert."""
output_dir = self.config.output_path
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
def _get_output_filename(
self,
year: Optional[int] = None,
month: Optional[int] = None,
extension: str = 'html'
) -> str:
"""Generiert den Ausgabe-Dateinamen."""
pattern = self.config.get('output.filename_pattern', 'finanzbericht_{year}')
now = datetime.now()
filename = pattern.format(
year=year or now.year,
month=month or now.month,
date=now.strftime('%Y-%m-%d'),
timestamp=now.strftime('%Y%m%d_%H%M%S'),
)
return f"{filename}.{extension}"
# ==================== CLI Output ====================
def generate_cli(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
detail: bool = False
) -> str:
"""
Generiert CLI-Ausgabe.
Args:
result: Aggregationsergebnis
title: Berichtstitel
detail: Detailansicht aktivieren
Returns:
Formatierter String für CLI-Ausgabe
"""
lines = []
sep = "=" * 60
# Header
lines.append(sep)
lines.append(title.center(60))
lines.append(sep)
lines.append("")
# Übersicht
lines.append(f"Dokumente gesamt: {result.document_count}")
lines.append(f" - mit Betrag: {result.documents_with_amount}")
lines.append(f" - ohne Betrag: {result.documents_without_amount}")
lines.append("")
lines.append(f"Gesamtsumme: {self._format_amount(result.total_amount)}")
lines.append(f"Durchschnitt: {self._format_amount(result.average_amount)}")
lines.append(f"Median: {self._format_amount(result.median_amount)}")
lines.append(f"Minimum: {self._format_amount(result.min_amount)}")
lines.append(f"Maximum: {self._format_amount(result.max_amount)}")
lines.append("")
# Nach Tag
if result.by_tag:
lines.append("-" * 60)
lines.append("Nach Tag:")
lines.append("-" * 60)
for name, stats in result.by_tag.items():
amount_str = self._format_amount(stats.amount).rjust(18)
pct_str = f"({stats.percentage:5.1f}%)"
lines.append(f" {name:<25} {amount_str} {pct_str}")
lines.append("")
# Nach Korrespondent
if result.by_correspondent and detail:
lines.append("-" * 60)
lines.append("Nach Korrespondent:")
lines.append("-" * 60)
for name, stats in list(result.by_correspondent.items())[:15]:
amount_str = self._format_amount(stats.amount).rjust(18)
pct_str = f"({stats.percentage:5.1f}%)"
lines.append(f" {name[:25]:<25} {amount_str} {pct_str}")
if len(result.by_correspondent) > 15:
lines.append(f" ... und {len(result.by_correspondent) - 15} weitere")
lines.append("")
# Nach Kategorie
if result.by_category:
lines.append("-" * 60)
lines.append("Nach Kategorie:")
lines.append("-" * 60)
for name, stats in result.by_category.items():
amount_str = self._format_amount(stats.amount).rjust(18)
pct_str = f"({stats.percentage:5.1f}%)"
lines.append(f" {name[:25]:<25} {amount_str} {pct_str}")
lines.append("")
# Nach Monat
if result.by_month:
lines.append("-" * 60)
lines.append("Nach Monat:")
lines.append("-" * 60)
for month, stats in result.by_month.items():
amount_str = self._format_amount(stats.amount).rjust(18)
lines.append(f" {month:<10} {amount_str} ({stats.count} Dok.)")
lines.append("")
# Nach Zahlungsart
if result.by_payment_type and detail:
lines.append("-" * 60)
lines.append("Nach Zahlungsart:")
lines.append("-" * 60)
for name, stats in result.by_payment_type.items():
amount_str = self._format_amount(stats.amount).rjust(18)
pct_str = f"({stats.percentage:5.1f}%)"
lines.append(f" {name:<25} {amount_str} {pct_str}")
lines.append("")
# Top-Posten
if result.top_items and detail:
lines.append("-" * 60)
lines.append("Top 10 Einzelposten:")
lines.append("-" * 60)
for i, doc in enumerate(result.top_items[:10], 1):
amount_str = self._format_amount(doc.betrag).rjust(18)
title = doc.title[:35]
lines.append(f" {i:2}. {title:<35} {amount_str}")
lines.append("")
lines.append(sep)
lines.append(f"Generiert: {datetime.now().strftime('%d.%m.%Y %H:%M')}")
lines.append(sep)
return "\n".join(lines)
# ==================== HTML Output ====================
def generate_html(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
year: Optional[int] = None,
month: Optional[int] = None,
comparison: Optional[Dict] = None
) -> str:
"""
Generiert HTML-Bericht.
Args:
result: Aggregationsergebnis
title: Berichtstitel
year: Jahr für den Bericht
month: Monat für den Bericht (optional)
comparison: Vergleichsdaten (optional)
Returns:
HTML-String
"""
template = self.jinja_env.get_template('report.html')
# Chart-Daten vorbereiten
tag_chart_data = self._prepare_chart_data(result.by_tag)
category_chart_data = self._prepare_chart_data(result.by_category)
month_chart_data = self._prepare_line_chart_data(result.by_month)
correspondent_chart_data = self._prepare_chart_data(
dict(list(result.by_correspondent.items())[:10])
)
context = {
'title': title,
'year': year,
'month': month,
'currency': self.currency,
'generated_at': datetime.now(),
'result': result,
'comparison': comparison,
# Chart-Daten als JSON
'tag_chart_data': json.dumps(tag_chart_data),
'category_chart_data': json.dumps(category_chart_data),
'month_chart_data': json.dumps(month_chart_data),
'correspondent_chart_data': json.dumps(correspondent_chart_data),
}
return template.render(**context)
def _prepare_chart_data(self, groups: Dict[str, GroupStats]) -> Dict[str, Any]:
"""Bereitet Daten für ein Balken-/Kreisdiagramm vor."""
labels = []
values = []
colors = self._generate_colors(len(groups))
for name, stats in groups.items():
labels.append(name)
values.append(float(stats.amount))
return {
'labels': labels,
'values': values,
'colors': colors,
}
def _prepare_line_chart_data(self, groups: Dict[str, GroupStats]) -> Dict[str, Any]:
"""Bereitet Daten für ein Liniendiagramm vor."""
# Nach Datum sortieren
sorted_items = sorted(groups.items())
labels = [item[0] for item in sorted_items]
values = [float(item[1].amount) for item in sorted_items]
return {
'labels': labels,
'values': values,
}
def _generate_colors(self, count: int) -> List[str]:
"""Generiert eine Farbpalette."""
# Vordefinierte Farben
colors = [
'#2E86AB', # Blau
'#A23B72', # Magenta
'#F18F01', # Orange
'#C73E1D', # Rot
'#3B1F2B', # Dunkelrot
'#95C623', # Grün
'#5C5D67', # Grau
'#E8D21D', # Gelb
'#1B998B', # Türkis
'#7768AE', # Lila
]
# Farben wiederholen falls nötig
while len(colors) < count:
colors.extend(colors)
return colors[:count]
def save_html(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
year: Optional[int] = None,
month: Optional[int] = None,
comparison: Optional[Dict] = None,
filename: Optional[str] = None
) -> Path:
"""
Speichert HTML-Bericht als Datei.
Returns:
Pfad zur erstellten Datei
"""
html = self.generate_html(result, title, year, month, comparison)
output_dir = self._ensure_output_dir()
if filename is None:
filename = self._get_output_filename(year, month, 'html')
output_path = output_dir / filename
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html)
logger.info(f"HTML-Bericht gespeichert: {output_path}")
return output_path
# ==================== PDF Output ====================
def generate_pdf(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
year: Optional[int] = None,
month: Optional[int] = None,
comparison: Optional[Dict] = None
) -> bytes:
"""
Generiert PDF-Bericht.
Returns:
PDF als Bytes
"""
try:
from weasyprint import HTML, CSS
except ImportError:
raise ImportError(
"WeasyPrint ist nicht installiert. "
"Installiere mit: pip install weasyprint"
)
# HTML generieren
html_content = self.generate_html(result, title, year, month, comparison)
# PDF generieren
html = HTML(string=html_content)
# Zusätzliches CSS für PDF
pdf_css = CSS(string='''
@page {
size: A4;
margin: 2cm;
}
body {
font-size: 10pt;
}
.chart-container {
page-break-inside: avoid;
}
table {
page-break-inside: avoid;
}
''')
return html.write_pdf(stylesheets=[pdf_css])
def save_pdf(
self,
result: AggregationResult,
title: str = "Paperless Finanzbericht",
year: Optional[int] = None,
month: Optional[int] = None,
comparison: Optional[Dict] = None,
filename: Optional[str] = None
) -> Path:
"""
Speichert PDF-Bericht als Datei.
Returns:
Pfad zur erstellten Datei
"""
pdf_bytes = self.generate_pdf(result, title, year, month, comparison)
output_dir = self._ensure_output_dir()
if filename is None:
filename = self._get_output_filename(year, month, 'pdf')
output_path = output_dir / filename
with open(output_path, 'wb') as f:
f.write(pdf_bytes)
logger.info(f"PDF-Bericht gespeichert: {output_path}")
return output_path
# ==================== JSON Output ====================
def generate_json(
self,
result: AggregationResult,
indent: int = 2
) -> str:
"""
Generiert JSON-Ausgabe.
Returns:
JSON-String
"""
data = {
'generated_at': datetime.now().isoformat(),
'currency': self.currency,
'summary': {
'total_amount': result.total_amount,
'document_count': result.document_count,
'documents_with_amount': result.documents_with_amount,
'documents_without_amount': result.documents_without_amount,
'average_amount': result.average_amount,
'median_amount': result.median_amount,
'min_amount': result.min_amount,
'max_amount': result.max_amount,
},
'by_tag': result.by_tag,
'by_correspondent': result.by_correspondent,
'by_category': result.by_category,
'by_payment_type': result.by_payment_type,
'by_month': result.by_month,
'top_items': result.top_items[:20],
'documents': result.documents,
}
return json.dumps(data, indent=indent, cls=DecimalEncoder, ensure_ascii=False)
def save_json(
self,
result: AggregationResult,
year: Optional[int] = None,
month: Optional[int] = None,
filename: Optional[str] = None
) -> Path:
"""
Speichert JSON-Bericht als Datei.
Returns:
Pfad zur erstellten Datei
"""
json_str = self.generate_json(result)
output_dir = self._ensure_output_dir()
if filename is None:
filename = self._get_output_filename(year, month, 'json')
output_path = output_dir / filename
with open(output_path, 'w', encoding='utf-8') as f:
f.write(json_str)
logger.info(f"JSON-Bericht gespeichert: {output_path}")
return output_path
# ==================== CSV Output ====================
def generate_csv(
self,
documents: List[FinanceDocument],
delimiter: str = ';'
) -> str:
"""
Generiert CSV-Export der Dokumente.
Returns:
CSV-String
"""
lines = []
# Header
headers = [
'ID', 'Titel', 'Datum', 'Betrag', 'Korrespondent',
'Kategorie', 'Zahlungsart', 'Tags', 'URL'
]
lines.append(delimiter.join(headers))
# Daten
for doc in documents:
row = [
str(doc.id),
f'"{doc.title}"' if delimiter in doc.title else doc.title,
self._format_date(doc.effective_date),
self._format_amount(doc.betrag, with_currency=False) if doc.betrag else '',
doc.correspondent or '',
doc.kategorie or '',
doc.zahlungsart or '',
', '.join(doc.tags),
doc.web_url or '',
]
lines.append(delimiter.join(row))
return '\n'.join(lines)
def save_csv(
self,
documents: List[FinanceDocument],
year: Optional[int] = None,
month: Optional[int] = None,
filename: Optional[str] = None
) -> Path:
"""Speichert CSV-Export als Datei."""
csv_str = self.generate_csv(documents)
output_dir = self._ensure_output_dir()
if filename is None:
filename = self._get_output_filename(year, month, 'csv')
output_path = output_dir / filename
with open(output_path, 'w', encoding='utf-8-sig') as f: # BOM für Excel
f.write(csv_str)
logger.info(f"CSV-Export gespeichert: {output_path}")
return output_path
# ==================== Vergleichsbericht ====================
def generate_comparison_cli(self, comparison: Dict) -> str:
"""Generiert CLI-Ausgabe für Periodenvergleich."""
lines = []
sep = "=" * 70
p1 = comparison['period1']
p2 = comparison['period2']
lines.append(sep)
lines.append(f"Vergleich: {p1['name']} vs {p2['name']}".center(70))
lines.append(sep)
lines.append("")
# Übersicht
lines.append(f"{'Kennzahl':<30} {p1['name']:>15} {p2['name']:>15} {'Diff':>10}")
lines.append("-" * 70)
lines.append(
f"{'Gesamtsumme':<30} "
f"{self._format_amount(p1['total'], False):>15} "
f"{self._format_amount(p2['total'], False):>15} "
f"{comparison['diff_percent']:>+9.1f}%"
)
lines.append(
f"{'Anzahl Dokumente':<30} "
f"{p1['count']:>15} "
f"{p2['count']:>15} "
f"{p2['count'] - p1['count']:>+10}"
)
lines.append("")
lines.append("-" * 70)
lines.append("Nach Kategorie:")
lines.append("-" * 70)
for cat, data in sorted(
comparison['category_comparison'].items(),
key=lambda x: abs(x[1]['diff_absolute']),
reverse=True
):
status = ""
if data['status'] == 'new':
status = "[NEU]"
elif data['status'] == 'removed':
status = "[ENTF]"
lines.append(
f" {cat[:25]:<25} "
f"{self._format_amount(data['period1'], False):>12} "
f"{self._format_amount(data['period2'], False):>12} "
f"{data['diff_percent']:>+8.1f}% "
f"{status}"
)
lines.append("")
lines.append(sep)
return "\n".join(lines)
+31
View File
@@ -0,0 +1,31 @@
# Paperless Finance Report Tool - Dependencies
# HTTP Client
requests>=2.31.0
# CLI Framework
click>=8.1.7
# Configuration
pyyaml>=6.0.1
# HTML Templating
jinja2>=3.1.2
# PDF Generation
weasyprint>=60.1
# Data Processing
python-dateutil>=2.8.2
# Caching
diskcache>=5.6.3
# Logging (colorized output)
colorlog>=6.8.0
# Progress bars
tqdm>=4.66.1
# Table formatting for CLI
tabulate>=0.9.0
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Setup-Skript für das Paperless Finance Report Tool.
"""
from setuptools import setup, find_packages
from pathlib import Path
# README einlesen
readme_path = Path(__file__).parent / 'README.md'
long_description = ''
if readme_path.exists():
long_description = readme_path.read_text(encoding='utf-8')
setup(
name='paperless-report',
version='1.0.0',
description='Finanz-Auswertungstool für Paperless-ngx',
long_description=long_description,
long_description_content_type='text/markdown',
author='Your Name',
author_email='your.email@example.com',
url='https://github.com/yourusername/paperless-report',
license='MIT',
py_modules=[
'main',
'config',
'paperless_client',
'extractor',
'report_generator',
],
include_package_data=True,
package_data={
'': ['templates/*.html', 'config.yaml.example'],
},
install_requires=[
'requests>=2.31.0',
'click>=8.1.7',
'pyyaml>=6.0.1',
'jinja2>=3.1.2',
'python-dateutil>=2.8.2',
'tabulate>=0.9.0',
'tqdm>=4.66.1',
],
extras_require={
'full': [
'weasyprint>=60.1',
'diskcache>=5.6.3',
'colorlog>=6.8.0',
],
'dev': [
'pytest>=7.4.0',
'pytest-cov>=4.1.0',
'black>=23.7.0',
'flake8>=6.1.0',
'mypy>=1.5.0',
],
},
entry_points={
'console_scripts': [
'paperless-report=main:main',
],
},
python_requires='>=3.8',
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Console',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Topic :: Office/Business :: Financial :: Accounting',
],
keywords='paperless paperless-ngx finance report accounting',
)
+848
View File
@@ -0,0 +1,848 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
:root {
--primary-color: #2E86AB;
--secondary-color: #A23B72;
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
--light-bg: #f8f9fa;
--border-color: #dee2e6;
--text-color: #212529;
--text-muted: #6c757d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: #fff;
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
h1, h2, h3 {
margin-bottom: 1rem;
color: var(--primary-color);
}
h1 {
font-size: 2rem;
border-bottom: 3px solid var(--primary-color);
padding-bottom: 0.5rem;
margin-bottom: 1.5rem;
}
h2 {
font-size: 1.5rem;
margin-top: 2rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header-info {
text-align: right;
color: var(--text-muted);
font-size: 0.9rem;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.card {
background: var(--light-bg);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card-title {
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.card-value {
font-size: 1.75rem;
font-weight: bold;
color: var(--primary-color);
}
.card-value.highlight {
color: var(--secondary-color);
}
.card-subtitle {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 2rem;
margin: 2rem 0;
}
.chart-wrapper {
background: var(--light-bg);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-wrapper h3 {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.chart-container {
position: relative;
height: 300px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.9rem;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--light-bg);
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
}
tr:hover {
background-color: var(--light-bg);
}
.text-right {
text-align: right;
}
.amount {
font-family: 'SF Mono', Consolas, monospace;
white-space: nowrap;
}
.amount-positive {
color: var(--danger-color);
}
.percentage {
color: var(--text-muted);
font-size: 0.85rem;
}
.progress-bar {
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin-top: 0.25rem;
}
.progress-bar-fill {
height: 100%;
background-color: var(--primary-color);
border-radius: 4px;
}
.tag {
display: inline-block;
padding: 0.2rem 0.6rem;
background-color: var(--primary-color);
color: white;
border-radius: 12px;
font-size: 0.75rem;
margin: 0.1rem;
}
.document-link {
color: var(--primary-color);
text-decoration: none;
}
.document-link:hover {
text-decoration: underline;
}
.section {
margin: 2rem 0;
}
.comparison-table {
margin-top: 1rem;
}
.comparison-table .change-positive {
color: var(--danger-color);
}
.comparison-table .change-negative {
color: var(--success-color);
}
.comparison-table .new-item {
background-color: #fff3cd;
}
.comparison-table .removed-item {
background-color: #f8d7da;
}
.export-buttons {
margin: 2rem 0;
display: flex;
gap: 1rem;
}
.btn {
display: inline-block;
padding: 0.5rem 1rem;
background-color: var(--primary-color);
color: white;
text-decoration: none;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn:hover {
opacity: 0.9;
}
.btn-secondary {
background-color: var(--text-muted);
}
.footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
@media print {
body {
padding: 0;
}
.export-buttons {
display: none;
}
.chart-wrapper {
page-break-inside: avoid;
}
table {
page-break-inside: avoid;
}
}
@media (max-width: 768px) {
.charts-container {
grid-template-columns: 1fr;
}
.summary-cards {
grid-template-columns: 1fr 1fr;
}
}
</style>
</head>
<body>
<div class="header">
<h1>{{ title }}</h1>
<div class="header-info">
{% if year %}
<div><strong>Zeitraum:</strong>
{% if month %}
{{ '%02d'|format(month) }}/{{ year }}
{% else %}
{{ year }}
{% endif %}
</div>
{% endif %}
<div><strong>Generiert:</strong> {{ generated_at|format_date('%d.%m.%Y %H:%M') }}</div>
</div>
</div>
<!-- Zusammenfassung -->
<div class="summary-cards">
<div class="card">
<div class="card-title">Gesamtsumme</div>
<div class="card-value highlight">{{ result.total_amount|format_amount }}</div>
<div class="card-subtitle">{{ result.documents_with_amount }} Dokumente mit Betrag</div>
</div>
<div class="card">
<div class="card-title">Dokumente</div>
<div class="card-value">{{ result.document_count }}</div>
<div class="card-subtitle">{{ result.documents_without_amount }} ohne Betrag</div>
</div>
<div class="card">
<div class="card-title">Durchschnitt</div>
<div class="card-value">{{ result.average_amount|format_amount }}</div>
<div class="card-subtitle">pro Dokument</div>
</div>
<div class="card">
<div class="card-title">Median</div>
<div class="card-value">{{ result.median_amount|format_amount }}</div>
<div class="card-subtitle">Min: {{ result.min_amount|format_amount }}</div>
</div>
</div>
<!-- Diagramme -->
<div class="charts-container">
{% if result.by_tag %}
<div class="chart-wrapper">
<h3>Verteilung nach Tag</h3>
<div class="chart-container">
<canvas id="tagChart"></canvas>
</div>
</div>
{% endif %}
{% if result.by_category %}
<div class="chart-wrapper">
<h3>Verteilung nach Kategorie</h3>
<div class="chart-container">
<canvas id="categoryChart"></canvas>
</div>
</div>
{% endif %}
{% if result.by_month %}
<div class="chart-wrapper">
<h3>Monatsverlauf</h3>
<div class="chart-container">
<canvas id="monthChart"></canvas>
</div>
</div>
{% endif %}
{% if result.by_correspondent %}
<div class="chart-wrapper">
<h3>Top 10 Korrespondenten</h3>
<div class="chart-container">
<canvas id="correspondentChart"></canvas>
</div>
</div>
{% endif %}
</div>
<!-- Nach Tag -->
{% if result.by_tag %}
<div class="section">
<h2>Nach Tag</h2>
<table>
<thead>
<tr>
<th>Tag</th>
<th class="text-right">Betrag</th>
<th class="text-right">Anteil</th>
<th class="text-right">Anzahl</th>
<th style="width: 200px;">Verteilung</th>
</tr>
</thead>
<tbody>
{% for name, stats in result.by_tag.items() %}
<tr>
<td><span class="tag">{{ name }}</span></td>
<td class="text-right amount">{{ stats.amount|format_amount }}</td>
<td class="text-right percentage">{{ stats.percentage|format_percent }}</td>
<td class="text-right">{{ stats.count }}</td>
<td>
<div class="progress-bar">
<div class="progress-bar-fill" style="width: {{ stats.percentage }}%"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Nach Kategorie -->
{% if result.by_category %}
<div class="section">
<h2>Nach Kategorie</h2>
<table>
<thead>
<tr>
<th>Kategorie</th>
<th class="text-right">Betrag</th>
<th class="text-right">Anteil</th>
<th class="text-right">Anzahl</th>
<th style="width: 200px;">Verteilung</th>
</tr>
</thead>
<tbody>
{% for name, stats in result.by_category.items() %}
<tr>
<td>{{ name }}</td>
<td class="text-right amount">{{ stats.amount|format_amount }}</td>
<td class="text-right percentage">{{ stats.percentage|format_percent }}</td>
<td class="text-right">{{ stats.count }}</td>
<td>
<div class="progress-bar">
<div class="progress-bar-fill" style="width: {{ stats.percentage }}%"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Nach Monat -->
{% if result.by_month %}
<div class="section">
<h2>Nach Monat</h2>
<table>
<thead>
<tr>
<th>Monat</th>
<th class="text-right">Betrag</th>
<th class="text-right">Anzahl</th>
</tr>
</thead>
<tbody>
{% for month, stats in result.by_month.items() %}
<tr>
<td>{{ month }}</td>
<td class="text-right amount">{{ stats.amount|format_amount }}</td>
<td class="text-right">{{ stats.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Nach Korrespondent -->
{% if result.by_correspondent %}
<div class="section">
<h2>Nach Korrespondent</h2>
<table>
<thead>
<tr>
<th>Korrespondent</th>
<th class="text-right">Betrag</th>
<th class="text-right">Anteil</th>
<th class="text-right">Anzahl</th>
</tr>
</thead>
<tbody>
{% for name, stats in result.by_correspondent.items() %}
<tr>
<td>{{ name }}</td>
<td class="text-right amount">{{ stats.amount|format_amount }}</td>
<td class="text-right percentage">{{ stats.percentage|format_percent }}</td>
<td class="text-right">{{ stats.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Top Einzelposten -->
{% if result.top_items %}
<div class="section">
<h2>Top 10 Einzelposten</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>Titel</th>
<th>Datum</th>
<th>Korrespondent</th>
<th class="text-right">Betrag</th>
</tr>
</thead>
<tbody>
{% for doc in result.top_items[:10] %}
<tr>
<td>{{ loop.index }}</td>
<td>
{% if doc.web_url %}
<a href="{{ doc.web_url }}" class="document-link" target="_blank">{{ doc.title }}</a>
{% else %}
{{ doc.title }}
{% endif %}
</td>
<td>{{ doc.effective_date|format_date }}</td>
<td>{{ doc.correspondent or '-' }}</td>
<td class="text-right amount amount-positive">{{ doc.betrag|format_amount }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Vergleich -->
{% if comparison %}
<div class="section">
<h2>Periodenvergleich: {{ comparison.period1.name }} vs {{ comparison.period2.name }}</h2>
<div class="summary-cards">
<div class="card">
<div class="card-title">{{ comparison.period1.name }}</div>
<div class="card-value">{{ comparison.period1.total|format_amount }}</div>
<div class="card-subtitle">{{ comparison.period1.count }} Dokumente</div>
</div>
<div class="card">
<div class="card-title">{{ comparison.period2.name }}</div>
<div class="card-value">{{ comparison.period2.total|format_amount }}</div>
<div class="card-subtitle">{{ comparison.period2.count }} Dokumente</div>
</div>
<div class="card">
<div class="card-title">Veränderung</div>
<div class="card-value {% if comparison.diff_percent > 0 %}change-positive{% else %}change-negative{% endif %}">
{{ '%+.1f'|format(comparison.diff_percent) }}%
</div>
<div class="card-subtitle">{{ comparison.diff_absolute|format_amount }}</div>
</div>
</div>
<table class="comparison-table">
<thead>
<tr>
<th>Kategorie</th>
<th class="text-right">{{ comparison.period1.name }}</th>
<th class="text-right">{{ comparison.period2.name }}</th>
<th class="text-right">Differenz</th>
<th class="text-right">Veränderung</th>
</tr>
</thead>
<tbody>
{% for cat, data in comparison.category_comparison.items() %}
<tr class="{% if data.status == 'new' %}new-item{% elif data.status == 'removed' %}removed-item{% endif %}">
<td>{{ cat }}</td>
<td class="text-right amount">{{ data.period1|format_amount }}</td>
<td class="text-right amount">{{ data.period2|format_amount }}</td>
<td class="text-right amount">{{ data.diff_absolute|format_amount }}</td>
<td class="text-right {% if data.diff_percent > 0 %}change-positive{% else %}change-negative{% endif %}">
{{ '%+.1f'|format(data.diff_percent) }}%
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Alle Dokumente -->
<div class="section">
<h2>Alle Dokumente ({{ result.document_count }})</h2>
<table id="documentsTable">
<thead>
<tr>
<th>Datum</th>
<th>Titel</th>
<th>Korrespondent</th>
<th>Tags</th>
<th class="text-right">Betrag</th>
</tr>
</thead>
<tbody>
{% for doc in result.documents %}
<tr>
<td>{{ doc.effective_date|format_date }}</td>
<td>
{% if doc.web_url %}
<a href="{{ doc.web_url }}" class="document-link" target="_blank">{{ doc.title }}</a>
{% else %}
{{ doc.title }}
{% endif %}
</td>
<td>{{ doc.correspondent or '-' }}</td>
<td>
{% for tag in doc.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</td>
<td class="text-right amount">{{ doc.betrag|format_amount if doc.betrag else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Export-Buttons -->
<div class="export-buttons">
<button class="btn" onclick="window.print()">Drucken / PDF</button>
<button class="btn btn-secondary" onclick="exportTableToCSV()">CSV exportieren</button>
</div>
<div class="footer">
<p>Paperless Finance Report &bull; Generiert am {{ generated_at|format_date('%d.%m.%Y um %H:%M') }}</p>
</div>
<script>
// Chart-Daten von Jinja
const tagChartData = {{ tag_chart_data|safe }};
const categoryChartData = {{ category_chart_data|safe }};
const monthChartData = {{ month_chart_data|safe }};
const correspondentChartData = {{ correspondent_chart_data|safe }};
const currency = "{{ currency }}";
// Formatierung für Tooltips
function formatAmount(value) {
return currency + " " + value.toLocaleString('de-CH', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
// Tag-Chart (Doughnut)
if (tagChartData.labels && tagChartData.labels.length > 0) {
const tagCtx = document.getElementById('tagChart');
if (tagCtx) {
new Chart(tagCtx, {
type: 'doughnut',
data: {
labels: tagChartData.labels,
datasets: [{
data: tagChartData.values,
backgroundColor: tagChartData.colors,
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 12,
padding: 10
}
},
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': ' + formatAmount(context.raw);
}
}
}
}
}
});
}
}
// Kategorie-Chart (Bar)
if (categoryChartData.labels && categoryChartData.labels.length > 0) {
const catCtx = document.getElementById('categoryChart');
if (catCtx) {
new Chart(catCtx, {
type: 'bar',
data: {
labels: categoryChartData.labels,
datasets: [{
data: categoryChartData.values,
backgroundColor: categoryChartData.colors,
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
return formatAmount(context.raw);
}
}
}
},
scales: {
x: {
ticks: {
callback: function(value) {
return formatAmount(value);
}
}
}
}
}
});
}
}
// Monats-Chart (Line)
if (monthChartData.labels && monthChartData.labels.length > 0) {
const monthCtx = document.getElementById('monthChart');
if (monthCtx) {
new Chart(monthCtx, {
type: 'line',
data: {
labels: monthChartData.labels,
datasets: [{
data: monthChartData.values,
borderColor: '#2E86AB',
backgroundColor: 'rgba(46, 134, 171, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
return formatAmount(context.raw);
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return formatAmount(value);
}
}
}
}
}
});
}
}
// Korrespondent-Chart (Bar horizontal)
if (correspondentChartData.labels && correspondentChartData.labels.length > 0) {
const corrCtx = document.getElementById('correspondentChart');
if (corrCtx) {
new Chart(corrCtx, {
type: 'bar',
data: {
labels: correspondentChartData.labels,
datasets: [{
data: correspondentChartData.values,
backgroundColor: '#A23B72',
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function(context) {
return formatAmount(context.raw);
}
}
}
},
scales: {
x: {
ticks: {
callback: function(value) {
return formatAmount(value);
}
}
}
}
}
});
}
}
// CSV Export
function exportTableToCSV() {
const table = document.getElementById('documentsTable');
const rows = table.querySelectorAll('tr');
let csv = [];
rows.forEach(row => {
const cells = row.querySelectorAll('th, td');
const rowData = [];
cells.forEach(cell => {
let text = cell.innerText.replace(/"/g, '""');
if (text.includes(';') || text.includes('"') || text.includes('\n')) {
text = '"' + text + '"';
}
rowData.push(text);
});
csv.push(rowData.join(';'));
});
const csvContent = '\ufeff' + csv.join('\n'); // BOM for Excel
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'finanzbericht_export.csv';
link.click();
}
</script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
# Zwölfton-Synthesizer (Dodekaphonie)
Ein interaktiver Web-Synthesizer basierend auf Arnold Schönbergs Zwölftontechnik.
## Features
- **Automatische Zwölfton-Komposition**: Generiert fortlaufend Musik nach den Regeln der Dodekaphonie
- **Vier Reihenformen**: Original, Krebs (Retrograde), Umkehrung (Inversion), Krebsumkehrung
- **Reverb-Effekt**: Einstellbarer Hall mit Convolver-basierter Impulsantwort
- **Web Audio API**: Echtzeit-Klangsynthese im Browser
- **Audio-Visualisierung**: Wellenform und Frequenzspektrum in Echtzeit
- **Zwölftonmatrix**: Vollständige 12x12-Matrix aller möglichen Transpositionen
## Die Zwölftontechnik
Die Dodekaphonie wurde von Arnold Schönberg um 1921 entwickelt:
1. Alle 12 chromatischen Töne werden gleichberechtigt verwendet
2. Kein Ton darf wiederholt werden, bevor alle anderen gespielt wurden
3. Die Grundreihe erscheint in vier Formen:
- **Original (O)**: Die Grundreihe
- **Krebs (R)**: Rückwärts gespielt
- **Umkehrung (I)**: Intervalle gespiegelt
- **Krebsumkehrung (RI)**: Kombination aus Krebs und Umkehrung
4. Jede Form kann auf alle 12 Stufen transponiert werden (48 mögliche Reihen)
## Installation
1. PHP-Server starten (PHP 7.4+ erforderlich):
```bash
cd twelve-tone-synthesizer
php -S localhost:8000
```
2. Browser öffnen: `http://localhost:8000`
## Bedienung
- **Starten**: Startet die automatische Wiedergabe der Zwölftonreihe
- **Stoppen**: Beendet die Wiedergabe
- **Neue Reihe**: Generiert eine zufällige neue Zwölftonreihe
### Klangparameter
- **Tempo (BPM)**: Geschwindigkeit der Notenwiedergabe (40-300)
- **Oktave**: Tonhöhenbereich (2-6)
- **Reverb**: Hallanteil (0-100%)
- **Attack**: Einschwingzeit der Töne
- **Release**: Ausklingzeit der Töne
- **Wellenform**: Sinus, Dreieck, Rechteck, Sägezahn
## Technologie
- **PHP**: Backend für Zwölftonreihen-Generierung und Matrix-Berechnung
- **JavaScript**: Web Audio API für Klangsynthese
- **ConvolverNode**: Realistische Reverb-Simulation
- **Canvas API**: Audio-Visualisierung
## Lizenz
MIT License
+939
View File
@@ -0,0 +1,939 @@
<?php
/**
* Zwölfton-Synthesizer (Dodekaphonie)
* Nach der Lehre von Arnold Schönberg
*
* Regeln der Zwölftontechnik:
* 1. Alle 12 chromatischen Töne müssen verwendet werden
* 2. Kein Ton darf wiederholt werden, bevor alle anderen gespielt wurden
* 3. Die Reihe kann transformiert werden: Original, Krebs, Umkehrung, Krebsumkehrung
* 4. Transposition auf alle 12 Stufen ist erlaubt
*/
class TwelveToneGenerator {
private array $noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
private array $originalRow;
public function __construct() {
$this->originalRow = $this->generateRandomRow();
}
/**
* Generiert eine zufällige Zwölftonreihe
*/
public function generateRandomRow(): array {
$row = range(0, 11);
shuffle($row);
return $row;
}
/**
* Gibt die Grundreihe zurück
*/
public function getOriginalRow(): array {
return $this->originalRow;
}
/**
* Krebs (Retrograde) - Reihe rückwärts
*/
public function getRetrograde(): array {
return array_reverse($this->originalRow);
}
/**
* Umkehrung (Inversion) - Intervalle gespiegelt
*/
public function getInversion(): array {
$inversion = [];
$firstNote = $this->originalRow[0];
foreach ($this->originalRow as $note) {
$interval = $note - $firstNote;
$invertedNote = ($firstNote - $interval + 12) % 12;
$inversion[] = $invertedNote;
}
return $inversion;
}
/**
* Krebsumkehrung (Retrograde Inversion)
*/
public function getRetrogradeInversion(): array {
return array_reverse($this->getInversion());
}
/**
* Transponiert eine Reihe um n Halbtöne
*/
public function transpose(array $row, int $semitones): array {
return array_map(function($note) use ($semitones) {
return ($note + $semitones) % 12;
}, $row);
}
/**
* Konvertiert Notennummern zu Notennamen
*/
public function toNoteNames(array $row): array {
return array_map(function($note) {
return $this->noteNames[$note];
}, $row);
}
/**
* Generiert die komplette Zwölftonmatrix (12x12)
*/
public function generateMatrix(): array {
$matrix = [];
$inversion = $this->getInversion();
for ($i = 0; $i < 12; $i++) {
$transposition = $inversion[$i];
$matrix[$i] = $this->transpose($this->originalRow, $transposition);
}
return $matrix;
}
/**
* Gibt alle Daten als JSON zurück
*/
public function toJSON(): string {
return json_encode([
'original' => $this->originalRow,
'retrograde' => $this->getRetrograde(),
'inversion' => $this->getInversion(),
'retrogradeInversion' => $this->getRetrogradeInversion(),
'noteNames' => $this->toNoteNames($this->originalRow),
'matrix' => $this->generateMatrix()
]);
}
}
// API-Endpoint für neue Reihe
if (isset($_GET['action']) && $_GET['action'] === 'generate') {
header('Content-Type: application/json');
$generator = new TwelveToneGenerator();
echo $generator->toJSON();
exit;
}
$generator = new TwelveToneGenerator();
$initialData = $generator->toJSON();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zwölfton-Synthesizer | Dodekaphonie nach Schönberg</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
color: #e0e0e0;
overflow-x: hidden;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
padding: 30px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 30px;
}
h1 {
font-size: 2.5em;
background: linear-gradient(90deg, #00d4ff, #7b2cbf, #ff6b6b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 10px;
}
.subtitle {
color: #888;
font-size: 1.1em;
}
.controls {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 30px;
flex-wrap: wrap;
}
button {
padding: 15px 30px;
font-size: 1.1em;
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.btn-primary {
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: #fff;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 212, 255, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, #7b2cbf, #5a189a);
color: #fff;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(123, 44, 191, 0.3);
}
.btn-danger {
background: linear-gradient(135deg, #ff6b6b, #ee5a5a);
color: #fff;
}
.btn-danger:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(255, 107, 107, 0.3);
}
.panel {
background: rgba(255,255,255,0.05);
border-radius: 20px;
padding: 25px;
margin-bottom: 25px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.1);
}
.panel h2 {
margin-bottom: 20px;
color: #00d4ff;
font-size: 1.3em;
}
.row-display {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.note-box {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 212, 255, 0.1);
border: 2px solid rgba(0, 212, 255, 0.3);
border-radius: 10px;
font-weight: bold;
font-size: 1.2em;
transition: all 0.3s ease;
}
.note-box.active {
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
border-color: #fff;
transform: scale(1.2);
box-shadow: 0 0 30px rgba(0, 212, 255, 0.5);
}
.note-box.played {
background: rgba(123, 44, 191, 0.3);
border-color: rgba(123, 44, 191, 0.5);
}
.sliders {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.slider-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.slider-group label {
display: flex;
justify-content: space-between;
color: #aaa;
}
input[type="range"] {
width: 100%;
height: 8px;
border-radius: 4px;
background: rgba(255,255,255,0.1);
outline: none;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
cursor: pointer;
}
.visualizer-container {
height: 200px;
background: rgba(0,0,0,0.3);
border-radius: 15px;
overflow: hidden;
position: relative;
}
#visualizer {
width: 100%;
height: 100%;
}
.transformation-select {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 20px;
}
.transform-btn {
padding: 10px 20px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 25px;
color: #e0e0e0;
cursor: pointer;
transition: all 0.3s ease;
}
.transform-btn.active {
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
border-color: transparent;
}
.transform-btn:hover {
background: rgba(0, 212, 255, 0.3);
}
.status {
text-align: center;
padding: 15px;
background: rgba(0,0,0,0.2);
border-radius: 10px;
margin-top: 20px;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 10px;
background: #666;
}
.status-indicator.playing {
background: #00ff88;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.matrix-container {
overflow-x: auto;
}
.matrix {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 3px;
min-width: 500px;
}
.matrix-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 212, 255, 0.1);
border-radius: 5px;
font-size: 0.9em;
font-weight: 500;
}
.info-text {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 10px;
margin-top: 15px;
font-size: 0.9em;
color: #aaa;
line-height: 1.6;
}
footer {
text-align: center;
padding: 30px;
color: #666;
border-top: 1px solid rgba(255,255,255,0.1);
margin-top: 30px;
}
@media (max-width: 768px) {
h1 { font-size: 1.8em; }
.note-box { width: 45px; height: 45px; font-size: 1em; }
.controls { flex-direction: column; align-items: center; }
button { width: 100%; max-width: 300px; }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Zwölfton-Synthesizer</h1>
<p class="subtitle">Dodekaphonie nach Arnold Schönberg</p>
</header>
<div class="controls">
<button id="startBtn" class="btn-primary"> Starten</button>
<button id="stopBtn" class="btn-danger"> Stoppen</button>
<button id="newRowBtn" class="btn-secondary">🎲 Neue Reihe</button>
</div>
<div class="panel">
<h2>Aktuelle Zwölftonreihe</h2>
<div class="transformation-select">
<button class="transform-btn active" data-transform="original">Original (O)</button>
<button class="transform-btn" data-transform="retrograde">Krebs (R)</button>
<button class="transform-btn" data-transform="inversion">Umkehrung (I)</button>
<button class="transform-btn" data-transform="retrogradeInversion">Krebsumkehrung (RI)</button>
</div>
<div id="rowDisplay" class="row-display"></div>
</div>
<div class="panel">
<h2>Klangparameter</h2>
<div class="sliders">
<div class="slider-group">
<label>Tempo (BPM): <span id="tempoValue">120</span></label>
<input type="range" id="tempo" min="40" max="300" value="120">
</div>
<div class="slider-group">
<label>Oktave: <span id="octaveValue">4</span></label>
<input type="range" id="octave" min="2" max="6" value="4">
</div>
<div class="slider-group">
<label>Reverb: <span id="reverbValue">50</span>%</label>
<input type="range" id="reverb" min="0" max="100" value="50">
</div>
<div class="slider-group">
<label>Attack: <span id="attackValue">0.05</span>s</label>
<input type="range" id="attack" min="1" max="500" value="50">
</div>
<div class="slider-group">
<label>Release: <span id="releaseValue">0.3</span>s</label>
<input type="range" id="release" min="50" max="2000" value="300">
</div>
<div class="slider-group">
<label>Wellenform:</label>
<select id="waveform" style="padding: 8px; border-radius: 5px; background: rgba(255,255,255,0.1); color: #e0e0e0; border: 1px solid rgba(255,255,255,0.2);">
<option value="sine">Sinus</option>
<option value="triangle">Dreieck</option>
<option value="square">Rechteck</option>
<option value="sawtooth" selected>Sägezahn</option>
</select>
</div>
</div>
</div>
<div class="panel">
<h2>Audio-Visualisierung</h2>
<div class="visualizer-container">
<canvas id="visualizer"></canvas>
</div>
<div class="status">
<span id="statusIndicator" class="status-indicator"></span>
<span id="statusText">Bereit zum Starten</span>
</div>
</div>
<div class="panel">
<h2>Zwölftonmatrix</h2>
<div class="matrix-container">
<div id="matrix" class="matrix"></div>
</div>
<div class="info-text">
<strong>Die Zwölftontechnik (Dodekaphonie):</strong><br>
Entwickelt von Arnold Schönberg um 1921. Alle 12 Halbtöne der chromatischen Tonleiter
werden gleichberechtigt verwendet. Die Grundreihe kann in vier Formen erscheinen:
<strong>Original (O)</strong> - die Grundreihe,
<strong>Krebs (R)</strong> - rückwärts gespielt,
<strong>Umkehrung (I)</strong> - Intervalle gespiegelt,
<strong>Krebsumkehrung (RI)</strong> - Kombination aus Krebs und Umkehrung.
Jede Form kann auf alle 12 Stufen transponiert werden (48 mögliche Reihen).
</div>
</div>
<footer>
<p>Zwölfton-Synthesizer &copy; 2024 | Basierend auf der Dodekaphonie von Arnold Schönberg</p>
</footer>
</div>
<script>
// Initiale Daten vom PHP-Backend
let rowData = <?= $initialData ?>;
// Audio-Kontext und Knoten
let audioContext = null;
let masterGain = null;
let reverbGain = null;
let dryGain = null;
let convolver = null;
let analyser = null;
let isPlaying = false;
let currentNoteIndex = 0;
let playInterval = null;
let currentTransform = 'original';
// Frequenzen für alle Noten (A4 = 440Hz)
const noteFrequencies = {
0: 261.63, // C
1: 277.18, // C#
2: 293.66, // D
3: 311.13, // D#
4: 329.63, // E
5: 349.23, // F
6: 369.99, // F#
7: 392.00, // G
8: 415.30, // G#
9: 440.00, // A
10: 466.16, // A#
11: 493.88 // B
};
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
/**
* Initialisiert den Audio-Kontext
*/
async function initAudio() {
if (audioContext) return;
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Master Gain
masterGain = audioContext.createGain();
masterGain.gain.value = 0.5;
masterGain.connect(audioContext.destination);
// Analyser für Visualisierung
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.connect(masterGain);
// Dry/Wet Gain für Reverb
dryGain = audioContext.createGain();
reverbGain = audioContext.createGain();
dryGain.connect(analyser);
reverbGain.connect(analyser);
// Convolver für Reverb
convolver = audioContext.createConvolver();
convolver.connect(reverbGain);
// Generiere Impulsantwort für Reverb
await createReverbImpulse();
updateReverbMix();
startVisualization();
}
/**
* Erstellt eine synthetische Impulsantwort für den Reverb
*/
async function createReverbImpulse() {
const sampleRate = audioContext.sampleRate;
const length = sampleRate * 3; // 3 Sekunden Reverb
const impulse = audioContext.createBuffer(2, length, sampleRate);
for (let channel = 0; channel < 2; channel++) {
const channelData = impulse.getChannelData(channel);
for (let i = 0; i < length; i++) {
// Exponentieller Decay mit etwas Rauschen
const decay = Math.pow(1 - i / length, 2);
channelData[i] = (Math.random() * 2 - 1) * decay;
}
}
convolver.buffer = impulse;
}
/**
* Aktualisiert das Dry/Wet-Verhältnis des Reverbs
*/
function updateReverbMix() {
const reverbAmount = document.getElementById('reverb').value / 100;
dryGain.gain.value = 1 - reverbAmount * 0.5;
reverbGain.gain.value = reverbAmount;
}
/**
* Spielt eine Note
*/
function playNote(noteNumber) {
if (!audioContext) return;
const octave = parseInt(document.getElementById('octave').value);
const frequency = noteFrequencies[noteNumber] * Math.pow(2, octave - 4);
const waveform = document.getElementById('waveform').value;
const attack = document.getElementById('attack').value / 1000;
const release = document.getElementById('release').value / 1000;
// Oszillator
const osc = audioContext.createOscillator();
osc.type = waveform;
osc.frequency.value = frequency;
// Gain für ADSR-Hüllkurve
const gainNode = audioContext.createGain();
gainNode.gain.value = 0;
// Verbindungen
osc.connect(gainNode);
gainNode.connect(dryGain);
gainNode.connect(convolver);
const now = audioContext.currentTime;
// Attack
gainNode.gain.linearRampToValueAtTime(0.7, now + attack);
// Release
gainNode.gain.linearRampToValueAtTime(0, now + attack + release);
osc.start(now);
osc.stop(now + attack + release + 0.1);
}
/**
* Startet die automatische Wiedergabe
*/
async function startPlaying() {
await initAudio();
if (isPlaying) return;
isPlaying = true;
currentNoteIndex = 0;
updateStatus(true);
playNextNote();
}
/**
* Stoppt die Wiedergabe
*/
function stopPlaying() {
isPlaying = false;
if (playInterval) {
clearTimeout(playInterval);
playInterval = null;
}
updateStatus(false);
resetNoteDisplay();
}
/**
* Spielt die nächste Note in der Reihe
*/
function playNextNote() {
if (!isPlaying) return;
const row = getCurrentRow();
const noteNumber = row[currentNoteIndex];
// Visuelle Hervorhebung
updateNoteDisplay(currentNoteIndex);
// Note spielen
playNote(noteNumber);
// Nächste Note vorbereiten
currentNoteIndex++;
if (currentNoteIndex >= 12) {
currentNoteIndex = 0;
// Zufällig Transformation wechseln (optional)
if (Math.random() > 0.7) {
const transforms = ['original', 'retrograde', 'inversion', 'retrogradeInversion'];
currentTransform = transforms[Math.floor(Math.random() * transforms.length)];
updateTransformButtons();
displayRow();
}
}
// Tempo berechnen
const bpm = parseInt(document.getElementById('tempo').value);
const interval = 60000 / bpm;
playInterval = setTimeout(playNextNote, interval);
}
/**
* Gibt die aktuelle Reihenform zurück
*/
function getCurrentRow() {
switch (currentTransform) {
case 'retrograde': return rowData.retrograde;
case 'inversion': return rowData.inversion;
case 'retrogradeInversion': return rowData.retrogradeInversion;
default: return rowData.original;
}
}
/**
* Aktualisiert die visuelle Darstellung der aktuellen Note
*/
function updateNoteDisplay(activeIndex) {
const boxes = document.querySelectorAll('.note-box');
boxes.forEach((box, index) => {
box.classList.remove('active');
if (index < activeIndex) {
box.classList.add('played');
} else {
box.classList.remove('played');
}
if (index === activeIndex) {
box.classList.add('active');
}
});
}
/**
* Setzt die Notenanzeige zurück
*/
function resetNoteDisplay() {
const boxes = document.querySelectorAll('.note-box');
boxes.forEach(box => {
box.classList.remove('active', 'played');
});
}
/**
* Aktualisiert den Status-Anzeiger
*/
function updateStatus(playing) {
const indicator = document.getElementById('statusIndicator');
const text = document.getElementById('statusText');
if (playing) {
indicator.classList.add('playing');
text.textContent = 'Spielt...';
} else {
indicator.classList.remove('playing');
text.textContent = 'Gestoppt';
}
}
/**
* Zeigt die Zwölftonreihe an
*/
function displayRow() {
const container = document.getElementById('rowDisplay');
const row = getCurrentRow();
container.innerHTML = row.map((note, index) => `
<div class="note-box" data-note="${note}" data-index="${index}">
${noteNames[note]}
</div>
`).join('');
}
/**
* Zeigt die Zwölftonmatrix an
*/
function displayMatrix() {
const container = document.getElementById('matrix');
const matrix = rowData.matrix;
container.innerHTML = matrix.flat().map(note => `
<div class="matrix-cell">${noteNames[note]}</div>
`).join('');
}
/**
* Aktualisiert die Transformations-Buttons
*/
function updateTransformButtons() {
document.querySelectorAll('.transform-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.transform === currentTransform);
});
}
/**
* Generiert eine neue Reihe vom Server
*/
async function generateNewRow() {
try {
const response = await fetch('?action=generate');
rowData = await response.json();
displayRow();
displayMatrix();
currentNoteIndex = 0;
resetNoteDisplay();
} catch (error) {
console.error('Fehler beim Generieren:', error);
}
}
/**
* Audio-Visualisierung
*/
function startVisualization() {
const canvas = document.getElementById('visualizer');
const ctx = canvas.getContext('2d');
function resize() {
canvas.width = canvas.offsetWidth * window.devicePixelRatio;
canvas.height = canvas.offsetHeight * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
resize();
window.addEventListener('resize', resize);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
requestAnimationFrame(draw);
analyser.getByteTimeDomainData(dataArray);
const width = canvas.offsetWidth;
const height = canvas.offsetHeight;
// Hintergrund mit Fade-Effekt
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, 0, width, height);
// Wellenform zeichnen
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(0, 212, 255, 0.8)';
ctx.beginPath();
const sliceWidth = width / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * height / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(width, height / 2);
ctx.stroke();
// Frequenzspektrum
analyser.getByteFrequencyData(dataArray);
const barWidth = (width / 64) * 1.5;
let barX = 0;
for (let i = 0; i < 64; i++) {
const barHeight = (dataArray[i] / 255) * height * 0.7;
const gradient = ctx.createLinearGradient(0, height - barHeight, 0, height);
gradient.addColorStop(0, 'rgba(123, 44, 191, 0.8)');
gradient.addColorStop(1, 'rgba(0, 212, 255, 0.8)');
ctx.fillStyle = gradient;
ctx.fillRect(barX, height - barHeight, barWidth - 2, barHeight);
barX += barWidth;
}
}
draw();
}
// Event Listeners
document.getElementById('startBtn').addEventListener('click', startPlaying);
document.getElementById('stopBtn').addEventListener('click', stopPlaying);
document.getElementById('newRowBtn').addEventListener('click', generateNewRow);
document.querySelectorAll('.transform-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentTransform = btn.dataset.transform;
updateTransformButtons();
displayRow();
currentNoteIndex = 0;
resetNoteDisplay();
});
});
// Slider Updates
document.getElementById('tempo').addEventListener('input', e => {
document.getElementById('tempoValue').textContent = e.target.value;
});
document.getElementById('octave').addEventListener('input', e => {
document.getElementById('octaveValue').textContent = e.target.value;
});
document.getElementById('reverb').addEventListener('input', e => {
document.getElementById('reverbValue').textContent = e.target.value;
if (audioContext) updateReverbMix();
});
document.getElementById('attack').addEventListener('input', e => {
document.getElementById('attackValue').textContent = (e.target.value / 1000).toFixed(2);
});
document.getElementById('release').addEventListener('input', e => {
document.getElementById('releaseValue').textContent = (e.target.value / 1000).toFixed(2);
});
// Initialisierung
displayRow();
displayMatrix();
</script>
</body>
</html>