diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 86eb6681..c1c78c19 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ DD2AD8A8296D2DF9001FF0E7 /* MapViewSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2AD8A7296D2DF9001FF0E7 /* MapViewSwiftUI.swift */; }; DD33DB622B3D27C7003E1EA0 /* FirmwareApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD33DB612B3D27C7003E1EA0 /* FirmwareApi.swift */; }; DD3501892852FC3B000FC853 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3501882852FC3B000FC853 /* Settings.swift */; }; + DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD354FD82BD96A0B0061A25F /* IAQScale.swift */; }; DD3619152B1EF9F900C41C8C /* LocationsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */; }; DD3CC6B528E33FD100FA9159 /* ShareChannels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */; }; DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */; }; @@ -87,7 +88,7 @@ DD5E5211298EE33B00D21B61 /* remote_hardware.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5E51FF298EE33B00D21B61 /* remote_hardware.pb.swift */; }; DD5E5212298EE33B00D21B61 /* apponly.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5E5200298EE33B00D21B61 /* apponly.pb.swift */; }; DD5E5213298EE33B00D21B61 /* deviceonly.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5E5201298EE33B00D21B61 /* deviceonly.pb.swift */; }; - DD5E523F298F5A9E00D21B61 /* AirQualityIndexCompact.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5E523E298F5A9E00D21B61 /* AirQualityIndexCompact.swift */; }; + DD5E523F298F5A9E00D21B61 /* AirQualityIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5E523E298F5A9E00D21B61 /* AirQualityIndex.swift */; }; DD6193752862F6E600E59241 /* ExternalNotificationConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6193742862F6E600E59241 /* ExternalNotificationConfig.swift */; }; DD6193772862F90F00E59241 /* CannedMessagesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6193762862F90F00E59241 /* CannedMessagesConfig.swift */; }; DD6193792863875F00E59241 /* SerialConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6193782863875F00E59241 /* SerialConfig.swift */; }; @@ -292,10 +293,12 @@ DD295CE92B323ED9002CC4AC /* MeshtasticDataModelV22.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV22.xcdatamodel; sourceTree = ""; }; DD2AD8A7296D2DF9001FF0E7 /* MapViewSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewSwiftUI.swift; sourceTree = ""; }; DD2CC2E52ABFE04E00EDFDA7 /* MeshtasticDataModelV19.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV19.xcdatamodel; sourceTree = ""; }; + DD31B04D2BDC6FD30024FA63 /* MeshtasticDataModelV 36.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 36.xcdatamodel"; sourceTree = ""; }; DD31EC492B7F18B7006A3995 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; DD33DB602B3D1ECC003E1EA0 /* MeshtasticDataModelV 23.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 23.xcdatamodel"; sourceTree = ""; }; DD33DB612B3D27C7003E1EA0 /* FirmwareApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareApi.swift; sourceTree = ""; }; DD3501882852FC3B000FC853 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; + DD354FD82BD96A0B0061A25F /* IAQScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAQScale.swift; sourceTree = ""; }; DD3619132B1EE20700C41C8C /* MeshtasticDataModelV21.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV21.xcdatamodel; sourceTree = ""; }; DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsHandler.swift; sourceTree = ""; }; DD398EBD2B93F640002B4C51 /* MeshtasticDataModelV 29.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 29.xcdatamodel"; sourceTree = ""; }; @@ -339,7 +342,7 @@ DD5E51FF298EE33B00D21B61 /* remote_hardware.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = remote_hardware.pb.swift; sourceTree = ""; }; DD5E5200298EE33B00D21B61 /* apponly.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = apponly.pb.swift; sourceTree = ""; }; DD5E5201298EE33B00D21B61 /* deviceonly.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = deviceonly.pb.swift; sourceTree = ""; }; - DD5E523E298F5A9E00D21B61 /* AirQualityIndexCompact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirQualityIndexCompact.swift; sourceTree = ""; }; + DD5E523E298F5A9E00D21B61 /* AirQualityIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirQualityIndex.swift; sourceTree = ""; }; DD6193742862F6E600E59241 /* ExternalNotificationConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalNotificationConfig.swift; sourceTree = ""; }; DD6193762862F90F00E59241 /* CannedMessagesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CannedMessagesConfig.swift; sourceTree = ""; }; DD6193782863875F00E59241 /* SerialConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfig.swift; sourceTree = ""; }; @@ -649,9 +652,11 @@ DD5E523D298F5A7D00D21B61 /* Weather */ = { isa = PBXGroup; children = ( - DD5E523E298F5A9E00D21B61 /* AirQualityIndexCompact.swift */, + DD5E523E298F5A9E00D21B61 /* AirQualityIndex.swift */, DDFEB3BA29900C1200EE7472 /* CurrentConditionsCompact.swift */, + DDA9515D2BC6F56F00CEA535 /* IndoorAirQuality.swift */, DD41A61429AB0035003C5A37 /* NodeWeatherForecast.swift */, + DD354FD82BD96A0B0061A25F /* IAQScale.swift */, ); path = Weather; sourceTree = ""; @@ -921,7 +926,6 @@ DDB75A1D2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift */, DDB75A202A12B954006ED576 /* LoRaSignalStrength.swift */, DDB75A222A13CDA9006ED576 /* BatteryLevelCompact.swift */, - DDA9515D2BC6F56F00CEA535 /* IndoorAirQuality.swift */, ); path = Helpers; sourceTree = ""; @@ -1204,7 +1208,7 @@ DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */, DD0E20FD2B87090400F2D100 /* clientonly.pb.swift in Sources */, D93069082B81DF040066FBC8 /* SaveConfigButton.swift in Sources */, - DD5E523F298F5A9E00D21B61 /* AirQualityIndexCompact.swift in Sources */, + DD5E523F298F5A9E00D21B61 /* AirQualityIndex.swift in Sources */, DD964FBF296E76EF007C176F /* WaypointFormMapKit.swift in Sources */, DD3501892852FC3B000FC853 /* Settings.swift in Sources */, DDDC22382BA92344002C44F1 /* MeshMapContent.swift in Sources */, @@ -1239,6 +1243,7 @@ DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, + DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */, DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */, DD2AD8A8296D2DF9001FF0E7 /* MapViewSwiftUI.swift in Sources */, @@ -1578,7 +1583,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.6; + MARKETING_VERSION = 2.3.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1612,7 +1617,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.6; + MARKETING_VERSION = 2.3.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1685,7 +1690,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.6; + MARKETING_VERSION = 2.3.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1718,7 +1723,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.6; + MARKETING_VERSION = 2.3.7; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1820,6 +1825,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DD31B04D2BDC6FD30024FA63 /* MeshtasticDataModelV 36.xcdatamodel */, DD268D8C2BCC7D11008073AE /* MeshtasticDataModelV 35.xcdatamodel */, DDDBC87C2BC65682001E8DF7 /* MeshtasticDataModelV 34.xcdatamodel */, DDF45C382BC46B16005ED5F2 /* MeshtasticDataModelV33.xcdatamodel */, @@ -1856,7 +1862,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DD268D8C2BCC7D11008073AE /* MeshtasticDataModelV 35.xcdatamodel */; + currentVersion = DD31B04D2BDC6FD30024FA63 /* MeshtasticDataModelV 36.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Enums/TelemetryEnums.swift b/Meshtastic/Enums/TelemetryEnums.swift index 1189660c..1e9f54fd 100644 --- a/Meshtastic/Enums/TelemetryEnums.swift +++ b/Meshtastic/Enums/TelemetryEnums.swift @@ -8,6 +8,86 @@ import Foundation import SwiftUI +enum Aqi: Int, CaseIterable, Identifiable { + case good = 0 + case moderate = 1 + case sensitive = 2 + case unhealthy = 3 + case veryUnhealthy = 4 + case hazardous = 5 + + var id: Int { self.rawValue } + var description: String { + switch self { + case .good: + return "Good" + case .moderate: + return "Moderate" + case .sensitive: + return "Unhealthy for Sensitive Groups" + case .unhealthy: + return "Unhealthy" + case .veryUnhealthy: + return "Very Unhealthy" + case .hazardous: + return "Hazardous" + } + } + var color: Color { + switch self { + case .good: + return .green + case .moderate: + return .yellow + case .sensitive: + return .orange + case .unhealthy: + return .red + case .veryUnhealthy: + return .purple + case .hazardous: + return .magenta + } + } + var range: Range { + switch self { + case .good: + return Range(0...50) + case .moderate: + return Range(51...100) + case .sensitive: + return Range(101...150) + case .unhealthy: + return Range(151...200) + case .veryUnhealthy: + return Range(201...300) + case .hazardous: + return Range(301...500) + } + } + + static func getAqi(for value: Int) -> Aqi { + let aqi: Aqi + switch value { + case 0...50: + aqi = .good + case 51...100: + aqi = .moderate + case 101...150: + aqi = .sensitive + case 151...200: + aqi = .unhealthy + case 201...300: + aqi = .veryUnhealthy + case 301...500: + aqi = .hazardous + default: + fatalError("Invalid int value") + } + return aqi + } +} + enum Iaq: Int, CaseIterable, Identifiable { case excellent = 0 case good = 1 @@ -27,7 +107,7 @@ enum Iaq: Int, CaseIterable, Identifiable { case .lightlyPolluted: return "Lightly Polluted" case .moderatelyPolluted: - return "Lightly Polluted" + return "Moderately Polluted" case .heavilyPolluted: return "Heavily Polluted" case .severelyPolluted: @@ -49,11 +129,30 @@ enum Iaq: Int, CaseIterable, Identifiable { case .heavilyPolluted: return .red case .severelyPolluted: - return .purple + return .magenta case .extremelyPolluted: return .brown } } + + var range: Range { + switch self { + case .excellent: + return Range(0...50) + case .good: + return Range(51...100) + case .lightlyPolluted: + return Range(101...150) + case .moderatelyPolluted: + return Range(151...200) + case .heavilyPolluted: + return Range(201...250) + case .severelyPolluted: + return Range(251...350) + case .extremelyPolluted: + return Range(351...500) + } + } static func getIaq(for value: Int) -> Iaq { let iaq: Iaq switch value { diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift index 396c00fe..1048846c 100644 --- a/Meshtastic/Export/WriteCsvFile.swift +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -32,7 +32,7 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin } } else if metricsType == 1 { // Create Environment Telemetry Header - csvString = "Temperature, Relative Humidity, Barometric Pressure, Indoor Air Quality, Gas Resistance, \("voltage".localized), \("current".localized), \("timestamp".localized)" + csvString = "Temperature, Relative Humidity, Barometric Pressure, Indoor Air Quality, Gas Resistance, \("timestamp".localized)" for dm in telemetry { if dm.metricsType == 1 { csvString += "\n" @@ -46,10 +46,6 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin csvString += ", " csvString += String(dm.gasResistance) csvString += ", " - csvString += String(dm.voltage) - csvString += ", " - csvString += String(dm.current) - csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } } diff --git a/Meshtastic/Extensions/Color.swift b/Meshtastic/Extensions/Color.swift index da47cadc..252eb361 100644 --- a/Meshtastic/Extensions/Color.swift +++ b/Meshtastic/Extensions/Color.swift @@ -17,6 +17,7 @@ extension Color { let brightness = ((components[0] * 299) + (components[1] * 587) + (components[2] * 114)) / 1000 return (brightness > 0.5) } + public static let magenta = Color(red: 0.50, green: 0.00, blue: 0.00) } extension UIColor { diff --git a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift index dfa373be..e5a4d95a 100644 --- a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift @@ -18,4 +18,14 @@ extension ChannelEntity { let unreadMessages = allPrivateMessages.filter{ ($0 as AnyObject).read == false } return unreadMessages.count } + + var protoBuf: Channel { + var channel = Channel() + channel.index = self.index + channel.settings.name = self.name ?? "" + channel.settings.psk = self.psk ?? Data() + channel.role = Channel.Role(rawValue: Int(self.role)) ?? Channel.Role.secondary + channel.settings.moduleSettings.positionPrecision = UInt32(self.positionPrecision) + return channel + } } diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 49757d69..d4b7c098 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -68,6 +68,7 @@ extension UserDefaults { case enableSmartPosition case newNodeNotifications case lowBatteryNotifications + case channelMessageNotifications case modemPreset case firmwareVersion case testIntEnum @@ -146,6 +147,9 @@ extension UserDefaults { @UserDefault(.enableSmartPosition, defaultValue: false) static var enableSmartPosition: Bool + @UserDefault(.channelMessageNotifications, defaultValue: true) + static var channelMessageNotifications: Bool + @UserDefault(.newNodeNotifications, defaultValue: true) static var newNodeNotifications: Bool diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 084fe083..8e8a4b53 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -151,12 +151,15 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate isConnecting = false isConnected = false isSubscribed = false + self.connectedPeripheral = nil invalidVersion = false connectedVersion = "0.0.0" connectedPeripheral = nil if timeoutTimer != nil { timeoutTimer!.invalidate() } + automaticallyReconnect = false + stopScanning() startScanning() } @@ -556,7 +559,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate tryClearExistingChannels() } // NodeInfo - if decodedInfo.nodeInfo.num > 0 {// && !invalidVersion { + if context != nil && decodedInfo.nodeInfo.num > 0 {// && !invalidVersion { nowKnown = true let nodeInfo = nodeInfoPacket(nodeInfo: decodedInfo.nodeInfo, channel: decodedInfo.packet.channel, context: context!) @@ -570,17 +573,17 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } } // Channels - if decodedInfo.channel.isInitialized && connectedPeripheral != nil { + if context != nil && decodedInfo.channel.isInitialized && connectedPeripheral != nil { nowKnown = true channelPacket(channel: decodedInfo.channel, fromNum: Int64(truncatingIfNeeded: connectedPeripheral.num), context: context!) } // Config - if decodedInfo.config.isInitialized && !invalidVersion && connectedPeripheral != nil { + if context != nil && decodedInfo.config.isInitialized && !invalidVersion && connectedPeripheral != nil { nowKnown = true localConfig(config: decodedInfo.config, context: context!, nodeNum: Int64(truncatingIfNeeded: self.connectedPeripheral.num), nodeLongName: self.connectedPeripheral.longName) } // Module Config - if decodedInfo.moduleConfig.isInitialized && !invalidVersion && self.connectedPeripheral?.num != 0{ + if context != nil && decodedInfo.moduleConfig.isInitialized && !invalidVersion && self.connectedPeripheral?.num != 0{ nowKnown = true moduleConfig(config: decodedInfo.moduleConfig, context: context!, nodeNum: Int64(truncatingIfNeeded: self.connectedPeripheral?.num ?? 0), nodeLongName: self.connectedPeripheral.longName) if decodedInfo.moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(decodedInfo.moduleConfig.cannedMessage) { @@ -590,7 +593,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } } // Device Metadata - if decodedInfo.metadata.firmwareVersion.count > 0 && !invalidVersion { + if context != nil && decodedInfo.metadata.firmwareVersion.count > 0 && !invalidVersion { nowKnown = true deviceMetadataPacket(metadata: decodedInfo.metadata, fromNum: connectedPeripheral.num, context: context!) connectedPeripheral.firmwareVersion = decodedInfo.metadata.firmwareVersion @@ -994,37 +997,28 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return success } - public func getPositionFromPhoneGPS(channel: Int32, destNum: Int64) -> Position? { + public func getPositionFromPhoneGPS(destNum: Int64) -> Position? { var positionPacket = Position() - - let fetchChannelRequest: NSFetchRequest = NSFetchRequest.init(entityName: "ChannelEntity") - fetchChannelRequest.predicate = NSPredicate(format: "index == %lld", channel) - do { - guard let fetchedChannel = try context!.fetch(fetchChannelRequest) as? [ChannelEntity] else { - return nil - } if #available(iOS 17.0, macOS 14.0, *) { if let lastLocation = LocationsHandler.shared.locationsArray.last { - if fetchedChannel.count > 0 { - positionPacket.latitudeI = Int32(lastLocation.coordinate.latitude * 1e7) - positionPacket.longitudeI = Int32(lastLocation.coordinate.longitude * 1e7) - let timestamp = lastLocation.timestamp - positionPacket.time = UInt32(timestamp.timeIntervalSince1970) - positionPacket.timestamp = UInt32(timestamp.timeIntervalSince1970) - positionPacket.altitude = Int32(lastLocation.altitude) - positionPacket.satsInView = UInt32(LocationsHandler.satsInView) - positionPacket.precisionBits = UInt32(fetchedChannel[0].positionPrecision) - let currentSpeed = lastLocation.speed - if currentSpeed > 0 && (!currentSpeed.isNaN || !currentSpeed.isInfinite) { - positionPacket.groundSpeed = UInt32(currentSpeed * 3.6) - } - let currentHeading = lastLocation.course - if currentHeading > 0 && (!currentHeading.isNaN || !currentHeading.isInfinite) { - positionPacket.groundTrack = UInt32(currentHeading) - } + positionPacket.latitudeI = Int32(lastLocation.coordinate.latitude * 1e7) + positionPacket.longitudeI = Int32(lastLocation.coordinate.longitude * 1e7) + let timestamp = lastLocation.timestamp + positionPacket.time = UInt32(timestamp.timeIntervalSince1970) + positionPacket.timestamp = UInt32(timestamp.timeIntervalSince1970) + positionPacket.altitude = Int32(lastLocation.altitude) + positionPacket.satsInView = UInt32(LocationsHandler.satsInView) + + let currentSpeed = lastLocation.speed + if currentSpeed > 0 && (!currentSpeed.isNaN || !currentSpeed.isInfinite) { + positionPacket.groundSpeed = UInt32(currentSpeed * 3.6) + } + let currentHeading = lastLocation.course + if currentHeading > 0 && (!currentHeading.isNaN || !currentHeading.isInfinite) { + positionPacket.groundTrack = UInt32(currentHeading) } } @@ -1032,9 +1026,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if destNum <= 0 || LocationHelper.currentLocation.distance(from: LocationHelper.DefaultLocation) == 0.0 { return nil } - if fetchedChannel.count <= 0 { - return nil - } positionPacket.latitudeI = Int32(LocationHelper.currentLocation.latitude * 1e7) positionPacket.longitudeI = Int32(LocationHelper.currentLocation.longitude * 1e7) @@ -1043,7 +1034,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate positionPacket.timestamp = UInt32(timestamp.timeIntervalSince1970) positionPacket.altitude = Int32(LocationHelper.shared.locationManager.location?.altitude ?? 0) positionPacket.satsInView = UInt32(LocationHelper.satsInView) - positionPacket.precisionBits = UInt32(fetchedChannel[0].positionPrecision) let currentSpeed = LocationHelper.shared.locationManager.location?.speed ?? 0 if currentSpeed > 0 && (!currentSpeed.isNaN || !currentSpeed.isInfinite) { positionPacket.groundSpeed = UInt32(currentSpeed * 3.6) @@ -1053,17 +1043,15 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate positionPacket.groundTrack = UInt32(currentHeading) } } - } catch { return nil } - return positionPacket } public func setFixedPosition(fromUser: UserEntity, channel: Int32) -> Bool { var adminPacket = AdminMessage() - guard let positionPacket = getPositionFromPhoneGPS(channel: channel, destNum: fromUser.num) else { + guard let positionPacket = getPositionFromPhoneGPS(destNum: fromUser.num) else { return false } adminPacket.setFixedPosition = positionPacket @@ -1109,7 +1097,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate public func sendPosition(channel: Int32, destNum: Int64, wantResponse: Bool) -> Bool { var success = false let fromNodeNum = connectedPeripheral.num - guard let positionPacket = getPositionFromPhoneGPS(channel: channel, destNum: destNum) else { + guard let positionPacket = getPositionFromPhoneGPS(destNum: destNum) else { return false } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 3f6aa1ee..f61d1994 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -8,6 +8,7 @@ import Foundation import CoreData import SwiftUI +import RegexBuilder #if canImport(ActivityKit) import ActivityKit #endif @@ -160,12 +161,14 @@ func channelPacket (channel: Channel, fromNum: Int64, context: NSManagedObjectCo newChannel.name = channel.settings.name newChannel.role = Int32(channel.role.rawValue) newChannel.psk = channel.settings.psk - newChannel.positionPrecision = Int32(truncatingIfNeeded: channel.settings.moduleSettings.positionPrecision) + if channel.settings.hasModuleSettings { + newChannel.positionPrecision = Int32(truncatingIfNeeded: channel.settings.moduleSettings.positionPrecision) + newChannel.mute = channel.settings.moduleSettings.isClientMuted + } guard let mutableChannels = fetchedMyInfo[0].channels!.mutableCopy() as? NSMutableOrderedSet else { return } if let oldChannel = mutableChannels.first(where: {($0 as AnyObject).index == newChannel.index }) as? ChannelEntity { - newChannel.mute = oldChannel.mute let index = mutableChannels.index(of: oldChannel as Any) mutableChannels.replaceObject(at: index, with: newChannel) } else { @@ -772,7 +775,19 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connectedNode: Int64, storeForward: Bool = false, context: NSManagedObjectContext) { var messageText = String(bytes: packet.decoded.payload, encoding: .utf8) - if !wantRangeTestPackets && (String(messageText ?? "seq ").starts(with: "seq ")) { + let rangeRef = Reference(Int.self) + let rangeTestRegex = Regex { + "seq " + + TryCapture(as: rangeRef) { + OneOrMore(.digit) + } transform: { match in + Int(match) + } + } + let rangeTest = messageText?.contains(rangeTestRegex) ?? false && messageText?.starts(with: "seq ") ?? false + + if !wantRangeTestPackets && rangeTest { return } var storeForwardBroadcast = false @@ -841,26 +856,28 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec return } let appState = AppState.shared - if newMessage.fromUser != nil && newMessage.toUser != nil && !(newMessage.fromUser?.mute ?? false) { + if newMessage.fromUser != nil && newMessage.toUser != nil { // Set Unread Message Indicators if packet.to == connectedNode { appState.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0 UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages } - // Create an iOS Notification for the received DM message and schedule it immediately - let manager = LocalNotificationManager() - manager.notifications = [ - Notification( - id: ("notification.id.\(newMessage.messageId)"), - title: "\(newMessage.fromUser?.longName ?? "unknown".localized)", - subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", - content: messageText!, - target: "message", - path: "meshtastic://open-dm?userid=\(newMessage.fromUser?.num ?? 0)&id=\(newMessage.messageId)" - ) - ] - manager.schedule() - print("💬 iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "unknown".localized)") + if !(newMessage.fromUser?.mute ?? false) { + // Create an iOS Notification for the received DM message and schedule it immediately + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: ("notification.id.\(newMessage.messageId)"), + title: "\(newMessage.fromUser?.longName ?? "unknown".localized)", + subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", + content: messageText!, + target: "message", + path: "meshtastic://open-dm?userid=\(newMessage.fromUser?.num ?? 0)&id=\(newMessage.messageId)" + ) + ] + manager.schedule() + print("💬 iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "unknown".localized)") + } } else if newMessage.fromUser != nil && newMessage.toUser == nil { let fetchMyInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "MyInfoEntity") @@ -878,7 +895,7 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec if channel.index == newMessage.channel { context.refresh(channel, mergeChanges: true) } - if channel.index == newMessage.channel && !channel.mute { + if channel.index == newMessage.channel && !channel.mute && UserDefaults.channelMessageNotifications { // Create an iOS Notification for the received private channel message and schedule it immediately let manager = LocalNotificationManager() manager.notifications = [ diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index f837dfda..9ac5587b 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV 35.xcdatamodel + MeshtasticDataModelV 36.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 36.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 36.xcdatamodel/contents new file mode 100644 index 00000000..f7144b48 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 36.xcdatamodel/contents @@ -0,0 +1,461 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 0b5428fc..4c1bed12 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -462,6 +462,7 @@ func upsertDeviceConfigPacket(config: Meshtastic.Config.DeviceConfig, nodeNum: I newDeviceConfig.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) newDeviceConfig.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) newDeviceConfig.doubleTapAsButtonPress = config.doubleTapAsButtonPress + newDeviceConfig.ledHeartbeatEnabled = !config.ledHeartbeatDisabled newDeviceConfig.isManaged = config.isManaged newDeviceConfig.tzdef = config.tzdef fetchedNode[0].deviceConfig = newDeviceConfig @@ -474,6 +475,7 @@ func upsertDeviceConfigPacket(config: Meshtastic.Config.DeviceConfig, nodeNum: I fetchedNode[0].deviceConfig?.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) fetchedNode[0].deviceConfig?.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) fetchedNode[0].deviceConfig?.doubleTapAsButtonPress = config.doubleTapAsButtonPress + fetchedNode[0].deviceConfig?.ledHeartbeatEnabled = !config.ledHeartbeatDisabled fetchedNode[0].deviceConfig?.isManaged = config.isManaged fetchedNode[0].deviceConfig?.tzdef = config.tzdef } diff --git a/Meshtastic/Protobufs/meshtastic/channel.pb.swift b/Meshtastic/Protobufs/meshtastic/channel.pb.swift index b2c55540..f2f4da72 100644 --- a/Meshtastic/Protobufs/meshtastic/channel.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/channel.pb.swift @@ -120,6 +120,11 @@ struct ModuleSettings { /// Bits of precision for the location sent in position packets. var positionPrecision: UInt32 = 0 + /// + /// Controls whether or not the phone / clients should mute the current channel + /// Useful for noisy public channels you don't necessarily want to disable + var isClientMuted: Bool = false + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -311,6 +316,7 @@ extension ModuleSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImplement static let protoMessageName: String = _protobuf_package + ".ModuleSettings" static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .standard(proto: "position_precision"), + 2: .standard(proto: "is_client_muted"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -320,6 +326,7 @@ extension ModuleSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImplement // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularUInt32Field(value: &self.positionPrecision) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.isClientMuted) }() default: break } } @@ -329,11 +336,15 @@ extension ModuleSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImplement if self.positionPrecision != 0 { try visitor.visitSingularUInt32Field(value: self.positionPrecision, fieldNumber: 1) } + if self.isClientMuted != false { + try visitor.visitSingularBoolField(value: self.isClientMuted, fieldNumber: 2) + } try unknownFields.traverse(visitor: &visitor) } static func ==(lhs: ModuleSettings, rhs: ModuleSettings) -> Bool { if lhs.positionPrecision != rhs.positionPrecision {return false} + if lhs.isClientMuted != rhs.isClientMuted {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 498496ce..c8f422b7 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -266,6 +266,18 @@ struct Connect: View { .controlSize(.large) .padding() } + if bleManager.isConnecting { + Button(role: .destructive, action: { + bleManager.cancelPeripheralConnection() + + }) { + Label("disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + } #endif Spacer() } diff --git a/Meshtastic/Views/Helpers/CircleText.swift b/Meshtastic/Views/Helpers/CircleText.swift index bb3fa07b..0a348349 100644 --- a/Meshtastic/Views/Helpers/CircleText.swift +++ b/Meshtastic/Views/Helpers/CircleText.swift @@ -29,10 +29,8 @@ struct CircleText: View { struct CircleText_Previews: PreviewProvider { static var previews: some View { - - HStack { - VStack { - + VStack { + HStack { CircleText(text: "N1", color: Color.yellow, circleSize: 80) .previewLayout(.fixed(width: 300, height: 100)) CircleText(text: "8", color: Color.purple, circleSize: 80) @@ -41,17 +39,20 @@ struct CircleText_Previews: PreviewProvider { .previewLayout(.fixed(width: 300, height: 100)) CircleText(text: "🍔", color: Color.brown, circleSize: 80) .previewLayout(.fixed(width: 300, height: 100)) + } + HStack { CircleText(text: "👻", color: Color.orange, circleSize: 80) .previewLayout(.fixed(width: 300, height: 100)) CircleText(text: "🤙", color: Color.orange, circleSize: 80) .previewLayout(.fixed(width: 300, height: 100)) - - } - VStack { CircleText(text: "69", color: Color.green, circleSize: 80) .previewLayout(.fixed(width: 300, height: 100)) CircleText(text: "WWWW", color: Color.cyan, circleSize: 80) .previewLayout(.fixed(width: 300, height: 100)) + } + HStack { + + CircleText(text: "CW-A", color: Color.secondary) .previewLayout(.fixed(width: 300, height: 100)) CircleText(text: "CW-A", color: Color.secondary, circleSize: 80) @@ -60,7 +61,20 @@ struct CircleText_Previews: PreviewProvider { .previewLayout(.fixed(width: 300, height: 100)) CircleText(text: "IIII", color: Color.accentColor, circleSize: 80) .previewLayout(.fixed(width: 300, height: 100)) - CircleText(text: "LCP", color: Color.primary, circleSize: 80) + } + HStack { + + CircleText(text: "🚗", color: Color.orange) + .previewLayout(.fixed(width: 300, height: 100)) + CircleText(text: "🔋", color: Color.indigo, circleSize: 80) + .previewLayout(.fixed(width: 300, height: 100)) + CircleText(text: "🛢️", color: Color.orange, circleSize: 80) + .previewLayout(.fixed(width: 300, height: 100)) + CircleText(text: "LCP", color: Color.indigo, circleSize: 80) + .previewLayout(.fixed(width: 300, height: 100)) + } + HStack { + CircleText(text: "🤡", color: Color.red, circleSize: 80) .previewLayout(.fixed(width: 300, height: 100)) } } diff --git a/Meshtastic/Views/Helpers/IndoorAirQuality.swift b/Meshtastic/Views/Helpers/IndoorAirQuality.swift deleted file mode 100644 index fa90d6f5..00000000 --- a/Meshtastic/Views/Helpers/IndoorAirQuality.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// IndoorAirQuality.swift -// Meshtastic -// -// Copyright(c) by Garth Vander Houwen on 4/10/24. -// - -import Foundation -import SwiftUI - -enum IaqDisplayMode: Int, CaseIterable, Identifiable { - - case pill = 0 - case dot = 1 - case text = 2 - case gauge = 3 - - var id: Int { self.rawValue } -} - -struct IndoorAirQuality: View { - var iaq: Int = 0 - var displayMode: IaqDisplayMode = .pill - let gradient = Gradient(colors: [.green, .mint, .yellow, .orange, .red, .purple, .purple, .brown, .brown, .brown, .brown]) - - var body: some View { - let iaqEnum = Iaq.getIaq(for: iaq) - switch displayMode { - case .pill: - ZStack (alignment: .leading) { - RoundedRectangle(cornerRadius: 10) - .fill(iaqEnum.color) - .frame(width: 125, height: 30) - Label("IAQ \(iaq)", systemImage: iaq < 100 ? "aqi.low" : ((iaq > 100 && iaq < 201) ? "aqi.medium" : "aqi.high")) - .padding(.leading, 4) - } - case .dot: - VStack { - HStack { - Text("\(iaq)") - Circle() - .fill(iaqEnum.color) - .frame(width: 10, height: 10) - } - } - case .text: - Text(iaqEnum.description) - .font(.caption) - case .gauge: - Gauge(value: Double(iaq), in: 0...500) { - - Text("IAQ") - .foregroundColor(iaqEnum.color) - } currentValueLabel: { - Text("\(Int(iaq))") - } - .tint(gradient) - .gaugeStyle(.accessoryCircular) - } - } -} - -struct IndoorAirQuality_Previews: PreviewProvider { - static var previews: some View { - VStack { - Text(".pill") - .font(.title) - HStack { - VStack{ - IndoorAirQuality(iaq: 6) - IndoorAirQuality(iaq: 51) - IndoorAirQuality(iaq: 101) - } - VStack { - IndoorAirQuality(iaq: 201) - IndoorAirQuality(iaq: 350) - IndoorAirQuality(iaq: 351) - } - } - Text(".dot") - .font(.title) - HStack { - VStack (alignment: .leading) { - IndoorAirQuality(iaq: 6, displayMode: .dot) - IndoorAirQuality(iaq: 51, displayMode: .dot) - IndoorAirQuality(iaq: 101, displayMode: .dot) - } - VStack (alignment: .leading) { - IndoorAirQuality(iaq: 201, displayMode: .dot) - IndoorAirQuality(iaq: 350, displayMode: .dot) - IndoorAirQuality(iaq: 351, displayMode: .dot) - } - } - Text(".text") - .font(.title) - IndoorAirQuality(iaq: 6, displayMode: .text) - IndoorAirQuality(iaq: 51, displayMode: .text) - IndoorAirQuality(iaq: 101, displayMode: .text) - IndoorAirQuality(iaq: 201, displayMode: .text) - IndoorAirQuality(iaq: 350, displayMode: .text) - IndoorAirQuality(iaq: 351, displayMode: .text) - Text(".gauge") - .font(.title) - HStack (alignment: .top) { - VStack{ - IndoorAirQuality(iaq: 6, displayMode: .gauge) - IndoorAirQuality(iaq: 51, displayMode: .gauge) - IndoorAirQuality(iaq: 101, displayMode: .gauge) - IndoorAirQuality(iaq: 151, displayMode: .gauge) - } - VStack{ - IndoorAirQuality(iaq: 201, displayMode: .gauge) - IndoorAirQuality(iaq: 251, displayMode: .gauge) - IndoorAirQuality(iaq: 301, displayMode: .gauge) - IndoorAirQuality(iaq: 350, displayMode: .gauge) - } - VStack{ - IndoorAirQuality(iaq: 351, displayMode: .gauge) - IndoorAirQuality(iaq: 401, displayMode: .gauge) - IndoorAirQuality(iaq: 500, displayMode: .gauge) - } - } - }.previewLayout(.fixed(width: 300, height: 800)) - } -} diff --git a/Meshtastic/Views/Helpers/Weather/AirQualityIndex.swift b/Meshtastic/Views/Helpers/Weather/AirQualityIndex.swift new file mode 100644 index 00000000..160caae5 --- /dev/null +++ b/Meshtastic/Views/Helpers/Weather/AirQualityIndex.swift @@ -0,0 +1,146 @@ +// +// AQICircleDisplay.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 2/4/23. +// +import SwiftUI + +enum AqiDisplayMode: Int, CaseIterable, Identifiable { + + case pill = 0 + case dot = 1 + case text = 2 + case gauge = 3 + case gradient = 4 + + var id: Int { self.rawValue } +} + +struct AirQualityIndex: View { + var aqi: Int + var displayMode: IaqDisplayMode = .pill + let gradient = Gradient(colors: [.green, .yellow, .orange, .red, .purple, .magenta]) + + var body: some View { + + let aqiEnum = Aqi.getAqi(for: aqi) + switch displayMode { + case .pill: + ZStack (alignment: .leading) { + RoundedRectangle(cornerRadius: 10) + .fill(aqiEnum.color) + .frame(width: 125, height: 30) + Label("IAQ \(aqi)", systemImage: aqi < 100 ? "aqi.low" : ((aqi > 100 && aqi < 201) ? "aqi.medium" : "aqi.high")) + .padding(.leading, 4) + } + case .dot: + VStack { + HStack { + Text("\(aqi)") + Circle() + .fill(aqiEnum.color) + .frame(width: 10, height: 10) + } + } + case .text: + Text(aqiEnum.description) + .font(.caption) + case .gauge: + Gauge(value: Double(aqi), in: 0...500) { + + Text("IAQ") + .foregroundColor(aqiEnum.color) + } currentValueLabel: { + Text("\(Int(aqi))") + } + .tint(gradient) + .gaugeStyle(.accessoryCircular) + case .gradient: + HStack { + Gauge(value: Double(aqi), in: 0...500) { + Text("IAQ") + .foregroundColor(aqiEnum.color) + } currentValueLabel: { + Text("IAQ ")+Text("\(Int(aqi))") + .foregroundColor(.gray) + } + .tint(gradient) + .gaugeStyle(.accessoryLinear) + Text(aqiEnum.description) + .font(.caption) + } + .padding([.leading, .trailing]) + } + } +} + +struct AirQualityIndex_Previews: PreviewProvider { + static var previews: some View { + VStack { + Text(".pill") + .font(.title2) + HStack { + AirQualityIndex(aqi: 6) + AirQualityIndex(aqi: 51) + } + HStack { + AirQualityIndex(aqi: 101) + AirQualityIndex(aqi: 151) + } + HStack { + AirQualityIndex(aqi: 201) + AirQualityIndex(aqi: 351) + } + Text(".dot") + .font(.title2) + HStack { + AirQualityIndex(aqi: 6, displayMode: .dot) + AirQualityIndex(aqi: 51, displayMode: .dot) + AirQualityIndex(aqi: 101, displayMode: .dot) + AirQualityIndex(aqi: 201, displayMode: .dot) + AirQualityIndex(aqi: 350, displayMode: .dot) + AirQualityIndex(aqi: 351, displayMode: .dot) + } + Text(".text") + .font(.title2) + HStack { + AirQualityIndex(aqi: 6, displayMode: .text) + AirQualityIndex(aqi: 51, displayMode: .text) + AirQualityIndex(aqi: 101, displayMode: .text) + } + HStack { + AirQualityIndex(aqi: 201, displayMode: .text) + AirQualityIndex(aqi: 350, displayMode: .text) + } + Text(".gauge") + .font(.title2) + HStack (alignment: .top) { + AirQualityIndex(aqi: 6, displayMode: .gauge) + AirQualityIndex(aqi: 51, displayMode: .gauge) + AirQualityIndex(aqi: 101, displayMode: .gauge) + AirQualityIndex(aqi: 151, displayMode: .gauge) + } + HStack (alignment: .top) { + AirQualityIndex(aqi: 201, displayMode: .gauge) + AirQualityIndex(aqi: 251, displayMode: .gauge) + AirQualityIndex(aqi: 301, displayMode: .gauge) + AirQualityIndex(aqi: 351, displayMode: .gauge) + } + HStack (alignment: .top) { + AirQualityIndex(aqi: 401, displayMode: .gauge) + AirQualityIndex(aqi: 500, displayMode: .gauge) + } + Text(".gradient") + .font(.title2) + AirQualityIndex(aqi: 6, displayMode: .gradient) + AirQualityIndex(aqi: 51, displayMode: .gradient) + AirQualityIndex(aqi: 101, displayMode: .gradient) + AirQualityIndex(aqi: 201, displayMode: .gradient) + AirQualityIndex(aqi: 351, displayMode: .gradient) + AirQualityIndex(aqi: 401, displayMode: .gradient) + AirQualityIndex(aqi: 500, displayMode: .gradient) + + }.previewLayout(.fixed(width: 300, height: 800)) + } +} diff --git a/Meshtastic/Views/Helpers/Weather/AirQualityIndexCompact.swift b/Meshtastic/Views/Helpers/Weather/AirQualityIndexCompact.swift deleted file mode 100644 index 642a0596..00000000 --- a/Meshtastic/Views/Helpers/Weather/AirQualityIndexCompact.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// AQICircleDisplay.swift -// Meshtastic -// -// Copyright(c) Garth Vander Houwen 2/4/23. -// -import SwiftUI - -struct AirQualityIndexCompact: View { - var aqi: Int - - var body: some View { - - HStack(spacing: 0.5) { - Text("AQI \(aqi)") - .foregroundColor(.gray) - .padding(.trailing, 0) - .font(.caption) - - if aqi > 0 && aqi < 51 { - // Good - Circle() - .fill(.green) - .frame(width: 10, height: 10) - } else if aqi > 50 && aqi < 101 { - // Satisfactory - Circle() - .fill(Color(red: 0, green: 0.9882, blue: 0.1804)) - .frame(width: 10, height: 10) - } else if aqi > 100 && aqi < 201 { - // Moderate - Circle() - .fill(.yellow) - .frame(width: 10, height: 10) - } else if aqi > 200 && aqi < 301 { - // Poor - Circle() - .fill(.orange) - .frame(width: 10, height: 10) - - } else if aqi > 300 && aqi < 401 { - // Very Poor - Circle() - .fill(.red) - .frame(width: 10, height: 10) - } else if aqi >= 401 { - // Very Poor - Circle() - .fill(Color(red: 0.8392, green: 0.0667, blue: 0)) - .frame(width: 10, height: 10) - } - } - } -} -struct AQICircleDisplay_Previews: PreviewProvider { - static var previews: some View { - - VStack { - AirQualityIndexCompact(aqi: 5) - AirQualityIndexCompact(aqi: 51) - AirQualityIndexCompact(aqi: 101) - AirQualityIndexCompact(aqi: 201) - AirQualityIndexCompact(aqi: 301) - AirQualityIndexCompact(aqi: 401) - } - } -} diff --git a/Meshtastic/Views/Helpers/Weather/IAQScale.swift b/Meshtastic/Views/Helpers/Weather/IAQScale.swift new file mode 100644 index 00000000..58de0bb8 --- /dev/null +++ b/Meshtastic/Views/Helpers/Weather/IAQScale.swift @@ -0,0 +1,39 @@ +// +// IAQScale.swift +// Meshtastic +// +// Created by Garth Vander Houwen on 4/24/24. +// + +import SwiftUI + +struct IAQScale: View { + + var body: some View { + VStack(alignment:.leading) { + ForEach(Iaq.allCases) { iaq in + HStack { + RoundedRectangle(cornerRadius: 5) + .fill(iaq.color) + .frame(width: 30, height: 20) + Text(iaq.description) + .font(.callout) + } + } + } + .padding() + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) +// .overlay( +// RoundedRectangle(cornerRadius: 20) +// .stroke(.secondary, lineWidth: 5) +// ) + } +} + +struct IAQSCalePreviews: PreviewProvider { + static var previews: some View { + VStack { + IAQScale() + } + } +} diff --git a/Meshtastic/Views/Helpers/Weather/IndoorAirQuality.swift b/Meshtastic/Views/Helpers/Weather/IndoorAirQuality.swift new file mode 100644 index 00000000..5e12aa9a --- /dev/null +++ b/Meshtastic/Views/Helpers/Weather/IndoorAirQuality.swift @@ -0,0 +1,148 @@ +// +// IndoorAirQuality.swift +// Meshtastic +// +// Copyright(c) by Garth Vander Houwen on 4/10/24. +// + +import Foundation +import SwiftUI + +enum IaqDisplayMode: Int, CaseIterable, Identifiable { + + case pill = 0 + case dot = 1 + case text = 2 + case gauge = 3 + case gradient = 4 + + var id: Int { self.rawValue } +} + +struct IndoorAirQuality: View { + var iaq: Int = 0 + var displayMode: IaqDisplayMode = .pill + let gradient = Gradient(colors: [.green, .mint, .yellow, .orange, .red, .purple, .purple, .brown, .brown, .brown, .brown]) + + var body: some View { + let iaqEnum = Iaq.getIaq(for: iaq) + switch displayMode { + case .pill: + ZStack (alignment: .leading) { + RoundedRectangle(cornerRadius: 10) + .fill(iaqEnum.color) + .frame(width: 125, height: 30) + Label("IAQ \(iaq)", systemImage: iaq < 100 ? "aqi.low" : ((iaq > 100 && iaq < 201) ? "aqi.medium" : "aqi.high")) + .padding(.leading, 4) + } + case .dot: + VStack { + HStack { + Text("\(iaq)") + Circle() + .fill(iaqEnum.color) + .frame(width: 10, height: 10) + } + } + case .text: + Text(iaqEnum.description) + .font(.caption) + case .gauge: + Gauge(value: Double(iaq), in: 0...500) { + + Text("IAQ") + .foregroundColor(iaqEnum.color) + } currentValueLabel: { + Text("\(Int(iaq))") + } + .tint(gradient) + .gaugeStyle(.accessoryCircular) + case .gradient: + HStack { + Gauge(value: Double(iaq), in: 0...500) { + Text("IAQ") + .foregroundColor(iaqEnum.color) + } currentValueLabel: { + Text("IAQ ")+Text("\(Int(iaq))") + .foregroundColor(.gray) + } + .tint(gradient) + .gaugeStyle(.accessoryLinear) + Text(iaqEnum.description) + .font(.caption) + } + .padding([.leading, .trailing]) + } + } +} + +struct IndoorAirQuality_Previews: PreviewProvider { + static var previews: some View { + VStack { + Text(".pill") + .font(.title2) + HStack { + IndoorAirQuality(iaq: 6) + IndoorAirQuality(iaq: 51) + } + HStack { + IndoorAirQuality(iaq: 101) + IndoorAirQuality(iaq: 201) + } + HStack { + IndoorAirQuality(iaq: 350) + IndoorAirQuality(iaq: 351) + } + Text(".dot") + .font(.title2) + HStack { + IndoorAirQuality(iaq: 6, displayMode: .dot) + IndoorAirQuality(iaq: 51, displayMode: .dot) + IndoorAirQuality(iaq: 101, displayMode: .dot) + IndoorAirQuality(iaq: 201, displayMode: .dot) + IndoorAirQuality(iaq: 350, displayMode: .dot) + IndoorAirQuality(iaq: 351, displayMode: .dot) + } + Text(".text") + .font(.title2) + HStack { + IndoorAirQuality(iaq: 6, displayMode: .text) + IndoorAirQuality(iaq: 51, displayMode: .text) + IndoorAirQuality(iaq: 101, displayMode: .text) + } + HStack { + IndoorAirQuality(iaq: 201, displayMode: .text) + IndoorAirQuality(iaq: 350, displayMode: .text) + IndoorAirQuality(iaq: 351, displayMode: .text) + } + Text(".gauge") + .font(.title2) + HStack (alignment: .top) { + IndoorAirQuality(iaq: 6, displayMode: .gauge) + IndoorAirQuality(iaq: 51, displayMode: .gauge) + IndoorAirQuality(iaq: 101, displayMode: .gauge) + IndoorAirQuality(iaq: 151, displayMode: .gauge) + } + HStack (alignment: .top) { + IndoorAirQuality(iaq: 201, displayMode: .gauge) + IndoorAirQuality(iaq: 251, displayMode: .gauge) + IndoorAirQuality(iaq: 301, displayMode: .gauge) + IndoorAirQuality(iaq: 351, displayMode: .gauge) + } + HStack (alignment: .top) { + IndoorAirQuality(iaq: 401, displayMode: .gauge) + IndoorAirQuality(iaq: 500, displayMode: .gauge) + } + Text(".gradient") + .font(.title2) + IndoorAirQuality(iaq: 6, displayMode: .gradient) + IndoorAirQuality(iaq: 51, displayMode: .gradient) + IndoorAirQuality(iaq: 101, displayMode: .gradient) + IndoorAirQuality(iaq: 201, displayMode: .gradient) + IndoorAirQuality(iaq: 351, displayMode: .gradient) + IndoorAirQuality(iaq: 401, displayMode: .gradient) + IndoorAirQuality(iaq: 500, displayMode: .gradient) + + }.previewLayout(.fixed(width: 300, height: 800)) + } +} diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index 2c3a83f4..d8a4e96d 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -109,6 +109,24 @@ struct ChannelList: View { Label("Delete Messages", systemImage: "trash") } } + Button { + channel.mute = !channel.mute + + do { + let adminMessageId = bleManager.saveChannel(channel: channel.protoBuf, fromUser: node!.user!, toUser: node!.user!) + if adminMessageId > 0 { + context.refresh(channel, mergeChanges: true) + } + + try context.save() + + } catch { + context.rollback() + print("💥 Save Channel Mute Error") + } + } label: { + Label(channel.mute ? "Show Alerts" : "Hide Alerts", systemImage: channel.mute ? "bell" : "bell.slash") + } } .confirmationDialog( "This conversation will be deleted.", diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 8688eed4..7d7a7962 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -43,16 +43,24 @@ struct ChannelMessageList: View { .padding(.trailing) } } - HStack(alignment: .top) { + HStack(alignment: .bottom) { if currentUser { Spacer(minLength: 50) } if !currentUser { CircleText(text: message.fromUser?.shortName ?? "?", color: Color(UIColor(hex: UInt32(message.fromUser?.num ?? 0))), circleSize: 44) .padding(.all, 5) - .offset(y: -5) + .offset(y: -7) } + VStack(alignment: currentUser ? .trailing : .leading) { let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) + if !currentUser && message.fromUser != nil { + Text("\(message.fromUser?.longName ?? "unknown".localized ) (\(message.fromUser?.userId ?? "?"))") + .font(.caption) + .foregroundColor(.gray) + .offset(y: 8) + } + HStack { MessageText( message: message, @@ -67,7 +75,7 @@ struct ChannelMessageList: View { RetryButton(message: message, destination: .channel(channel)) } } - + TapbackResponses(message: message) { appState.unreadChannelMessages = myInfo.unreadMessages UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages diff --git a/Meshtastic/Views/Settings/Config/DeviceConfig.swift b/Meshtastic/Views/Settings/Config/DeviceConfig.swift index 1d438cbe..79ccc933 100644 --- a/Meshtastic/Views/Settings/Config/DeviceConfig.swift +++ b/Meshtastic/Views/Settings/Config/DeviceConfig.swift @@ -25,6 +25,7 @@ struct DeviceConfig: View { @State var rebroadcastMode = 0 @State var nodeInfoBroadcastSecs = 10800 @State var doubleTapAsButtonPress = false + @State var ledHeartbeatEnabled = true @State var isManaged = false @State var tzdef = "" @@ -58,6 +59,12 @@ struct DeviceConfig: View { } .pickerStyle(DefaultPickerStyle()) + Toggle(isOn: $isManaged) { + Label("Managed Device", systemImage: "gearshape.arrow.triangle.2.circlepath") + Text("Enabling Managed mode will restrict access to all radio configurations, such as short/long names, regions, channels, modules, etc. and will only be accessible through the Admin channel. To avoid being locked out, make sure the Admin channel is working properly before enabling it.") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + Picker("Node Info Broadcast Interval", selection: $nodeInfoBroadcastSecs ) { ForEach(UpdateIntervals.allCases) { ui in if ui.rawValue >= 3600 { @@ -66,15 +73,18 @@ struct DeviceConfig: View { } } .pickerStyle(DefaultPickerStyle()) + } + Section(header: Text("Hardware")) { + Toggle(isOn: $doubleTapAsButtonPress) { Label("Double Tap as Button", systemImage: "hand.tap") Text("Treat double tap on supported accelerometers as a user button press.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - Toggle(isOn: $isManaged) { - Label("Managed Device", systemImage: "gearshape.arrow.triangle.2.circlepath") - Text("Enabling Managed mode will restrict access to all radio configurations, such as short/long names, regions, channels, modules, etc. and will only be accessible through the Admin channel. To avoid being locked out, make sure the Admin channel is working properly before enabling it.") + Toggle(isOn: $ledHeartbeatEnabled) { + Label("LED Heartbeat", systemImage: "waveform.path.ecg") + Text("Controls the blinking LED on the device. For most devices this will control one of the up to 4 LEDS, the charger and GPS LEDs are not controllable.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } @@ -202,6 +212,7 @@ struct DeviceConfig: View { dc.doubleTapAsButtonPress = doubleTapAsButtonPress dc.isManaged = isManaged dc.tzdef = tzdef + dc.ledHeartbeatDisabled = !ledHeartbeatEnabled if isManaged { serialEnabled = false debugLogEnabled = false @@ -300,6 +311,7 @@ struct DeviceConfig: View { nodeInfoBroadcastSecs = 3600 } self.doubleTapAsButtonPress = node?.deviceConfig?.doubleTapAsButtonPress ?? false + self.ledHeartbeatEnabled = node?.deviceConfig?.ledHeartbeatEnabled ?? true self.isManaged = node?.deviceConfig?.isManaged ?? false if self.tzdef.isEmpty { self.tzdef = TimeZone.current.posixDescription diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index a6931aff..58beac1c 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -398,21 +398,20 @@ struct MQTTConfig: View { if !stateTopic.isEmpty { nearbyTopics.append(stateTopic) } - let countyTopic = defaultTopic + "/" + (placemark.subAdministrativeArea?.lowercased().replacingOccurrences(of: " ", with: "") ?? "") + let countyTopic = defaultTopic + "/" + (placemark.administrativeArea ?? "") + "/" + (placemark.subAdministrativeArea?.lowercased().replacingOccurrences(of: " ", with: "") ?? "") if !countyTopic.isEmpty { nearbyTopics.append(countyTopic) } - let cityTopic = defaultTopic + "/" + (placemark.locality?.lowercased().replacingOccurrences(of: " ", with: "") ?? "") + let cityTopic = defaultTopic + "/" + (placemark.administrativeArea ?? "") + "/" + (placemark.locality?.lowercased().replacingOccurrences(of: " ", with: "") ?? "") if !cityTopic.isEmpty { nearbyTopics.append(cityTopic) } - let neightborhoodTopic = defaultTopic + "/" + (placemark.subLocality?.lowercased() + let neightborhoodTopic = defaultTopic + "/" + (placemark.administrativeArea ?? "") + "/" + (placemark.subLocality?.lowercased() .replacingOccurrences(of: " ", with: "") .replacingOccurrences(of: "'", with: "") ?? "") if !neightborhoodTopic.isEmpty { nearbyTopics.append(neightborhoodTopic) } - } else { diff --git a/Meshtastic/Views/Settings/Firmware.swift b/Meshtastic/Views/Settings/Firmware.swift index 6c442bdc..687b945c 100644 --- a/Meshtastic/Views/Settings/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware.swift @@ -12,7 +12,7 @@ struct Firmware: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager var node: NodeInfoEntity? - @State var minimumVersion = "2.3.5" + @State var minimumVersion = "2.3.7" @State var version = "" @State private var currentDevice: DeviceHardware? @State private var latestStable: FirmwareRelease? diff --git a/Settings.bundle/Root.plist b/Settings.bundle/Root.plist index 6d3b6588..7fb7c0cc 100644 --- a/Settings.bundle/Root.plist +++ b/Settings.bundle/Root.plist @@ -6,6 +6,16 @@ Root PreferenceSpecifiers + + Type + PSTitleValueSpecifier + DefaultValue + + Title + Will share your phone GPS location with your node. + Key + shareLocationTitle + Type PSToggleSwitchSpecifier @@ -20,7 +30,7 @@ Type PSMultiValueSpecifier Title - Share Location Interval + Interval Key provideLocationInterval Values @@ -78,9 +88,9 @@ Type PSToggleSwitchSpecifier Title - Low Battery + Channel Messages Key - lowBatteryNotifications + channelMessageNotifications DefaultValue @@ -94,6 +104,16 @@ DefaultValue + + Type + PSToggleSwitchSpecifier + Title + Low Battery + Key + lowBatteryNotifications + DefaultValue + + diff --git a/protobufs b/protobufs deleted file mode 160000 index 86640f20..00000000 --- a/protobufs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 86640f20db7b9b5be42949d18e8d96ad10d47a68