diff --git a/Localizable.xcstrings b/Localizable.xcstrings index eec7ee0d..c0599a50 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1378,6 +1378,12 @@ } } } + }, + "0" : { + + }, + "1" : { + }, "1 byte" : { "localizations" : { @@ -1652,6 +1658,9 @@ } } } + }, + "180" : { + }, "256 bit" : { "localizations" : { @@ -2459,6 +2468,28 @@ } } }, + "After %lld Days" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "After %lld Day" + } + }, + "other" : { + "stringUnit" : { + "state" : "new", + "value" : "After %lld Days" + } + } + } + } + } + } + }, "After config values save the node will reboot." : { "localizations" : { "de" : { @@ -6379,6 +6410,9 @@ } } } + }, + "Clear Stale Nodes" : { + }, "Client" : { "localizations" : { diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 050958e0..d76ffcfa 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -36,6 +36,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate var timeoutTimer: Timer? var timeoutTimerCount = 0 var positionTimer: Timer? + var maintenenceTimer: Timer? let mqttManager = MqttClientProxyManager.shared var wantRangeTestPackets = false var wantStoreAndForwardPackets = false @@ -52,6 +53,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let FROMNUM_UUID = CBUUID(string: "0xED9DA18C-A800-4F66-A670-AA7547E34453") let LEGACY_LOGRADIO_UUID = CBUUID(string: "0x6C6FD238-78FA-436B-AACF-15C5BE1EF2E2") let LOGRADIO_UUID = CBUUID(string: "0x5a3d6e49-06e6-4423-9944-e9de8cdf9547") + @AppStorage("purgeStaleNodeDays") var purgeStaleNodeDays: Double = 0 // MARK: init private override init() { @@ -68,13 +70,17 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } private init(appState: AppState, context: NSManagedObjectContext) { - self.appState = appState - self.context = context - self.lastConnectionError = "" - self.connectedVersion = "0.0.0" - super.init() - centralManager = CBCentralManager(delegate: self, queue: nil) - mqttManager.delegate = self + self.appState = appState + self.context = context + self.lastConnectionError = "" + self.connectedVersion = "0.0.0" + super.init() + centralManager = CBCentralManager(delegate: self, queue: nil) + mqttManager.delegate = self + // Run clearStaleNodes every 10 minutes + maintenenceTimer = Timer.scheduledTimer(withTimeInterval: 600, repeats: true, block: { _ in + clearStaleNodes(nodeExpireDays: Int(self.purgeStaleNodeDays), context: self.context) + }) } // MARK: Scanning for BLE Devices diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index fe290aa0..6149b582 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -292,9 +292,13 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje newTelemetries.append(telemetry) newNode.telemetries? = NSOrderedSet(array: newTelemetries) } - - newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) - newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) + if nodeInfo.lastHeard > 0 { + newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) + newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) + } else { + newNode.firstHeard = Date() + newNode.lastHeard = Date() + } newNode.snr = nodeInfo.snr if nodeInfo.hasUser { diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 53da7355..ac81a25f 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -8,6 +8,38 @@ import CoreData import MeshtasticProtobufs import OSLog +public func clearStaleNodes(nodeExpireDays: Int, context: NSManagedObjectContext) { + var nodeExpireTime: TimeInterval { + return TimeInterval(-nodeExpireDays * 86400) + } + + if nodeExpireDays == 0 { + // Purge Disabled + Logger.data.info("💾 [NodeInfoEntity] Skip clearing stale nodes") + return + } + let fetchRequest = NSFetchRequest(entityName: "NodeInfoEntity") + fetchRequest.predicate = NSPredicate(format: "lastHeard < %@ and favorite == false and ignored == false", + NSDate(timeIntervalSinceNow: nodeExpireTime)) + + let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + batchDeleteRequest.resultType = .resultTypeCount + + do { + Logger.data.info("💾 [NodeInfoEntity] Clearing nodes older than \(nodeExpireDays) days") + if let batchDeleteResult = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult { + try context.save() + let deletedNodes = batchDeleteResult.result as? Int ?? 0 + Logger.data.info("💾 [NodeInfoEntity] Cleared \(deletedNodes) stale nodes") + } else { + Logger.data.error("💥 [NodeInfoEntity] bad delete results") + } + } catch { + context.rollback() + Logger.data.error("💥 [NodeInfoEntity] Error deleting stale nodes") + } +} + public func clearPax(destNum: Int64, context: NSManagedObjectContext) -> Bool { let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index e16fb31b..c64e049c 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -11,6 +11,8 @@ struct AppSettings: View { @State var totalDownloadedTileSize = "" @State private var isPresentingCoreDataResetConfirm = false @State private var isPresentingDeleteMapTilesConfirm = false + @State private var purgeStaleNodes: Bool = false + @AppStorage("purgeStaleNodeDays") private var purgeStaleNodeDays: Double = 0 @AppStorage("environmentEnableWeatherKit") private var environmentEnableWeatherKit: Bool = true @AppStorage("enableAdministration") private var enableAdministration: Bool = false var body: some View { @@ -40,6 +42,36 @@ struct AppSettings: View { } } Section(header: Text("App Data")) { + Toggle(isOn: $purgeStaleNodes ) { + Label { + Text("Clear Stale Nodes") + } icon: { + Image(systemName: "list.bullet.circle") + } + } + .onFirstAppear { + purgeStaleNodes = purgeStaleNodeDays > 0 + Logger.services.info("ℹ️ Purge Stale Nodes toggle initialized to \(purgeStaleNodes)") + } + .onChange(of: purgeStaleNodes) { _, newValue in + purgeStaleNodeDays = purgeStaleNodeDays > 0 ? purgeStaleNodeDays : 7 + purgeStaleNodeDays = newValue ? purgeStaleNodeDays : 0 + Logger.services.info("ℹ️ Purge Stale Nodes changed to \(purgeStaleNodeDays)") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + .listRowSeparator(purgeStaleNodes ? .hidden : .visible) + if purgeStaleNodes { + VStack(alignment: .leading) { + Text(String(localized: "After \(Int(purgeStaleNodeDays)) Days")) + Slider(value: $purgeStaleNodeDays, in: 1...180, step: 1) { + } minimumValueLabel: { + Text("1") + } maximumValueLabel: { + Text("180") + } + } + } Button { isPresentingCoreDataResetConfirm = true } label: {