diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1e4c336 Binary files /dev/null and b/.DS_Store differ diff --git a/Docs/Contribution.md b/Docs/Contribution.md new file mode 100644 index 0000000..99fa44d --- /dev/null +++ b/Docs/Contribution.md @@ -0,0 +1,21 @@ +# SatHunter guide for contributors + +This project started as a personal effort by NE6NE. + +All kinds of contributions are welcome. Please report issues on GitHub, or even better, create +Pull Requests to fix them! + +The author is not an App developer by trade, not do they have any design talent. If you identified any +room for improvement in UI / UX please create an issue or PR. You may define the aesthetic taste of +this App. + +## Building SatHunter on your machine + +### Prerequisites + +Before working on app you need to install Google Protocol buffers. Open terminal and type: + +``` +brew install protobuf +brew install swift-protobuf +``` diff --git a/README.md b/README.md index 2c70e7f..d513251 100644 --- a/README.md +++ b/README.md @@ -58,14 +58,7 @@ The app currently only supports IC-705. ## Contribution -This project started as a personal effort by NE6NE. - -All kinds of contributions are welcome. Please report issues on GitHub, or even better, create -Pull Requests to fix them! - -The author is not an App developer by trade, not do they have any design talent. If you identified any -room for improvement in UI / UX please create an issue or PR. You may define the aesthetic taste of -this App. +See the [contribution guide](Docs/Contribution.md) ## License diff --git a/SatHunter.xcodeproj/project.pbxproj b/SatHunter.xcodeproj/project.pbxproj index 5bdd59c..5cd10c1 100644 --- a/SatHunter.xcodeproj/project.pbxproj +++ b/SatHunter.xcodeproj/project.pbxproj @@ -7,6 +7,20 @@ objects = { /* Begin PBXBuildFile section */ + 631CC5D82C67B720006D2B86 /* Mode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631CC5D72C67B720006D2B86 /* Mode.swift */; }; + 631CC5DA2C67B911006D2B86 /* ToneFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631CC5D92C67B911006D2B86 /* ToneFrequency.swift */; }; + 631CC5DC2C67BA3A006D2B86 /* Conversions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631CC5DB2C67BA3A006D2B86 /* Conversions.swift */; }; + 631CC5E12C67BF24006D2B86 /* RigError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631CC5E02C67BF24006D2B86 /* RigError.swift */; }; + 631CC5E32C67BF59006D2B86 /* ConnectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631CC5E22C67BF59006D2B86 /* ConnectionState.swift */; }; + 631CC5E72C67C1BA006D2B86 /* IC705.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631CC5E62C67C1BA006D2B86 /* IC705.swift */; }; + 631CC5EA2C67C280006D2B86 /* IC705BluetoothDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631CC5E92C67C280006D2B86 /* IC705BluetoothDelegate.swift */; }; + 631CC5EC2C67C334006D2B86 /* RigState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631CC5EB2C67C334006D2B86 /* RigState.swift */; }; + 631CC5EE2C67CB97006D2B86 /* ByteUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631CC5ED2C67CB97006D2B86 /* ByteUtilities.swift */; }; + 632A21B52C68CE0800ED4280 /* SatelliteOrbitElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632A21B42C68CE0800ED4280 /* SatelliteOrbitElements.swift */; }; + 632A21B72C68CE4700ED4280 /* SatelliteObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632A21B62C68CE4700ED4280 /* SatelliteObserver.swift */; }; + 632A21B92C68CE8700ED4280 /* SatellitePass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632A21B82C68CE8700ED4280 /* SatellitePass.swift */; }; + 632A21BB2C68D5A400ED4280 /* SatelliteListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632A21BA2C68D5A400ED4280 /* SatelliteListItem.swift */; }; + 632A21BD2C68EA5C00ED4280 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632A21BC2C68EA5C00ED4280 /* AppTheme.swift */; }; D10F8E132A34380D008D0D2F /* SatFreqView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10F8E122A34380D008D0D2F /* SatFreqView.swift */; }; D12952402A28004D00334302 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D129523F2A28004D00334302 /* SwiftProtobuf */; }; D12952422A28006800334302 /* sat_info.proto in Sources */ = {isa = PBXBuildFile; fileRef = D12952412A28006800334302 /* sat_info.proto */; }; @@ -33,7 +47,7 @@ D1A738912A24235B002832A1 /* TrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A738902A24235B002832A1 /* TrackingView.swift */; }; D1A738932A24235D002832A1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D1A738922A24235D002832A1 /* Assets.xcassets */; }; D1A738962A24235D002832A1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D1A738952A24235D002832A1 /* Preview Assets.xcassets */; }; - D1A738B62A242398002832A1 /* LibPredict.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A738852A230F81002832A1 /* LibPredict.swift */; }; + D1A738B62A242398002832A1 /* SatellitePredictions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A738852A230F81002832A1 /* SatellitePredictions.swift */; }; D1A738B72A242434002832A1 /* libpredict.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1403E342A2303ED000E873B /* libpredict.framework */; platformFilter = ios; }; D1A738B82A242434002832A1 /* libpredict.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D1403E342A2303ED000E873B /* libpredict.framework */; platformFilter = ios; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D1A738BE2A250C06002832A1 /* SatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A738BD2A250C06002832A1 /* SatListView.swift */; }; @@ -85,6 +99,20 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 631CC5D72C67B720006D2B86 /* Mode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mode.swift; sourceTree = ""; }; + 631CC5D92C67B911006D2B86 /* ToneFrequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToneFrequency.swift; sourceTree = ""; }; + 631CC5DB2C67BA3A006D2B86 /* Conversions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conversions.swift; sourceTree = ""; }; + 631CC5E02C67BF24006D2B86 /* RigError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RigError.swift; sourceTree = ""; }; + 631CC5E22C67BF59006D2B86 /* ConnectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionState.swift; sourceTree = ""; }; + 631CC5E62C67C1BA006D2B86 /* IC705.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IC705.swift; sourceTree = ""; }; + 631CC5E92C67C280006D2B86 /* IC705BluetoothDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IC705BluetoothDelegate.swift; sourceTree = ""; }; + 631CC5EB2C67C334006D2B86 /* RigState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RigState.swift; sourceTree = ""; }; + 631CC5ED2C67CB97006D2B86 /* ByteUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteUtilities.swift; sourceTree = ""; }; + 632A21B42C68CE0800ED4280 /* SatelliteOrbitElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SatelliteOrbitElements.swift; sourceTree = ""; }; + 632A21B62C68CE4700ED4280 /* SatelliteObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SatelliteObserver.swift; sourceTree = ""; }; + 632A21B82C68CE8700ED4280 /* SatellitePass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SatellitePass.swift; sourceTree = ""; }; + 632A21BA2C68D5A400ED4280 /* SatelliteListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SatelliteListItem.swift; sourceTree = ""; }; + 632A21BC2C68EA5C00ED4280 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; D10F8E122A34380D008D0D2F /* SatFreqView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SatFreqView.swift; sourceTree = ""; }; D12952412A28006800334302 /* sat_info.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = sat_info.proto; sourceTree = ""; }; D12952482A2806E600334302 /* SatInfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SatInfoManager.swift; sourceTree = ""; }; @@ -108,7 +136,7 @@ D1A738722A230C69002832A1 /* sun.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = sun.h; sourceTree = ""; }; D1A738732A230C69002832A1 /* sdp4.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = sdp4.h; sourceTree = ""; }; D1A738742A230C69002832A1 /* sgp4.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = sgp4.h; sourceTree = ""; }; - D1A738852A230F81002832A1 /* LibPredict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibPredict.swift; sourceTree = ""; }; + D1A738852A230F81002832A1 /* SatellitePredictions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SatellitePredictions.swift; sourceTree = ""; }; D1A7388C2A24235B002832A1 /* SatHunter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SatHunter.app; sourceTree = BUILT_PRODUCTS_DIR; }; D1A7388E2A24235B002832A1 /* SatHunterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SatHunterApp.swift; sourceTree = ""; }; D1A738902A24235B002832A1 /* TrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingView.swift; sourceTree = ""; }; @@ -142,6 +170,56 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 631CC5D62C67B708006D2B86 /* Models */ = { + isa = PBXGroup; + children = ( + D1A738C92A25A485002832A1 /* Rig.swift */, + 631CC5EB2C67C334006D2B86 /* RigState.swift */, + 631CC5E02C67BF24006D2B86 /* RigError.swift */, + 631CC5E22C67BF59006D2B86 /* ConnectionState.swift */, + 631CC5D72C67B720006D2B86 /* Mode.swift */, + 631CC5D92C67B911006D2B86 /* ToneFrequency.swift */, + 632A21BA2C68D5A400ED4280 /* SatelliteListItem.swift */, + ); + path = Models; + sourceTree = ""; + }; + 631CC5DD2C67BC28006D2B86 /* Utilities */ = { + isa = PBXGroup; + children = ( + 631CC5DB2C67BA3A006D2B86 /* Conversions.swift */, + 631CC5ED2C67CB97006D2B86 /* ByteUtilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + 631CC5DE2C67BC67006D2B86 /* Views */ = { + isa = PBXGroup; + children = ( + ); + path = Views; + sourceTree = ""; + }; + 631CC5E82C67C262006D2B86 /* Icom-705 */ = { + isa = PBXGroup; + children = ( + 631CC5E62C67C1BA006D2B86 /* IC705.swift */, + 631CC5E92C67C280006D2B86 /* IC705BluetoothDelegate.swift */, + ); + path = "Icom-705"; + sourceTree = ""; + }; + 631CC5EF2C67D07B006D2B86 /* Satellite */ = { + isa = PBXGroup; + children = ( + D1A738852A230F81002832A1 /* SatellitePredictions.swift */, + 632A21B42C68CE0800ED4280 /* SatelliteOrbitElements.swift */, + 632A21B62C68CE4700ED4280 /* SatelliteObserver.swift */, + 632A21B82C68CE8700ED4280 /* SatellitePass.swift */, + ); + path = Satellite; + sourceTree = ""; + }; D12952472A2803FD00334302 /* Protos */ = { isa = PBXGroup; children = ( @@ -210,11 +288,14 @@ D1A7388D2A24235B002832A1 /* SatHunter */ = { isa = PBXGroup; children = ( + 631CC5EF2C67D07B006D2B86 /* Satellite */, + 631CC5E82C67C262006D2B86 /* Icom-705 */, + 631CC5DE2C67BC67006D2B86 /* Views */, + 631CC5DD2C67BC28006D2B86 /* Utilities */, + 631CC5D62C67B708006D2B86 /* Models */, D12952472A2803FD00334302 /* Protos */, - D1A738C92A25A485002832A1 /* Rig.swift */, D1A738BC2A2475AD002832A1 /* Info.plist */, D10F8E122A34380D008D0D2F /* SatFreqView.swift */, - D1A738852A230F81002832A1 /* LibPredict.swift */, D1A7388E2A24235B002832A1 /* SatHunterApp.swift */, D1A738902A24235B002832A1 /* TrackingView.swift */, D13429B72A302A2B00184085 /* SkyView.swift */, @@ -227,6 +308,7 @@ D1F547902A29B15700AED698 /* SettingsView.swift */, D19BE3452A302C6800DC47D1 /* SatViewModel.swift */, D19BE3472A302CD400DC47D1 /* SatInfoView.swift */, + 632A21BC2C68EA5C00ED4280 /* AppTheme.swift */, ); path = SatHunter; sourceTree = ""; @@ -381,16 +463,30 @@ files = ( D1A738912A24235B002832A1 /* TrackingView.swift in Sources */, D12952422A28006800334302 /* sat_info.proto in Sources */, + 632A21B92C68CE8700ED4280 /* SatellitePass.swift in Sources */, + 632A21BB2C68D5A400ED4280 /* SatelliteListItem.swift in Sources */, + 632A21B72C68CE4700ED4280 /* SatelliteObserver.swift in Sources */, D19BE3482A302CD400DC47D1 /* SatInfoView.swift in Sources */, - D1A738B62A242398002832A1 /* LibPredict.swift in Sources */, + D1A738B62A242398002832A1 /* SatellitePredictions.swift in Sources */, D1A738C62A25441D002832A1 /* RigControlView.swift in Sources */, D1A7388F2A24235B002832A1 /* SatHunterApp.swift in Sources */, + 632A21B52C68CE0800ED4280 /* SatelliteOrbitElements.swift in Sources */, + 631CC5EC2C67C334006D2B86 /* RigState.swift in Sources */, + 631CC5DC2C67BA3A006D2B86 /* Conversions.swift in Sources */, D1F547912A29B15700AED698 /* SettingsView.swift in Sources */, D12952492A2806E600334302 /* SatInfoManager.swift in Sources */, + 631CC5E12C67BF24006D2B86 /* RigError.swift in Sources */, + 631CC5E32C67BF59006D2B86 /* ConnectionState.swift in Sources */, + 631CC5EA2C67C280006D2B86 /* IC705BluetoothDelegate.swift in Sources */, D1A738C82A2550B6002832A1 /* SatView.swift in Sources */, D10F8E132A34380D008D0D2F /* SatFreqView.swift in Sources */, + 631CC5E72C67C1BA006D2B86 /* IC705.swift in Sources */, D1A738CA2A25A485002832A1 /* Rig.swift in Sources */, + 632A21BD2C68EA5C00ED4280 /* AppTheme.swift in Sources */, + 631CC5D82C67B720006D2B86 /* Mode.swift in Sources */, D19BE3462A302C6800DC47D1 /* SatViewModel.swift in Sources */, + 631CC5EE2C67CB97006D2B86 /* ByteUtilities.swift in Sources */, + 631CC5DA2C67B911006D2B86 /* ToneFrequency.swift in Sources */, D13429B82A302A2B00184085 /* SkyView.swift in Sources */, D1A738BE2A250C06002832A1 /* SatListView.swift in Sources */, ); diff --git a/SatHunter.xcodeproj/project.xcworkspace/xcuserdata/azdravkovic.xcuserdatad/UserInterfaceState.xcuserstate b/SatHunter.xcodeproj/project.xcworkspace/xcuserdata/azdravkovic.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..2ec23f5 Binary files /dev/null and b/SatHunter.xcodeproj/project.xcworkspace/xcuserdata/azdravkovic.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SatHunter/AppTheme.swift b/SatHunter/AppTheme.swift new file mode 100644 index 0000000..d9baaad --- /dev/null +++ b/SatHunter/AppTheme.swift @@ -0,0 +1,85 @@ +// +// AppTheme.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/11/24. +// + +import Foundation + +import SwiftUI + +enum AppTheme: String, CaseIterable, Identifiable { + case system = "System" + case light = "Light" + case dark = "Dark" + case lowContrast = "Low Contrast" + + var id: String { self.rawValue } +} + +class ThemeManager: ObservableObject { + @Published var selectedTheme: AppTheme = .system { + didSet { + saveTheme() + } + } + + init() { + loadTheme() + } + + func applyTheme() -> ColorScheme? { + switch selectedTheme { + case .system: + return getSystemColorScheme() + case .light: + return .light + case .dark: + return .dark + case .lowContrast: + return .light // Apply a low contrast custom theme + } + } + + private func getSystemColorScheme() -> ColorScheme { +#if os(iOS) + // For iOS, using UIKit + let userInterfaceStyle = UITraitCollection.current.userInterfaceStyle + return userInterfaceStyle == .dark ? .dark : .light +#elseif os(macOS) + // For macOS, using AppKit + let appearanceName = NSApp.effectiveAppearance.name + return appearanceName == .darkAqua ? .dark : .light +#else + // Default to light for unsupported platforms + return .light +#endif + } + + + private func saveTheme() { + UserDefaults.standard.set(selectedTheme.rawValue, forKey: "selectedTheme") + } + + private func loadTheme() { + if let savedTheme = UserDefaults.standard.string(forKey: "selectedTheme"), + let theme = AppTheme(rawValue: savedTheme) { + selectedTheme = theme + } + } +} + +struct LowContrastModifier: ViewModifier { + func body(content: Content) -> some View { + content + .foregroundColor(.red) + .background(Color(white: 0.95)) + } +} + +extension View { + func applyLowContrast() -> some View { + self.modifier(LowContrastModifier()) + } +} diff --git a/SatHunter/Icom-705/IC705.swift b/SatHunter/Icom-705/IC705.swift new file mode 100644 index 0000000..101d9c7 --- /dev/null +++ b/SatHunter/Icom-705/IC705.swift @@ -0,0 +1,158 @@ +// +// IC705.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/10/24. +// + +import Foundation +import CoreBluetooth +import OSLog + +public var IC705_CIV_PREAMBLE: [UInt8] = [0xFE, 0xFE, 0xA4, 0xE0] +private let logger = Logger() + +public class Icom705Rig: Rig, RigStateObserver, ObservableObject { + + @Published var connectionState: ConnectionState = .NotConnected + private var bluetoothDelegate: IC705BluetoothDelegate? + private var bluetoothManager: CBCentralManager? + private var dispatchQueue = DispatchQueue(label: "rig_bt") + private var rigState = RigState() + private var rigStateSemaphore = DispatchSemaphore(value: 1) + + public init() {} + + func observe(vfoAFreq: Int) { + rigStateSemaphore.wait() + rigState.vfoAFreq = vfoAFreq + rigStateSemaphore.signal() + } + + func observe(vfoBFreq: Int) { + rigStateSemaphore.wait() + rigState.vfoBFreq = vfoBFreq + rigStateSemaphore.signal() + } + + func observe(connected _: Bool) { + DispatchQueue.main.async { + self.connectionState = .Connected + } + } + + public func connect() { + connectionState = .Connecting + bluetoothDelegate = IC705BluetoothDelegate() + bluetoothDelegate!.rigStateObserver = self + bluetoothManager = CBCentralManager(delegate: bluetoothDelegate, queue: dispatchQueue) + } + + public func disconnect() { + if let p = bluetoothDelegate?.ic705 { + bluetoothManager?.cancelPeripheralConnection(p) + } + bluetoothDelegate = nil + bluetoothManager = nil + connectionState = .NotConnected + } + + public func getVfoAFreq() -> Int { + rigStateSemaphore.wait() + let frequency = rigState.vfoAFreq + rigStateSemaphore.signal() + return frequency + } + + public func getVfoBFreq() -> Int { + rigStateSemaphore.wait() + let frequency = rigState.vfoBFreq + rigStateSemaphore.signal() + return frequency + } + + public func setVfoAFreq(_ frequency: Int) { + guard frequency > 0 else { return } + + do { + try bluetoothDelegate?.sendPacket(.init( + chainBytes(IC705_CIV_PREAMBLE, [0x25, 0x00], convertNumberToBCD(frequency), [0xFD]) + )) + } catch { + logger.error("Failed to set VFO-A frequency! Could not convert frequency BCD number!") + } + } + + public func setVfoBFreq(_ frequency: Int) { + guard frequency > 0 else { return } + + do{ + try bluetoothDelegate?.sendPacket(.init( + chainBytes(IC705_CIV_PREAMBLE, [0x25, 0x01], convertNumberToBCD(frequency), [0xFD]) + )) + } catch { + logger.error("Failed to set VFO-B frequency! Could not convert frequency to BCD number!") + } + } + + public func enableSplit() { + bluetoothDelegate?.sendPacket(.init( + chainBytes(IC705_CIV_PREAMBLE, [0x0F, 0x01, 0xFD]) + )) + } + + public func setVfoAMode(_ mode: Mode) { + bluetoothDelegate?.sendPacket(.init( + chainBytes(IC705_CIV_PREAMBLE, [0x26, 0x00, convertModeToCivByte(mode: mode), 00, 0xFD]) + )) + } + + public func setVfoBMode(_ mode: Mode) { + bluetoothDelegate?.sendPacket(.init( + chainBytes(IC705_CIV_PREAMBLE, [0x26, 0x01, convertModeToCivByte(mode: mode), 00, 0xFD]) + )) + } + + public func enableVfoARepeaterTone(_ enable: Bool) { + let enableByte: UInt8 = enable ? 0x01 : 0x00 + bluetoothDelegate?.sendPacket(.init( + chainBytes(IC705_CIV_PREAMBLE, [0x16, 0x42, enableByte, 0xFD]) + )) + } + + public func selectVfo(_ vfoA: Bool) { + let selectionByte: UInt8 = vfoA ? 0x00 : 0x01 + bluetoothDelegate?.sendPacket(.init( + chainBytes(IC705_CIV_PREAMBLE, [0x07, selectionByte, 0xFD]) + )) + } + + public func setVfoAToneFreq(_ toneFrequency: ToneFrequency) { + var b1: UInt8 = 0 + var b2: UInt8 = 0 + var v = toneFrequency.rawValue + b1 |= UInt8((v / 1000) << 4) + v %= 1000 + b1 |= UInt8(v / 100) + v %= 100 + b2 |= UInt8((v / 10) << 4) + v %= 10 + b2 |= UInt8(v) + bluetoothDelegate?.sendPacket(.init( + chainBytes(IC705_CIV_PREAMBLE, [0x1B, 00, b1, b2, 0xFD]) + )) + } + + private func convertModeToCivByte(mode: Mode) -> UInt8 { + switch mode { + case .FM: + return 0x05 + case .LSB: + return 0x00 + case .USB: + return 0x01 + case .CW: + return 0x03 + } + } +} diff --git a/SatHunter/Icom-705/IC705BluetoothDelegate.swift b/SatHunter/Icom-705/IC705BluetoothDelegate.swift new file mode 100644 index 0000000..97082f7 --- /dev/null +++ b/SatHunter/Icom-705/IC705BluetoothDelegate.swift @@ -0,0 +1,252 @@ +// +// IC705BluetoothDelegate.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/10/24. +// + +import Foundation +import CoreBluetooth +import OSLog + +private let IC705_BLE_CONTROL_SERVICE = + CBUUID(string: "14CF8001-1EC2-D408-1B04-2EB270F14203") +private let IC705_BLE_SERVICE_CHAR = + CBUUID(string: "14CF8002-1EC2-D408-1B04-2EB270F14203") + +private let logger = Logger() + +class IC705BluetoothDelegate: NSObject, CBCentralManagerDelegate, + CBPeripheralDelegate +{ + override init() { + ic705 = nil + state = .INIT + ctlChar = nil + super.init() + } + + // CBCentralManagerDelegate methods + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + if central.state == .poweredOn { + central.scanForPeripherals(withServices: [IC705_BLE_CONTROL_SERVICE]) + } else { + logger.error("BT state changed to \(central.state.rawValue)") + ic705 = nil + } + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi _: NSNumber + ) { + if let name = peripheral.name { + if name == "ICOM BT(IC-705)" { + logger + .debug( + "Discovered ic705: \(peripheral.description)\nDATA:\n\(advertisementData)" + ) + ic705 = peripheral + peripheral.delegate = self + central.connect(peripheral) + central.stopScan() + } + } + } + + func centralManager(_: CBCentralManager, + didConnect peripheral: CBPeripheral) + { + logger.info("Connected!") + peripheral.discoverServices([IC705_BLE_CONTROL_SERVICE]) + } + + // CBPeripheralDelegate methods + func peripheral( + _ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error? + ) { + if let error = error { + logger.error("Error discovering characteristic: \(error)") + return + } + for char in service.characteristics! { + ctlChar = char + peripheral.setNotifyValue(true, for: char) + } + } + + func peripheral( + _ peripheral: CBPeripheral, + didDiscoverServices error: Error? + ) { + if let error = error { + logger.error("Error discovring service: \(error)") + return + } + for service in peripheral.services! { + logger.debug("Discovered service: \(service)") + if service.uuid == IC705_BLE_CONTROL_SERVICE { + peripheral.discoverCharacteristics([IC705_BLE_SERVICE_CHAR], for: service) + } + } + } + + func peripheral( + _ peripheral: CBPeripheral, + didUpdateNotificationStateFor characteristic: CBCharacteristic, + error: Error? + ) { + if let error = error { + logger.error("Error update notification state: \(error)") + return + } + var idPacket: [UInt8] = [0xFE, 0xF1, 0x00, 0x61] + withUnsafeBytes(of: getBtId().uuid) { + b in + for i in 0 ..< b.count { + idPacket.append(b.load(fromByteOffset: i, as: UInt8.self)) + } + } + idPacket.append(0xFD) + peripheral.writeValue( + .init(idPacket), + for: characteristic, + type: .withResponse + ) + state = .ID_SENT + } + + func peripheral( + _ peripheral: CBPeripheral, + didWriteValueFor char: CBCharacteristic, + error: Error? + ) { + if let error = error { + logger.error("Error write value: \(error)") + return + } + switch state { + case .ID_SENT: + var namePacket: [UInt8] = [0xFE, 0xF1, 0x00, 0x62] + "SatHunter ".utf8CString.withUnsafeBytes { + b in + for i in 0 ..< 16 { + namePacket.append(b.load(fromByteOffset: i, as: UInt8.self)) + } + } + namePacket.append(0xFD) + peripheral.writeValue( + .init(namePacket), + for: char, + type: .withResponse + ) + logger.info("state: NAME_SENT") + state = .NAME_SENT + case .NAME_SENT: + let tokenPacket: [UInt8] = [0xFE, 0xF1, 0x00, 0x63, 0xEE, 0x39, 0x09, + 0x10, + 0xFD] + peripheral.writeValue( + .init(tokenPacket), + for: char, + type: .withResponse + ) + logger.info("state: TOKEN_SENT") + state = .TOKEN_SENT + case .TOKEN_SENT: + state = .STARTED + schedulePolling() + rigStateObserver?.observe(connected: true) + case .STARTED: + break + default: + logger.error("Invalid state") + } + } + + func peripheral( + _: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error: Error? + ) { + if characteristic.uuid == IC705_BLE_SERVICE_CHAR { + if error != nil { + logger.error("didUpdateValueFor called with error: \(error)") + return + } + let resp = characteristic.value! + if resp.count < 5 || !resp[0 ... 3] + .elementsEqual([0xFE, 0xFE, 0xE0, 0xA4]) || resp.last != 0xFD + { + // This packet is not addressed to us, or it's malformed. Ignore. + return + } + switch resp[4] { + // This is the response to our query of VFO. + case 0x25: + do { + if resp[5] == 0 { + try rigStateObserver?.observe(vfoAFreq: convertBCDToNumber(resp[6...10])) + } else if resp[5] == 1 { + try rigStateObserver?.observe(vfoBFreq: convertBCDToNumber(resp[6...10])) + } + } catch { + // Handle the error here, for example by logging it or taking corrective action + print("Error observing VFO frequency: \(error)") + } + default: + return + } + } + } + + // Public interfaces + // non-blocking and there is no response. No guarantee that the packet + // will be received. + // For state-setting packets, packet loss is not the end of the day. + // There may be mismatch with the UI state, but user can retry. + // For state-getting packets, since they are periodically sent from here, + // losing a packet is not a big deal. + func sendPacket(_ data: Data) { + ic705!.writeValue(data, for: ctlChar!, type: .withResponse) + } + + private func schedulePolling() { + DispatchQueue.global(qos: .userInteractive) + .asyncAfter(wallDeadline: .now() + .milliseconds(250)) { + [weak self] in + if let s = self { + // Get VFOA freq + s.sendPacket(.init(chainBytes(IC705_CIV_PREAMBLE, [0x25, 0x00, 0xFD]))) + // Get VFOB freq + s.sendPacket(.init(chainBytes(IC705_CIV_PREAMBLE, [0x25, 0x01, 0xFD]))) + s.schedulePolling() + } else { + logger.info("Rig state polling task exiting...") + return + } + } + } + + enum State { + case INIT + case ID_SENT + case NAME_SENT + case TOKEN_SENT + case STARTED + } + + var rigStateObserver: RigStateObserver? + var ic705: CBPeripheral? + + private var state: State + private var waitForStartSema: DispatchSemaphore? + private var ctlChar: CBCharacteristic? +} + + diff --git a/SatHunter/LibPredict.swift b/SatHunter/LibPredict.swift deleted file mode 100644 index 5abc1ea..0000000 --- a/SatHunter/LibPredict.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// LibPredict.swift -// LibPredictTestProgram -// -// Created by Zhuo Peng on 5/27/23. -// - -import Foundation - -// Rant: why does swift not have namespaces? - -public class SatOrbitElements { - init(_ tle: (String, String)) { - self.tle = tle - ptrInternal = predict_parse_tle(tle.0, tle.1) - } - deinit { - predict_destroy_orbital_elements(ptrInternal) - } - - private var ptrInternal: UnsafeMutablePointer - var ptr: UnsafeMutablePointer { - get { - ptrInternal - } - } - var tle: (String, String) -} - -public class SatObserver { - // lat / lon are DEGREES, not RAD; alt is in meters - init(name: String, lat: Double, lon: Double, alt: Double) { - ptrInternal = predict_create_observer(name, lat.rad, lon.rad, alt) - } - deinit { - predict_destroy_observer(ptrInternal) - } - - var ptr: UnsafeMutablePointer { - get { - ptrInternal - } - } - - private var ptrInternal: UnsafeMutablePointer -} - -public struct SatPass { - var aos: predict_observation - var los: predict_observation - var maxElevation: predict_observation - - var description: String { - "AOS (local): \(self.aos.date.description(with: .current))\nLOS (local): \(self.los.date.description(with: .current))\nMax elevation: \(self.maxElevation.elevation.deg) deg" - } -} - - - -public extension predict_observation { - // note that .time is julian - var date: Date { - get { - Date(timeIntervalSince1970: Double(predict_from_julian(self.time))) - } - } -} - -public func getNextSatPass(observer: SatObserver, orbit: SatOrbitElements, time: Date = Date.now) -> SatPass { - let aos = predict_next_aos(observer.ptr, orbit.ptr, predict_to_julian(time_t(time.timeIntervalSince1970))) - let los = predict_next_los(observer.ptr, orbit.ptr, aos.time) - let maxElevation = predict_at_max_elevation(observer.ptr, orbit.ptr, aos.time) - return SatPass(aos: aos, los: los, maxElevation: maxElevation) -} - -fileprivate let kPi = 3.1415926535897932384626433832795028841415926 -public extension Double { - var rad: Double { - self * kPi / 180 - } - var deg: Double { - self * 180 / kPi - } -} - -public enum FreqForDopplerCalculation { - case UpLink(Int) // Hz - case DownLink(Int) // Hz -} - -// Returns the shift (the delta to be added to freq), not shifted freq. -public func getSatDopplerShift(observation: predict_observation, freq: FreqForDopplerCalculation) -> Int { - var freqF: Double = 0 - switch freq { - case .DownLink(let freqI): - fallthrough - case .UpLink(let freqI): - freqF = Double(freqI) - } - - let shift = withUnsafePointer(to: observation) { - ptr in - predict_doppler_shift(ptr, freqF) - } - switch freq { - case .DownLink(_): - return Int(shift) - case .UpLink(_): - return Int(-shift) - } -} - -// Where is the sat now? -public func getSatObservation(observer: SatObserver, orbit: SatOrbitElements, time: Date = Date.now) -> Result { - var pos = predict_position() - let errCode = withUnsafeMutablePointer(to: &pos) { - ptr in - predict_orbit(orbit.ptr, ptr, predict_to_julian(time_t(time.timeIntervalSince1970))) - } - if errCode != 0 { - return .failure(NSError(domain: "getSatObservation", code: Int(errCode))) - } - var observation = predict_observation() - withUnsafeMutablePointer(to: &observation) { - obsPtr in - withUnsafePointer(to: pos) { - posPtr in - predict_observe_orbit(observer.ptr, posPtr, obsPtr) - } - } - return .success(observation) -} - -// Returns the immediate next LOS. If the sat is currently visible, then it's -// the LOS of the current pass, otherwise, it's the next pass. -public func getSatNextLos(observer: SatObserver, orbit: SatOrbitElements, time: Date = Date.now) -> predict_observation { - return predict_next_los(observer.ptr, orbit.ptr, predict_to_julian(time_t(time.timeIntervalSince1970))) -} diff --git a/SatHunter/Models/ConnectionState.swift b/SatHunter/Models/ConnectionState.swift new file mode 100644 index 0000000..29348f5 --- /dev/null +++ b/SatHunter/Models/ConnectionState.swift @@ -0,0 +1,25 @@ +// +// ConnectionState.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/10/24. +// + +import Foundation + +public enum ConnectionState { + case NotConnected + case Connecting + case Connected + + var description: String { + switch self { + case .Connected: + return "Connected" + case .NotConnected: + return "Connect" + case .Connecting: + return "Connecting" + } + } +} diff --git a/SatHunter/Models/Mode.swift b/SatHunter/Models/Mode.swift new file mode 100644 index 0000000..81d9812 --- /dev/null +++ b/SatHunter/Models/Mode.swift @@ -0,0 +1,25 @@ +// +// Mode.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/10/24. +// + +import Foundation + +public enum Mode { + case LSB, USB, FM, CW + + func Invert() -> Mode { + switch self { + case .FM: + return .FM + case .USB: + return .LSB + case .LSB: + return .USB + case .CW: + return .CW + } + } +} diff --git a/SatHunter/Models/Rig.swift b/SatHunter/Models/Rig.swift new file mode 100644 index 0000000..46ea436 --- /dev/null +++ b/SatHunter/Models/Rig.swift @@ -0,0 +1,26 @@ +// +// Rig.swift +// +// +// Created by Zhuo Peng on 5/26/23. +// + +import Foundation + +protocol Rig { + func connect() + func disconnect() + func getVfoAFreq() -> Int + func getVfoBFreq() -> Int + func setVfoAFreq(_ f: Int) + func setVfoBFreq(_ f: Int) + func enableSplit() + func setVfoAMode(_ m: Mode) + func setVfoBMode(_ m: Mode) +} + +protocol RigStateObserver { + func observe(connected: Bool) + func observe(vfoAFreq: Int) + func observe(vfoBFreq: Int) +} diff --git a/SatHunter/Models/RigError.swift b/SatHunter/Models/RigError.swift new file mode 100644 index 0000000..5b21efb --- /dev/null +++ b/SatHunter/Models/RigError.swift @@ -0,0 +1,13 @@ +// +// RigError.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/10/24. +// + +import Foundation + +public enum RigError: Error { + case MalformedResponseError + case TryAgainError +} diff --git a/SatHunter/Models/RigState.swift b/SatHunter/Models/RigState.swift new file mode 100644 index 0000000..968d8bf --- /dev/null +++ b/SatHunter/Models/RigState.swift @@ -0,0 +1,13 @@ +// +// RigState.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/10/24. +// + +import Foundation + +public struct RigState { + var vfoAFreq: Int = 0 + var vfoBFreq: Int = 0 +} diff --git a/SatHunter/Models/SatelliteListItem.swift b/SatHunter/Models/SatelliteListItem.swift new file mode 100644 index 0000000..0529422 --- /dev/null +++ b/SatHunter/Models/SatelliteListItem.swift @@ -0,0 +1,26 @@ +// +// SatelliteListItem.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/11/24. +// + +import Foundation + +struct SatelliteListItem: Identifiable, Sendable { + var satellite: Satellite + var visible: Bool + // visible == true + var los: Date? + + // visible = false + var nextAos: Date? + var nextLos: Date? + var maxEl: Double? + + var id: Int { + get { + Int(satellite.noradID) + } + } +} diff --git a/SatHunter/Models/ToneFrequency.swift b/SatHunter/Models/ToneFrequency.swift new file mode 100644 index 0000000..8b8aba8 --- /dev/null +++ b/SatHunter/Models/ToneFrequency.swift @@ -0,0 +1,32 @@ +// +// FreqTone.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/10/24. +// + +import Foundation + +public enum ToneFrequency: Int, CaseIterable, Identifiable, CustomStringConvertible { + case NotSet = 0 + case F67 = 670 + case F88_5 = 885 + case F141_3 = 1413 + + public var id: Int { + rawValue + } + + static func toString(for toneFrequency: ToneFrequency) -> String { + switch toneFrequency { + case .NotSet: + return "No CTCSS" + default: + return String(format: "%5.01f", Double(toneFrequency.rawValue) / 10) + } + } + + public var description: String { + return ToneFrequency.toString(for: self) + } +} diff --git a/SatHunter/Rig.swift b/SatHunter/Rig.swift deleted file mode 100644 index ed15411..0000000 --- a/SatHunter/Rig.swift +++ /dev/null @@ -1,502 +0,0 @@ -// -// rig.swift -// 705bt -// -// Created by Zhuo Peng on 5/26/23. -// - -import CoreBluetooth -import Foundation -import OSLog - -private let logger = Logger() -private let k705BtleControlService = - CBUUID(string: "14CF8001-1EC2-D408-1B04-2EB270F14203") -private let k705BtleServiceChar = - CBUUID(string: "14CF8002-1EC2-D408-1B04-2EB270F14203") - -public enum Mode { - case LSB - case USB - case FM - case CW - - func toCivByte() -> UInt8 { - switch self { - case .FM: - return 0x05 - case .LSB: - return 0x00 - case .USB: - return 0x01 - case .CW: - return 0x03 - } - } - - func inverted() -> Mode { - switch self { - case .FM: - return .FM - case .USB: - return .LSB - case .LSB: - return .USB - case .CW: - return .CW - } - } -} - -public enum ToneFreq: Int, CaseIterable, Identifiable { - public var id: Int { - rawValue - } - - case NotSet = 0 - case F67 = 670 - case F88_5 = 885 - case F141_3 = 1413 - - var description: String { - if self == .NotSet { - return "No CTCSS" - } - return .init(format: "%5.01f", Double(rawValue) / 10) - } -} - -public protocol Rig { - func connect() - func disconnect() - func getVfoAFreq() -> Int - func getVfoBFreq() -> Int - func setVfoAFreq(_ f: Int) - func setVfoBFreq(_ f: Int) - func enableSplit() - func setVfoAMode(_ m: Mode) - func setVfoBMode(_ m: Mode) -} - -private protocol RigStateObserver { - func observe(connected: Bool) - func observe(vfoAFreq: Int) - func observe(vfoBFreq: Int) -} - -private class Ic705BtDelegate: NSObject, CBCentralManagerDelegate, - CBPeripheralDelegate -{ - override init() { - ic705 = nil - state = .INIT - ctlChar = nil - super.init() - } - - // CBCentralManagerDelegate methods - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - if central.state == .poweredOn { - central.scanForPeripherals(withServices: [k705BtleControlService]) - } else { - logger.error("BT state changed to \(central.state.rawValue)") - ic705 = nil - } - } - - func centralManager( - _ central: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi _: NSNumber - ) { - if let name = peripheral.name { - if name == "ICOM BT(IC-705)" { - logger - .debug( - "Discovered ic705: \(peripheral.description)\nDATA:\n\(advertisementData)" - ) - ic705 = peripheral - peripheral.delegate = self - central.connect(peripheral) - central.stopScan() - } - } - } - - func centralManager(_: CBCentralManager, - didConnect peripheral: CBPeripheral) - { - logger.info("Connected!") - peripheral.discoverServices([k705BtleControlService]) - } - - // CBPeripheralDelegate methods - func peripheral( - _ peripheral: CBPeripheral, - didDiscoverCharacteristicsFor service: CBService, - error: Error? - ) { - if let error = error { - logger.error("Error discovering characteristic: \(error)") - return - } - for char in service.characteristics! { - ctlChar = char - peripheral.setNotifyValue(true, for: char) - } - } - - func peripheral( - _ peripheral: CBPeripheral, - didDiscoverServices error: Error? - ) { - if let error = error { - logger.error("Error discovring service: \(error)") - return - } - for service in peripheral.services! { - logger.debug("Discovered service: \(service)") - if service.uuid == k705BtleControlService { - peripheral.discoverCharacteristics([k705BtleServiceChar], for: service) - } - } - } - - func peripheral( - _ peripheral: CBPeripheral, - didUpdateNotificationStateFor characteristic: CBCharacteristic, - error: Error? - ) { - if let error = error { - logger.error("Error update notification state: \(error)") - return - } - var idPacket: [UInt8] = [0xFE, 0xF1, 0x00, 0x61] - withUnsafeBytes(of: getBtId().uuid) { - b in - for i in 0 ..< b.count { - idPacket.append(b.load(fromByteOffset: i, as: UInt8.self)) - } - } - idPacket.append(0xFD) - peripheral.writeValue( - .init(idPacket), - for: characteristic, - type: .withResponse - ) - state = .ID_SENT - } - - func peripheral( - _ peripheral: CBPeripheral, - didWriteValueFor char: CBCharacteristic, - error: Error? - ) { - if let error = error { - logger.error("Error write value: \(error)") - return - } - switch state { - case .ID_SENT: - var namePacket: [UInt8] = [0xFE, 0xF1, 0x00, 0x62] - "SatHunter ".utf8CString.withUnsafeBytes { - b in - for i in 0 ..< 16 { - namePacket.append(b.load(fromByteOffset: i, as: UInt8.self)) - } - } - namePacket.append(0xFD) - peripheral.writeValue( - .init(namePacket), - for: char, - type: .withResponse - ) - logger.info("state: NAME_SENT") - state = .NAME_SENT - case .NAME_SENT: - let tokenPacket: [UInt8] = [0xFE, 0xF1, 0x00, 0x63, 0xEE, 0x39, 0x09, - 0x10, - 0xFD] - peripheral.writeValue( - .init(tokenPacket), - for: char, - type: .withResponse - ) - logger.info("state: TOKEN_SENT") - state = .TOKEN_SENT - case .TOKEN_SENT: - state = .STARTED - schedulePolling() - rigStateObserver?.observe(connected: true) - case .STARTED: - break - default: - logger.error("Invalid state") - } - } - - func peripheral( - _: CBPeripheral, - didUpdateValueFor characteristic: CBCharacteristic, - error: Error? - ) { - if characteristic.uuid == k705BtleServiceChar { - if error != nil { - logger.error("didUpdateValueFor called with error: \(error)") - return - } - let resp = characteristic.value! - if resp.count < 5 || !resp[0 ... 3] - .elementsEqual([0xFE, 0xFE, 0xE0, 0xA4]) || resp.last != 0xFD - { - // This packet is not addressed to us, or it's malformed. Ignore. - return - } - switch resp[4] { - // This is the response to our query of VFO. - case 0x25: - if resp[5] == 0 { - rigStateObserver?.observe(vfoAFreq: fromBCD(resp[6 ... 10])) - } else if resp[5] == 1 { - rigStateObserver?.observe(vfoBFreq: fromBCD(resp[6 ... 10])) - } - default: - return - } - } - } - - // Public interfaces - // non-blocking and there is no response. No guarantee that the packet - // will be received. - // For state-setting packets, packet loss is not the end of the day. - // There may be mismatch with the UI state, but user can retry. - // For state-getting packets, since they are periodically sent from here, - // losing a packet is not a big deal. - func sendPacket(_ data: Data) { - ic705!.writeValue(data, for: ctlChar!, type: .withResponse) - } - - private func schedulePolling() { - DispatchQueue.global(qos: .userInteractive) - .asyncAfter(wallDeadline: .now() + .milliseconds(250)) { - [weak self] in - if let s = self { - // Get VFOA freq - s.sendPacket(.init(chainBytes(kCivPreamble, [0x25, 0x00, 0xFD]))) - // Get VFOB freq - s.sendPacket(.init(chainBytes(kCivPreamble, [0x25, 0x01, 0xFD]))) - s.schedulePolling() - } else { - logger.info("Rig state polling task exiting...") - return - } - } - } - - enum State { - case INIT - case ID_SENT - case NAME_SENT - case TOKEN_SENT - case STARTED - } - - var rigStateObserver: RigStateObserver? - var ic705: CBPeripheral? - - private var state: State - private var waitForStartSema: DispatchSemaphore? - private var ctlChar: CBCharacteristic? -} - -private let kCivPreamble: [UInt8] = [0xFE, 0xFE, 0xA4, 0xE0] - -private func chainBytes(_ bs: [UInt8]...) -> [UInt8] { - var result: [UInt8] = [] - for b in bs { - result.append(contentsOf: b) - } - return result -} - -public enum RigError: Error { - case MalformedResponseError - case TryAgainError -} - -public class MyIc705: Rig, RigStateObserver, ObservableObject { - enum ConnectionState { - case NotConnected - case Connecting - case Connected - var description: String { - switch self { - case .Connected: - return "Connected" - case .NotConnected: - return "Connect" - case .Connecting: - return "Connecting" - } - } - } - - @Published var connectionState: ConnectionState = .NotConnected - - public init() {} - - func observe(vfoAFreq: Int) { - rigStateMu.wait() - rigState.vfoAFreq = vfoAFreq - rigStateMu.signal() - } - - func observe(vfoBFreq: Int) { - rigStateMu.wait() - rigState.vfoBFreq = vfoBFreq - rigStateMu.signal() - } - - func observe(connected _: Bool) { - DispatchQueue.main.async { - self.connectionState = .Connected - } - } - - public func connect() { - connectionState = .Connecting - btDelegate = Ic705BtDelegate() - btDelegate!.rigStateObserver = self - btMgr = CBCentralManager(delegate: btDelegate, queue: btQueue) - } - - public func disconnect() { - if let p = btDelegate?.ic705 { - btMgr?.cancelPeripheralConnection(p) - } - btDelegate = nil - btMgr = nil - connectionState = .NotConnected - } - - public func getVfoAFreq() -> Int { - rigStateMu.wait() - let f = rigState.vfoAFreq - rigStateMu.signal() - return f - } - - public func getVfoBFreq() -> Int { - rigStateMu.wait() - let f = rigState.vfoBFreq - rigStateMu.signal() - return f - } - - public func setVfoAFreq(_ f: Int) { - guard f > 0 else { return } - btDelegate?.sendPacket(.init( - chainBytes(kCivPreamble, [0x25, 0x00], toBCD(f), [0xFD]) - )) - } - - public func setVfoBFreq(_ f: Int) { - guard f > 0 else { return } - btDelegate?.sendPacket(.init( - chainBytes(kCivPreamble, [0x25, 0x01], toBCD(f), [0xFD]) - )) - } - - public func enableSplit() { - btDelegate?.sendPacket(.init( - chainBytes(kCivPreamble, [0x0F, 0x01, 0xFD]) - )) - } - - public func setVfoAMode(_ m: Mode) { - btDelegate?.sendPacket(.init( - chainBytes(kCivPreamble, [0x26, 0x00, m.toCivByte(), 00, 0xFD]) - )) - } - - public func setVfoBMode(_ m: Mode) { - btDelegate?.sendPacket(.init( - chainBytes(kCivPreamble, [0x26, 0x01, m.toCivByte(), 00, 0xFD]) - )) - } - - public func enableVfoARepeaterTone(_ b: Bool) { - let enableByte: UInt8 = b ? 0x01 : 0x00 - btDelegate?.sendPacket(.init( - chainBytes(kCivPreamble, [0x16, 0x42, enableByte, 0xFD]) - )) - } - - public func selectVfo(_ vfoA: Bool) { - let selectionByte: UInt8 = vfoA ? 0x00 : 0x01 - btDelegate?.sendPacket(.init( - chainBytes(kCivPreamble, [0x07, selectionByte, 0xFD]) - )) - } - - public func setVfoAToneFreq(_ toneFreq: ToneFreq) { - var b1: UInt8 = 0 - var b2: UInt8 = 0 - var v = toneFreq.rawValue - b1 |= UInt8((v / 1000) << 4) - v %= 1000 - b1 |= UInt8(v / 100) - v %= 100 - b2 |= UInt8((v / 10) << 4) - v %= 10 - b2 |= UInt8(v) - btDelegate?.sendPacket(.init( - chainBytes(kCivPreamble, [0x1B, 00, b1, b2, 0xFD]) - )) - } - - private var btDelegate: Ic705BtDelegate? - private var btMgr: CBCentralManager? - private var btQueue = DispatchQueue(label: "rig_bt") - - private struct RigState { - var vfoAFreq: Int = 0 - var vfoBFreq: Int = 0 - } - - private var rigState = RigState() - private var rigStateMu = DispatchSemaphore(value: 1) -} - -private func toBCD(_ v: Int) -> [UInt8] { - if v >= 1_000_000_000 { - logger.error("Unable to convert \(v) to BCD. Overflow.") - } - var mv = v - var result: [UInt8] = [0, 0, 0, 0, 0] - var scale = 1_000_000_000 - for i in (0 ... 4).reversed() { - result[i] |= UInt8(mv / scale) << 4 - mv %= scale - scale /= 10 - result[i] |= UInt8(mv / scale) - mv %= scale - scale /= 10 - } - return result -} - -private func fromBCD(_ s: S) -> Int where S.Element == UInt8 { - var result = 0 - var scale = 1 - for b in s { - result += (Int(b) & 0x0F) * scale - scale *= 10 - result += (Int(b) >> 4) * scale - scale *= 10 - } - return result -} diff --git a/SatHunter/RigControlView.swift b/SatHunter/RigControlView.swift index 3345825..9eadf84 100644 --- a/SatHunter/RigControlView.swift +++ b/SatHunter/RigControlView.swift @@ -9,22 +9,22 @@ class DopplerShiftModel: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var transponderDownlinkShift: Int? @Published var transponderUplinkShift: Int? var transponder: Transponder? - private var orbit: SatOrbitElements? + private var orbit: SatelliteOrbitElements? var trackedSatTle: (String, String)? { didSet { if let tle = trackedSatTle { - orbit = SatOrbitElements(tle) + orbit = SatelliteOrbitElements(tle) } else { orbit = nil } } } - private var observer = SatObserver( + private var observer = SatelliteObserver( name: "user", - lat: 37.33481435508938, - lon: -122.00893980785605, - alt: 25 + latitudeDegrees: 37.33481435508938, + longitudeDegrees: -122.00893980785605, + altitude: 25 ) private var locationManager: CLLocationManager? private var dispatchQueue: DispatchQueue = .init(label: "doppler_shift_model") @@ -87,20 +87,20 @@ class DopplerShiftModel: NSObject, ObservableObject, CLLocationManagerDelegate { let userAlt = location.altitude let userLon = location.coordinate.longitude let userLat = location.coordinate.latitude - observer = SatObserver( + observer = SatelliteObserver( name: "user", - lat: userLat, - lon: userLon, - alt: userAlt + latitudeDegrees: userLat, + longitudeDegrees: userLon, + altitude: userAlt ) } } - func setTrueFreq(_ f: FreqForDopplerCalculation) { + func setTrueFreq(_ f: FrequencyForDopplerCalculation) { var fValue: Int var setF: (DopplerShiftModel, Int) -> Void switch f { - case let .DownLink(f): + case let .DownLinkHz(f): fValue = f setF = { (m: DopplerShiftModel, value: Int) in @@ -108,7 +108,7 @@ class DopplerShiftModel: NSObject, ObservableObject, CLLocationManagerDelegate { m.downlinkFreq = value } } - case let .UpLink(f): + case let .UpLinkHz(f): fValue = f setF = { (m: DopplerShiftModel, value: Int) in @@ -122,7 +122,7 @@ class DopplerShiftModel: NSObject, ObservableObject, CLLocationManagerDelegate { setF(self, fValue) return } - guard case let .success(observation) = getSatObservation(observer: observer, + guard case let .success(observation) = getSatelliteObservation(observer: observer, orbit: orbit) else { setF(self, fValue) @@ -145,27 +145,27 @@ class DopplerShiftModel: NSObject, ObservableObject, CLLocationManagerDelegate { var transponderUpShift: Int? var transponderDownShift: Int? if let orbit = orbit { - if case let .success(observation) = getSatObservation(observer: observer, + if case let .success(observation) = getSatelliteObservation(observer: observer, orbit: orbit) { if observation.elevation > 0 { down = downlinkFreq + getSatDopplerShift( observation: observation, - freq: .DownLink(downlinkFreq) + freq: .DownLinkHz(downlinkFreq) ) up = uplinkFreq + getSatDopplerShift( observation: observation, - freq: .UpLink(uplinkFreq) + freq: .UpLinkHz(uplinkFreq) ) if let t = transponder { transponderDownShift = getSatDopplerShift( observation: observation, - freq: .DownLink(t.downlinkCenterFreq) + freq: .DownLinkHz(t.downlinkCenterFreq) ) if let uplinkCenterFreq = t.uplinkCenterFreq { transponderUpShift = getSatDopplerShift( observation: observation, - freq: .UpLink(uplinkCenterFreq) + freq: .UpLinkHz(uplinkCenterFreq) ) } } @@ -214,13 +214,13 @@ class RadioModel: ObservableObject { } } - var ctcss: ToneFreq = .NotSet { + var ctcss: ToneFrequency = .NotSet { didSet { configCtcss() } } - var rig: MyIc705? + var rig: Icom705Rig? var dopplerShiftModel: DopplerShiftModel? var transponder: Transponder? private var timer: Timer? @@ -273,7 +273,7 @@ class RadioModel: ObservableObject { if rig.connectionState != .Connected { return } - if ctcss == .NotSet { + if ctcss == .NotSet { rig.selectVfo(false) rig.enableVfoARepeaterTone(false) rig.selectVfo(true) @@ -320,7 +320,7 @@ class RadioModel: ObservableObject { guard let m = dopplerShiftModel else { return } // When not tracking, let the doppler model follow VFO A. - m.setTrueFreq(.DownLink(vfoAFreq)) + m.setTrueFreq(.DownLinkHz(vfoAFreq)) // If transponder is available, then let VFO B follow VFO A. // How it follows depends on the type of transponder: guard let transponder = transponder else { @@ -335,7 +335,7 @@ class RadioModel: ObservableObject { let setVfoBFreq: (Int) -> Void = { f in self.rig?.setVfoBFreq(f) - m.setTrueFreq(.UpLink(f)) + m.setTrueFreq(.UpLinkHz(f)) } // downlink freq is not a range. if transponder.downlinkFreqUpper == 0 { @@ -451,12 +451,12 @@ struct RigControlView: View { var trackedSat: Satellite @State private var selectedVfoAMode: Mode = .LSB @State private var selectedVfoBMode: Mode = .LSB - @EnvironmentObject private var rig: MyIc705 + @EnvironmentObject private var rig: Icom705Rig @StateObject var dopplerShiftModel = DopplerShiftModel() @StateObject var radioModel = RadioModel() @State private var radioIsTracking: Bool = false @State private var transponderIdx: Int = -1 - @State private var selectedCtcss: ToneFreq = .NotSet + @State private var selectedCtcss: ToneFrequency = .NotSet var body: some View { VStack { @@ -469,11 +469,11 @@ struct RigControlView: View { Spacer() } } - SatFreqView( - downlinkFreqAtSat: $dopplerShiftModel.downlinkFreq, - uplinkFreqAtSat: $dopplerShiftModel.uplinkFreq, - downlinkFreqAtGround: $dopplerShiftModel.actualDownlinkFreq, - uplinkFreqAtGround: $dopplerShiftModel.actualUplinkFreq + SatelliteFrequencyView( + satelliteDownlinkFrequency: $dopplerShiftModel.downlinkFreq, + satelliteUplinkFrequency: $dopplerShiftModel.uplinkFreq, + groundDownlinkFrequency: $dopplerShiftModel.actualDownlinkFreq, + groundUplinkFrequency: $dopplerShiftModel.actualUplinkFreq ) TransponderView( transponderIdx: $transponderIdx, @@ -558,7 +558,7 @@ struct RigControlView: View { } Spacer() Picker(selection: $selectedCtcss, label: Text("CTCSS")) { - ForEach(ToneFreq.allCases) { + ForEach(ToneFrequency.allCases) { f in Text(f.description).tag(f) } @@ -604,7 +604,7 @@ struct RigControlView: View { selectedVfoBMode = transponder.uplinkMode.libPredictMode } else { dopplerShiftModel.uplinkFreq = 0 - dopplerShiftModel.setTrueFreq(.DownLink(radioModel.vfoAFreq)) + dopplerShiftModel.setTrueFreq(.DownLinkHz(radioModel.vfoAFreq)) } dopplerShiftModel.blockedRefresh() radioModel.setFreqFromDopplerModel() diff --git a/SatHunter/SatFreqView.swift b/SatHunter/SatFreqView.swift index 7ff183c..cd8d4a6 100644 --- a/SatHunter/SatFreqView.swift +++ b/SatHunter/SatFreqView.swift @@ -7,55 +7,56 @@ import SwiftUI -struct SatFreqView: View { - @Binding var downlinkFreqAtSat: Int - @Binding var uplinkFreqAtSat: Int - @Binding var downlinkFreqAtGround: Int? - @Binding var uplinkFreqAtGround: Int? +struct SatelliteFrequencyView: View { + @Binding var satelliteDownlinkFrequency: Int + @Binding var satelliteUplinkFrequency: Int + @Binding var groundDownlinkFrequency: Int? + @Binding var groundUplinkFrequency: Int? + var body: some View { VStack{ HStack { Image(systemName: "arrow.down") - Text(downlinkFreqAtSat.asFormattedFreq) + Text(satelliteDownlinkFrequency.asFormattedFreq) Spacer() Divider() Image(systemName: "dot.radiowaves.forward") - Text(getDownlinkFreqAtGround()) + Text(getGroundDownlinkFrequency()) .frame(maxHeight: .infinity) Spacer() } HStack { Image(systemName: "arrow.up") - Text(uplinkFreqAtSat.asFormattedFreq) + Text(satelliteUplinkFrequency.asFormattedFreq) Spacer() Divider() Image(systemName: "dot.radiowaves.forward") - Text(getUplinkFreqAtGround()) + Text(getGroundUplinkFrequency()) .frame(maxHeight: .infinity) Spacer() } }.font(.body.monospaced()) } - private func getDownlinkFreqAtGround() -> String { - if let f = downlinkFreqAtGround { + private func getGroundDownlinkFrequency() -> String { + if let f = groundDownlinkFrequency { return f.asFormattedFreq } return "N/A" } - private func getUplinkFreqAtGround() -> String { - if let f = uplinkFreqAtGround { + private func getGroundUplinkFrequency() -> String { + if let f = groundUplinkFrequency { return f.asFormattedFreq } return "N/A" } } -struct SatFreqView_Previews: PreviewProvider { +struct SatelliteFrequencyView_Previews: PreviewProvider { static var previews: some View { - SatFreqView( - downlinkFreqAtSat: .constant(144000000), uplinkFreqAtSat: .constant(440000000), downlinkFreqAtGround: .constant(144005000), uplinkFreqAtGround: .constant(440010000) + SatelliteFrequencyView( + satelliteDownlinkFrequency: .constant(144000000), satelliteUplinkFrequency: .constant(440000000), groundDownlinkFrequency: .constant(144005000), groundUplinkFrequency: .constant(440010000) ) } } diff --git a/SatHunter/SatHunterApp.swift b/SatHunter/SatHunterApp.swift index ed95d72..004bbc2 100644 --- a/SatHunter/SatHunterApp.swift +++ b/SatHunter/SatHunterApp.swift @@ -2,10 +2,20 @@ import SwiftUI @main struct SatHunterApp: App { - @StateObject private var rig = MyIc705() - var body: some Scene { - WindowGroup { - SatListView().environmentObject(rig) + @StateObject private var rig = Icom705Rig() + @StateObject private var themeManager = ThemeManager() + @State private var viewId = UUID() // Add a UUID to force view reload + + var body: some Scene { + WindowGroup { + SatellitesListView() + .id(viewId) // Attach the UUID to the view + .environmentObject(rig) + .environment(\.colorScheme, themeManager.applyTheme()!) + .environmentObject(themeManager) + .onChange(of: themeManager.selectedTheme) { _ in + viewId = UUID() + } + } } - } } diff --git a/SatHunter/SatInfoView.swift b/SatHunter/SatInfoView.swift index c3e83de..3f93b47 100644 --- a/SatHunter/SatInfoView.swift +++ b/SatHunter/SatInfoView.swift @@ -13,6 +13,8 @@ struct SatInfoView: View { @Binding var los: Date? @Binding var nextAos: Date? @Binding var nextLos: Date? + @Binding var elevation: Double? + @Binding var azimuth: Double? @Binding var maxEl: Double? @Binding var userGrid: String @@ -22,13 +24,28 @@ struct SatInfoView: View { if let isVisible = isVisible { if isVisible { Text("Passing") - HStack { - Text("LOS:") - Spacer() - Text( - "\(los!.formatted(date: .omitted, time: .shortened)) (\(Duration.seconds(los!.timeIntervalSinceNow).formatted(.time(pattern: .minuteSecond))))" - ) - } + VStack { + // LOS + HStack { + Text("LOS:") + Spacer() + Text( + "\(los!.formatted(date: .omitted, time: .shortened)) (\(Duration.seconds(los!.timeIntervalSinceNow).formatted(.time(pattern: .minuteSecond))))" + ) + } + // Azimuth + HStack { + Text("Az: ") + Spacer() + Text(String(format: "%.1f°", azimuth!)) + } + // Elevation + HStack { + Text("El: ") + Spacer() + Text(String(format: "%.1f°", elevation!)) + } + } } else { Text("Next pass") HStack { @@ -50,18 +67,14 @@ struct SatInfoView: View { "Max el:" ) Spacer() - Text("\(String(format: "%.0f", maxEl!)) deg") + Text("\(String(format: "%.0f", maxEl!)) °") } } HStack { - Text("Your grid:") + Text("My Grid:") Spacer() Text(userGrid) } - HStack { - Text("Times are local").font(.footnote) - Spacer() - } } else { Text("Calculating...") } @@ -72,11 +85,13 @@ struct SatInfoView: View { struct SatInfoView_Previews: PreviewProvider { static var previews: some View { SatInfoView( - satName: "XW-2A", + satName: "SO-50", isVisible: .constant(true), los: .constant(Date.now), nextAos: .constant(Date.now), nextLos: .constant(Date.now), + elevation: .constant(0), + azimuth: .constant(45), maxEl: .constant(13.5), userGrid: .constant("CM87") ) diff --git a/SatHunter/SatListView.swift b/SatHunter/SatListView.swift index 3dd95db..461b546 100644 --- a/SatHunter/SatListView.swift +++ b/SatHunter/SatListView.swift @@ -2,24 +2,6 @@ import Foundation import CoreLocation import SwiftUI -struct SatListItem: Identifiable, Sendable { - var satellite: Satellite - var visible: Bool - // visible == true - var los: Date? - - // visible = false - var nextAos: Date? - var nextLos: Date? - var maxEl: Double? - - var id: Int { - get { - Int(satellite.noradID) - } - } -} - extension Satellite { var tleTuple: (String, String) { (self.tle.line1, self.tle.line2) @@ -36,6 +18,7 @@ extension Satellite { var hasUplink: Bool { transponders.contains(where: {t in t.hasUplinkFreqLower }) } + var hasActiveUVTransponder: Bool { transponders.contains(where: { t in @@ -56,10 +39,12 @@ extension Satellite { } } -class SatListStore: NSObject, ObservableObject, CLLocationManagerDelegate { - @Published var sats = [SatListItem]() - @Published var lastLoadedAt: Date? = nil - private var observer = SatObserver(name: "user", lat: 37.33481435508938, lon:-122.00893980785605, alt: 25) +class SatellitesListStore: NSObject, ObservableObject, CLLocationManagerDelegate { + + @Published var satellites = [SatelliteListItem]() + @Published var lastUpdateTime: Date? = nil + + private var observer = SatelliteObserver(name: "user", latitudeDegrees: 37.33481435508938, longitudeDegrees:-122.00893980785605, altitude: 25) private var locationManager: CLLocationManager? = nil private var satInfoManager: SatInfoManager? = nil @@ -74,40 +59,48 @@ class SatListStore: NSObject, ObservableObject, CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { if let location = locations.last { - let userAlt = location.altitude - let userLon = location.coordinate.longitude - let userLat = location.coordinate.latitude - observer = SatObserver(name: "user", lat: userLat, lon: userLon, alt: userAlt) + let userAltitude = location.altitude + let userLongitude = location.coordinate.longitude + let userLatitude = location.coordinate.latitude + observer = SatelliteObserver(name: "user", latitudeDegrees: userLatitude, longitudeDegrees: userLongitude, altitude: userAltitude) } } func load(searchText: String? = nil) async { + if satInfoManager == nil { satInfoManager = .init() } - var result: [SatListItem] = [] + + var result: [SatelliteListItem] = [] let showOnlySatsWithUplink = getShowOnlySatsWithUplink() let showOnlyUVActiveSats = getShowUVActiveSatsOnly() - for sat in satInfoManager!.satellites.values { - let tle = sat.tleTuple - let orbit = SatOrbitElements(tle) - if showOnlySatsWithUplink && !sat.hasUplink { + + for satellite in satInfoManager!.satellites.values { + let tle = satellite.tleTuple + let orbit = SatelliteOrbitElements(tle) + + if showOnlySatsWithUplink && !satellite.hasUplink { continue } - if showOnlyUVActiveSats && !sat.hasActiveUVTransponder { + + if showOnlyUVActiveSats && !satellite.hasActiveUVTransponder { continue } - if case .success(let observation) = getSatObservation(observer: observer, orbit: orbit) { + + if case .success(let observation) = getSatelliteObservation(observer: observer, orbit: orbit) { let visible = observation.elevation > 0 - var item = SatListItem(satellite: sat, visible: visible) + var item = SatelliteListItem(satellite: satellite, visible: visible) if observation.elevation > 0 { - item.los = getSatNextLos(observer: observer, orbit: orbit).date + item.los = getSatNextLos(observer: observer, orbit: orbit).julianDate } else { - let nextPass = getNextSatPass(observer: observer, orbit: orbit) - item.nextAos = nextPass.aos.date - item.nextLos = nextPass.los.date + let nextPass = getNextSatellitePass(observer: observer, orbit: orbit) + item.nextAos = nextPass.aos.julianDate + item.nextLos = nextPass.los.julianDate item.maxEl = nextPass.maxElevation.elevation.deg } + + // TODO: Add in settings view "minimal elevation", so that user can configure this value! if item.maxEl != nil && item.maxEl! < 0 { } else { result.append(item) @@ -126,23 +119,23 @@ class SatListStore: NSObject, ObservableObject, CLLocationManagerDelegate { let toSend = result DispatchQueue.main.async { [toSend] in - self.sats = toSend - self.lastLoadedAt = Date.now + self.satellites = toSend + self.lastUpdateTime = Date.now } } } } -struct SatListView : View { - @StateObject var store = SatListStore() +struct SatellitesListView : View { + @StateObject var store = SatellitesListStore() @State private var searchText: String = "" - @EnvironmentObject private var rig: MyIc705 + @EnvironmentObject private var rig: Icom705Rig - var items: [SatListItem] { + var items: [SatelliteListItem] { if searchText.isEmpty { - return store.sats + return store.satellites } - return store.sats.filter { + return store.satellites.filter { return $0.satellite.name.range(of:searchText, options: .caseInsensitive) != nil } } @@ -210,7 +203,7 @@ struct SatListView : View { } .toolbar { ToolbarItem(placement: .primaryAction) { - NavigationLink (destination: SettingsView()) { + NavigationLink (destination: SettingsView(themeManager: ThemeManager())) { Image(systemName: "gearshape") } } diff --git a/SatHunter/SatView.swift b/SatHunter/SatView.swift index 25ef528..87a7aa1 100644 --- a/SatHunter/SatView.swift +++ b/SatHunter/SatView.swift @@ -14,11 +14,13 @@ struct SatView: View { los: $model.currentLos, nextAos: $model.nextAos, nextLos: $model.nextLos, + elevation: $model.currentEl, + azimuth: $model.currentAz, maxEl: $model.maxEl, userGrid: $model.userGridSquare ) }.onAppear { - model.trackedSat = SatOrbitElements(trackedSat.tleTuple) + model.trackedSat = SatelliteOrbitElements(trackedSat.tleTuple) } } } diff --git a/SatHunter/SatViewModel.swift b/SatHunter/SatViewModel.swift index e3edb36..1d5a4a9 100644 --- a/SatHunter/SatViewModel.swift +++ b/SatHunter/SatViewModel.swift @@ -13,7 +13,7 @@ import OSLog fileprivate let logger = Logger() class SatViewModel: NSObject, ObservableObject, CLLocationManagerDelegate { - var trackedSat: SatOrbitElements? = nil { + var trackedSat: SatelliteOrbitElements? = nil { didSet { self.refresh() } @@ -35,9 +35,9 @@ class SatViewModel: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var currentEl: Double? = nil @Published var userHeading: Double = 0 - @Published var userLat: Double = 0 - @Published var userLon: Double = 0 - @Published var userAlt: Double = 0 + @Published var userLatitude: Double = 0 + @Published var userLongitude: Double = 0 + @Published var userAltitude: Double = 0 @Published var userGridSquare: String = "" @Published var passTrack: [(Double, Double)] = [] @@ -45,7 +45,8 @@ class SatViewModel: NSObject, ObservableObject, CLLocationManagerDelegate { private var locationManager: CLLocationManager? = nil // APPLE PARK (:D) // 37.33481435508938, -122.00893980785605 - private var observer = SatObserver(name: "user", lat: 37.33481435508938, lon:-122.00893980785605, alt: 25) + private var observer = SatelliteObserver(name: "user", latitudeDegrees: 37.33481435508938, longitudeDegrees:-122.00893980785605, altitude: 25) + override init() { super.init() timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: {_ in self.refresh()}) @@ -66,29 +67,29 @@ class SatViewModel: NSObject, ObservableObject, CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { if let location = locations.last { - userAlt = location.altitude - userLon = location.coordinate.longitude - userLat = location.coordinate.latitude - observer = SatObserver(name: "user", lat: userLat, lon: userLon, alt: userAlt) - userGridSquare = latLonToGridSquare(lat: userLat, lon: userLon) + userAltitude = location.altitude + userLongitude = location.coordinate.longitude + userLatitude = location.coordinate.latitude + observer = SatelliteObserver(name: "user", latitudeDegrees: userLatitude, longitudeDegrees: userLongitude, altitude: userAltitude) + userGridSquare = latLonToGridSquare(latitude: userLatitude, longitude: userLongitude) } } func refresh() { if let trackedSat = trackedSat { - if case let .success(observation) = getSatObservation(observer: observer, + if case let .success(observation) = getSatelliteObservation(observer: observer, orbit: trackedSat) { if observation.elevation > 0 { currentAz = observation.azimuth.deg currentEl = observation.elevation.deg let los = getSatNextLos(observer: observer, orbit: trackedSat) - currentLos = los.date + currentLos = los.julianDate } else { - let nextPass = getNextSatPass(observer: observer, orbit: trackedSat) - nextAos = nextPass.aos.date - nextLos = nextPass.los.date - maxEl = nextPass.maxElevation.elevation.deg + let nextPass = getNextSatellitePass(observer: observer, orbit: trackedSat) + nextAos = nextPass.aos.julianDate + nextLos = nextPass.los.julianDate + maxEl = nextPass.maxElevation.elevation.deg } let newVisible = observation.elevation > 0 if visible == nil || visible! != newVisible { @@ -104,7 +105,7 @@ class SatViewModel: NSObject, ObservableObject, CLLocationManagerDelegate { let endTime = isVisible ? currentLos! : nextLos! var newPassTrack: [(Double, Double)] = [] for t in stride(from: startTime, through: endTime, by: 10) { - if case let .success(observation) = getSatObservation(observer: observer, orbit: trackedSat!, time: t) { + if case let .success(observation) = getSatelliteObservation(observer: observer, orbit: trackedSat!, time: t) { newPassTrack.append((observation.azimuth.deg, observation.elevation.deg)) } } @@ -119,9 +120,9 @@ func azElToXy(az: Double, el: Double) -> (Double, Double) { return (r * sin(az.rad), r * cos(az.rad)) } -func latLonToGridSquare(lat: Double, lon: Double) -> String { - var lon = lon + 180 - var lat = lat + 90 +func latLonToGridSquare(latitude: Double, longitude: Double) -> String { + var lon = longitude + 180 + var lat = latitude + 90 var result = "" var lonBand = floor(lon / 20) var latBand = floor(lat / 10) diff --git a/SatHunter/Satellite/SatelliteObserver.swift b/SatHunter/Satellite/SatelliteObserver.swift new file mode 100644 index 0000000..2106cdc --- /dev/null +++ b/SatHunter/Satellite/SatelliteObserver.swift @@ -0,0 +1,25 @@ +// +// SatelliteObserver.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/11/24. +// + +import Foundation + +public class SatelliteObserver { + init(name: String, latitudeDegrees: Double, longitudeDegrees: Double, altitude: Double) { + ptrInternal = predict_create_observer(name, latitudeDegrees.rad, longitudeDegrees.rad, altitude) + } + deinit { + predict_destroy_observer(ptrInternal) + } + + var ptr: UnsafeMutablePointer { + get { + ptrInternal + } + } + + private var ptrInternal: UnsafeMutablePointer +} diff --git a/SatHunter/Satellite/SatelliteOrbitElements.swift b/SatHunter/Satellite/SatelliteOrbitElements.swift new file mode 100644 index 0000000..8f11669 --- /dev/null +++ b/SatHunter/Satellite/SatelliteOrbitElements.swift @@ -0,0 +1,26 @@ +// +// SatelliteOrbitElements.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/11/24. +// + +import Foundation + +public class SatelliteOrbitElements { + init(_ tle: (String, String)) { + self.tle = tle + ptrInternal = predict_parse_tle(tle.0, tle.1) + } + deinit { + predict_destroy_orbital_elements(ptrInternal) + } + + private var ptrInternal: UnsafeMutablePointer + var ptr: UnsafeMutablePointer { + get { + ptrInternal + } + } + var tle: (String, String) +} diff --git a/SatHunter/Satellite/SatellitePass.swift b/SatHunter/Satellite/SatellitePass.swift new file mode 100644 index 0000000..9630dc7 --- /dev/null +++ b/SatHunter/Satellite/SatellitePass.swift @@ -0,0 +1,18 @@ +// +// SatellitePass.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/11/24. +// + +import Foundation + +public struct SatellitePass { + var aos: predict_observation + var los: predict_observation + var maxElevation: predict_observation + + var description: String { + "AOS (local): \(self.aos.julianDate.description(with: .current))\nLOS (local): \(self.los.julianDate.description(with: .current))\nMax elevation: \(self.maxElevation.elevation.deg) deg" + } +} diff --git a/SatHunter/Satellite/SatellitePredictions.swift b/SatHunter/Satellite/SatellitePredictions.swift new file mode 100644 index 0000000..c400a84 --- /dev/null +++ b/SatHunter/Satellite/SatellitePredictions.swift @@ -0,0 +1,78 @@ +// +// LibPredict.swift +// LibPredictTestProgram +// +// Created by Zhuo Peng on 5/27/23. +// + +import Foundation + + +public extension predict_observation { + var julianDate: Date { + get { + Date(timeIntervalSince1970: Double(predict_from_julian(self.time))) + } + } +} + +public func getNextSatellitePass(observer: SatelliteObserver, orbit: SatelliteOrbitElements, time: Date = Date.now) -> SatellitePass { + let aos = predict_next_aos(observer.ptr, orbit.ptr, predict_to_julian(time_t(time.timeIntervalSince1970))) + let los = predict_next_los(observer.ptr, orbit.ptr, aos.time) + let maxElevation = predict_at_max_elevation(observer.ptr, orbit.ptr, aos.time) + return SatellitePass(aos: aos, los: los, maxElevation: maxElevation) +} + +public enum FrequencyForDopplerCalculation { + case UpLinkHz(Int) + case DownLinkHz(Int) +} + +// Returns the shift (the delta to be added to freq), not shifted freq. +public func getSatDopplerShift(observation: predict_observation, freq: FrequencyForDopplerCalculation) -> Int { + var freqF: Double = 0 + switch freq { + case .DownLinkHz(let freqI): + fallthrough + case .UpLinkHz(let freqI): + freqF = Double(freqI) + } + + let shift = withUnsafePointer(to: observation) { + ptr in + predict_doppler_shift(ptr, freqF) + } + switch freq { + case .DownLinkHz(_): + return Int(shift) + case .UpLinkHz(_): + return Int(-shift) + } +} + +// Where is the sat now? +public func getSatelliteObservation(observer: SatelliteObserver, orbit: SatelliteOrbitElements, time: Date = Date.now) -> Result { + var position = predict_position() + let errorCode = withUnsafeMutablePointer(to: &position) { + ptr in + predict_orbit(orbit.ptr, ptr, predict_to_julian(time_t(time.timeIntervalSince1970))) + } + if errorCode != 0 { + return .failure(NSError(domain: "getSatObservation", code: Int(errorCode))) + } + var observation = predict_observation() + withUnsafeMutablePointer(to: &observation) { + observerPtr in + withUnsafePointer(to: position) { + positionPtr in + predict_observe_orbit(observer.ptr, positionPtr, observerPtr) + } + } + return .success(observation) +} + +// Returns the immediate next LOS. If the sat is currently visible, then it's +// the LOS of the current pass, otherwise, it's the next pass. +public func getSatNextLos(observer: SatelliteObserver, orbit: SatelliteOrbitElements, time: Date = Date.now) -> predict_observation { + return predict_next_los(observer.ptr, orbit.ptr, predict_to_julian(time_t(time.timeIntervalSince1970))) +} diff --git a/SatHunter/SettingsView.swift b/SatHunter/SettingsView.swift index 4a87db9..d9ffae4 100644 --- a/SatHunter/SettingsView.swift +++ b/SatHunter/SettingsView.swift @@ -1,114 +1,129 @@ import SwiftUI func getBtId(requestNew: Bool = false) -> UUID { - if let stored = UserDefaults.standard.string(forKey: "BTID") { - if let uuid = UUID(uuidString: stored) { - if !requestNew { - return uuid - } + if let stored = UserDefaults.standard.string(forKey: "BTID") { + if let uuid = UUID(uuidString: stored) { + if !requestNew { + return uuid + } + } } - } - let new = UUID() - UserDefaults.standard.set(new.uuidString, forKey: "BTID") - return new + let new = UUID() + UserDefaults.standard.set(new.uuidString, forKey: "BTID") + return new } func getShowOnlySatsWithUplink() -> Bool { - UserDefaults.standard.bool(forKey: "showOnlySatsWithUplink") + UserDefaults.standard.bool(forKey: "showOnlySatsWithUplink") } func setShowOnlySatsWithUplink(_ v: Bool) { - UserDefaults.standard.setValue(v, forKey: "showOnlySatsWithUplink") + UserDefaults.standard.setValue(v, forKey: "showOnlySatsWithUplink") } func getShowUVActiveSatsOnly() -> Bool { - UserDefaults.standard.bool(forKey: "showUVActiveSatsOnly") + UserDefaults.standard.bool(forKey: "showUVActiveSatsOnly") } func setShowUVActiveSatsOnly(_ v: Bool) { - UserDefaults.standard.setValue(v, forKey: "showUVActiveSatsOnly") + UserDefaults.standard.setValue(v, forKey: "showUVActiveSatsOnly") } struct SettingsView: View { - @State var isLoading: Bool = false - @State var lastTleLoadTime: Date? = SatInfoManager( - onlyLoadLocally: true).lastUpdated - @State var btId: UUID = getBtId(requestNew: false) - @State var showOnlySatsWithUplink: Bool = getShowOnlySatsWithUplink() - @State var showUVActiveSatsOnly: Bool = getShowUVActiveSatsOnly() - var body: some View { - ZStack { - List { - Section { - Text("[Getting Started Guide](https://github.com/brills/SatHunter/blob/main/Docs/GettingStarted.md)") - } - Section(content: { - HStack { - Text("Bluetooth ID") - Text(btId.uuidString) - .font(.body.monospaced()) - .minimumScaleFactor(0.6) - .scaledToFit() - } - Button("Reset") { - btId = getBtId(requestNew: true) - } - }, footer: { - Text( - "Your IC-705 remembers this ID when you pair it " + - "with your iPhone the first time, after which it " + - "only accepts BTLE connection from this iPhone." - ) - }) - Section(content: { - HStack { - Text("Data last updated") - Text(getLastTleLoadTime()).foregroundColor(.gray) - } - Button("Update now") { - self.isLoading = true - DispatchQueue.global().async { - let m = SatInfoManager() - _ = m.loadFromInternet() - let d = m.lastUpdated - DispatchQueue.main.async { - self.lastTleLoadTime = d - self.isLoading = false - } + @ObservedObject var themeManager: ThemeManager + @State var isLoading: Bool = false + @State var lastTleLoadTime: Date? = SatInfoManager(onlyLoadLocally: true).lastUpdated + @State var btId: UUID = getBtId(requestNew: false) + @State var showOnlySatsWithUplink: Bool = getShowOnlySatsWithUplink() + @State var showUVActiveSatsOnly: Bool = getShowUVActiveSatsOnly() + + var body: some View { + ZStack { + List { + Section { + Text("[Getting Started Guide](https://github.com/brills/SatHunter/blob/main/Docs/GettingStarted.md)") + } + // App theme + Section { + Picker("Theme", selection: $themeManager.selectedTheme) { + ForEach(AppTheme.allCases) { theme in + Text(theme.rawValue).tag(theme) + } + } + .pickerStyle(MenuPickerStyle()) + } + // Bluetooth ID + Section(content: { + HStack { + Text("Bluetooth ID") + Text(btId.uuidString) + .font(.body.monospaced()) + .minimumScaleFactor(0.6) + .scaledToFit() + } + Button("Reset") { + btId = getBtId(requestNew: true) + } + }, footer: { + Text( + "Your IC-705 remembers this ID when you pair it " + + "with your iPhone the first time, after which it " + + "only accepts BTLE connection from this iPhone." + ) + }) + // Update satellites + Section(content: { + HStack { + Text("Data last updated") + Text(getLastTleLoadTime()).foregroundColor(.gray) + } + Button("Update now") { + self.isLoading = true + DispatchQueue.global().async { + let m = SatInfoManager() + _ = m.loadFromInternet() + let d = m.lastUpdated + DispatchQueue.main.async { + self.lastTleLoadTime = d + self.isLoading = false + } + } + } + }, footer: { + Text( + "SatHunter downloads TLE and transponder information " + + "from the Internet. Use the latest orbit elements for the " + + "most accurate predictions." + ) + }) + // Show active U/V satellites only + Section(content: { + Toggle("Show active U/V satellites only", isOn: $showUVActiveSatsOnly).onChange(of: showUVActiveSatsOnly) { newValue in + setShowUVActiveSatsOnly(newValue) + } + }, footer: { + Text("Only show satellites that have at least an active " + + "transponder in UHF / VHF range.") + }) + } + .disabled(isLoading) + + if isLoading { + ProgressView() } - } - }, footer: { - Text( - "SatHunter downloads TLE and transponder information " + - "from the Internet. Use the latest orbit elements for the " + - "most accurate predictions." - ) - }) - Section(content: { - Toggle("Show active U/V satellites only", isOn: $showUVActiveSatsOnly).onChange(of: showUVActiveSatsOnly) { - newValue in - setShowUVActiveSatsOnly(newValue) - } - }, footer: { - Text("Only show satellites that have at least an active " + - "transponder in UHF / VHF range.") - }) - } - }.disabled(isLoading) - if isLoading { - ProgressView() + } } - } - private func getLastTleLoadTime() -> String { - if let d = lastTleLoadTime { - return d.formatted(date:.numeric, time:.standard) + + private func getLastTleLoadTime() -> String { + if let d = lastTleLoadTime { + return d.formatted(date: .numeric, time: .standard) + } + return "Never" } - return "Never" - } } struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - SettingsView() - } + static var previews: some View { + SettingsView(themeManager: ThemeManager()) + } } diff --git a/SatHunter/Utilities/ByteUtilities.swift b/SatHunter/Utilities/ByteUtilities.swift new file mode 100644 index 0000000..810a4c0 --- /dev/null +++ b/SatHunter/Utilities/ByteUtilities.swift @@ -0,0 +1,16 @@ +// +// ByteUtilities.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/10/24. +// + +import Foundation + +public func chainBytes(_ byteSequence: [UInt8]...) -> [UInt8] { + var result: [UInt8] = [] + for byte in byteSequence { + result.append(contentsOf: byte) + } + return result +} diff --git a/SatHunter/Utilities/Conversions.swift b/SatHunter/Utilities/Conversions.swift new file mode 100644 index 0000000..61f4548 --- /dev/null +++ b/SatHunter/Utilities/Conversions.swift @@ -0,0 +1,73 @@ +// +// Conversions.swift +// SatHunter +// +// Created by Aleksandar Zdravković on 8/10/24. +// + +import Foundation +import OSLog + +fileprivate let kPi = 3.1415926535897932384626433832795028841415926 + +public enum BCDConversionError: Error { + case overflow(value: Int) + case invalidInput +} + +public func convertNumberToBCD(_ number: Int) throws -> [UInt8] { + let maxBCDValue = 1_000_000_000 + let bcdDigitCount = 5 + + guard number < maxBCDValue else { + throw BCDConversionError.overflow(value: number) + } + + var remainder = number + var bcdBytes = Array(repeating: UInt8(0), count: bcdDigitCount) + var divisor = maxBCDValue + + for i in (0..(_ bcdBytes: S) throws -> Int where S.Element == UInt8 { + var number = 0 + var multiplier = 1 + + for byte in bcdBytes { + let lowNibble = Int(byte & 0x0F) + let highNibble = Int(byte >> 4) + + guard lowNibble < 10 && highNibble < 10 else { + throw BCDConversionError.invalidInput + } + + number += lowNibble * multiplier + multiplier *= 10 + number += highNibble * multiplier + multiplier *= 10 + } + + return number +} + +public extension Double { + var rad: Double { + self * kPi / 180 + } + var deg: Double { + self * 180 / kPi + } +}