From bf6be80bfa817bb0ad82005d990835fea7b5909f Mon Sep 17 00:00:00 2001 From: AlexandarZ Date: Sat, 10 Aug 2024 15:32:19 +0200 Subject: [PATCH 1/2] Polishing readme and contribution markdown --- Docs/Contribution.md | 21 +++++++++++++++++++++ README.md | 9 +-------- 2 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 Docs/Contribution.md 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 From 4fc2eb451a10042fd48ef07a526c6de2a56f21f2 Mon Sep 17 00:00:00 2001 From: AlexandarZ Date: Sun, 11 Aug 2024 15:42:02 +0200 Subject: [PATCH 2/2] Minor refactoring before further project improvements --- .DS_Store | Bin 0 -> 6148 bytes SatHunter.xcodeproj/project.pbxproj | 106 +++- .../UserInterfaceState.xcuserstate | Bin 0 -> 143996 bytes SatHunter/AppTheme.swift | 85 +++ SatHunter/Icom-705/IC705.swift | 158 ++++++ .../Icom-705/IC705BluetoothDelegate.swift | 252 +++++++++ SatHunter/LibPredict.swift | 138 ----- SatHunter/Models/ConnectionState.swift | 25 + SatHunter/Models/Mode.swift | 25 + SatHunter/Models/Rig.swift | 26 + SatHunter/Models/RigError.swift | 13 + SatHunter/Models/RigState.swift | 13 + SatHunter/Models/SatelliteListItem.swift | 26 + SatHunter/Models/ToneFrequency.swift | 32 ++ SatHunter/Rig.swift | 502 ------------------ SatHunter/RigControlView.swift | 66 +-- SatHunter/SatFreqView.swift | 33 +- SatHunter/SatHunterApp.swift | 20 +- SatHunter/SatInfoView.swift | 43 +- SatHunter/SatListView.swift | 87 ++- SatHunter/SatView.swift | 4 +- SatHunter/SatViewModel.swift | 41 +- SatHunter/Satellite/SatelliteObserver.swift | 25 + .../Satellite/SatelliteOrbitElements.swift | 26 + SatHunter/Satellite/SatellitePass.swift | 18 + .../Satellite/SatellitePredictions.swift | 78 +++ SatHunter/SettingsView.swift | 191 ++++--- SatHunter/Utilities/ByteUtilities.swift | 16 + SatHunter/Utilities/Conversions.swift | 73 +++ 29 files changed, 1253 insertions(+), 869 deletions(-) create mode 100644 .DS_Store create mode 100644 SatHunter.xcodeproj/project.xcworkspace/xcuserdata/azdravkovic.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 SatHunter/AppTheme.swift create mode 100644 SatHunter/Icom-705/IC705.swift create mode 100644 SatHunter/Icom-705/IC705BluetoothDelegate.swift delete mode 100644 SatHunter/LibPredict.swift create mode 100644 SatHunter/Models/ConnectionState.swift create mode 100644 SatHunter/Models/Mode.swift create mode 100644 SatHunter/Models/Rig.swift create mode 100644 SatHunter/Models/RigError.swift create mode 100644 SatHunter/Models/RigState.swift create mode 100644 SatHunter/Models/SatelliteListItem.swift create mode 100644 SatHunter/Models/ToneFrequency.swift delete mode 100644 SatHunter/Rig.swift create mode 100644 SatHunter/Satellite/SatelliteObserver.swift create mode 100644 SatHunter/Satellite/SatelliteOrbitElements.swift create mode 100644 SatHunter/Satellite/SatellitePass.swift create mode 100644 SatHunter/Satellite/SatellitePredictions.swift create mode 100644 SatHunter/Utilities/ByteUtilities.swift create mode 100644 SatHunter/Utilities/Conversions.swift diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1e4c3366491f3448d0aa7f01feebf0425a9f368c GIT binary patch literal 6148 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 CV>kc+ literal 0 HcmV?d00001 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 + } +}