diff --git a/Meshtastic/Services/DiscoveryScanEngine.swift b/Meshtastic/Services/DiscoveryScanEngine.swift index 26c95444f..71b7b09b8 100644 --- a/Meshtastic/Services/DiscoveryScanEngine.swift +++ b/Meshtastic/Services/DiscoveryScanEngine.swift @@ -1176,43 +1176,77 @@ extension DiscoveryScanEngine { let userLat = session.userLatitude let userLon = session.userLongitude + // Snapshot every value the reveal needs in one synchronous pass, BEFORE the timed loop. + // The loop below awaits between batches for most of the dwell; live ingestion keeps + // mutating the store the whole time, and touching a `NodeInfoEntity`/`PositionEntity` + // whose row was pruned mid-reveal traps in SwiftData's persisted-property accessor + // (reproduced under a 100 pkt/s TCP stress replay). Plain values can't be invalidated. + struct SeededNodeSnapshot { + let nodeNum: Int64 + let shortName: String + let longName: String + let hopCount: Int + let snr: Float + let rssi: Int + let isInfrastructure: Bool + let latitude: Double + let longitude: Double + let messageCount: Int + let sensorPacketCount: Int + } + let snapshots: [SeededNodeSnapshot] = candidates.map { node in + SeededNodeSnapshot( + nodeNum: node.num, + shortName: node.user?.shortName ?? "", + longName: node.user?.longName ?? "", + hopCount: Int(node.hopsAway), + snr: node.snr, + rssi: Int(node.rssi), + // Infrastructure roles: Router (2), Router Late (11), Client Base (12) + isInfrastructure: [2, 11, 12].contains(Int(node.user?.role ?? 0)), + latitude: node.latestPosition?.latitude ?? 0.0, + longitude: node.latestPosition?.longitude ?? 0.0, + messageCount: messageCounts[node.num] ?? 0, + // Sensor packets ≈ environment (1) + air-quality (3) telemetry the node has reported, + // matching the live scan's sensorPacketCount (.environmentMetrics + .airQualityMetrics). + sensorPacketCount: node.telemetries.filter { $0.metricsType == 1 || $0.metricsType == 3 }.count + ) + } + // Spread the reveal across ~85% of the dwell (so it finishes before finalize), in batches // over ~120 ticks for a smooth, accelerated fill regardless of mesh size. let revealWindow = max(2.0, dwellDuration * 0.85) let ticks = 120.0 let tick = max(0.08, revealWindow / ticks) - let batchSize = max(1, Int((Double(candidates.count) / ticks).rounded(.up))) + let batchSize = max(1, Int((Double(snapshots.count) / ticks).rounded(.up))) var index = 0 var seeded = 0 - while index < candidates.count { + while index < snapshots.count { if Task.isCancelled { break } - let end = min(index + batchSize, candidates.count) - for node in candidates[index.. { $0.nodePosition != nil && $0.latest == true && $0.nodePosition?.ignored != true }) - private var allLatestPositions: [PositionEntity] + /// Latest positions, refreshed on a throttled cadence (see the `.task` in `body`) instead of a + /// live `@Query`. Under heavy ingestion (large mesh, TCP replay, reconnect node-DB dump) a live + /// query invalidates `body` on every position write, and each evaluation re-ran the + /// filter + density sort over the whole set — pegging the main thread at ~100% CPU. A ~2s + /// refresh is imperceptible on a map while pan/zoom/filter changes still refresh immediately. + @State private var allLatestPositions: [PositionEntity] = [] + /// Change-detection key of the last applied position state; skips snapshot/overlay rebuilds + /// when a periodic refresh finds nothing visibly different. + @State private var appliedPositionStateKey: Int64 = .min + + private func fetchLatestPositions() -> [PositionEntity] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.nodePosition != nil && $0.latest == true && $0.nodePosition?.ignored != true } + ) + return (try? context.fetch(descriptor)) ?? [] + } + + /// Re-fetch positions, rebuild the derived state, and apply it when it actually changed. + /// `force` bypasses the change-detection key (used when visibility flips, so snapshots are + /// rebuilt after being dropped even though the underlying positions are unchanged). + @MainActor + private func refreshPositionState(force: Bool = false) { + allLatestPositions = fetchLatestPositions() + let state = visiblePositionState + guard force || state.key != appliedPositionStateKey else { return } + appliedPositionStateKey = state.key + refreshVisiblePositionSnapshots(from: state.positions) + syncFallbackLocation() + decodeOfflineIfVisible() + } /// Enabled saved routes drawn as polylines + start/finish markers (parity with the old map). @Query(filter: #Predicate { $0.enabled == true }, sort: \RouteEntity.name) @@ -216,17 +244,15 @@ struct MeshMapMK: View { combine(&key, Int64((visibleRegion.span.longitudeDelta * 10_000).rounded(.towardZero))) } combine(&key, filterRefreshKey) - for position in positions.prefix(64) { + // Hash EVERY visible position: since the throttled refresh, this key is the only gate for + // snapshot/overlay rebuilds, and a truncated sample would leave pins beyond it permanently + // stale when a node moves without changing the set's count or ordering. + for position in positions { combine(&key, Int64(truncatingIfNeeded: position.persistentModelID.hashValue)) combine(&key, Int64(position.latitudeI)) combine(&key, Int64(position.longitudeI)) combine(&key, Int64(position.precisionBits)) } - if let last = positions.last { - combine(&key, Int64(truncatingIfNeeded: last.persistentModelID.hashValue)) - combine(&key, Int64(last.latitudeI)) - combine(&key, Int64(last.longitudeI)) - } return MeshMapVisiblePositionState(positions: positions, key: key) } @@ -486,7 +512,6 @@ struct MeshMapMK: View { } var body: some View { - let positionState = visiblePositionState NavigationStack { ZStack { mapWithSheets @@ -514,10 +539,14 @@ struct MeshMapMK: View { } .toolbarBackground(.hidden, for: .navigationBar) } - .onChange(of: positionState.key) { - refreshVisiblePositionSnapshots(from: positionState.positions) - syncFallbackLocation() - decodeOfflineIfVisible() + .task(id: isMapVisible) { + // Throttled position refresh: re-derive the visible positions on a gentle + // cadence instead of on every SwiftData write (see `allLatestPositions`). + guard isMapVisible else { return } + while !Task.isCancelled { + refreshPositionState() + try? await Task.sleep(for: .seconds(2)) + } } .onChange(of: offlineMapManager.regions) { reloadOfflineSource() @@ -525,8 +554,14 @@ struct MeshMapMK: View { .onChange(of: overlayInputsKey) { rebuildAllMapContent() } - .onChange(of: allLatestPositions) { - syncFallbackLocation() + .onChange(of: filterRefreshKey) { + // Filter/search edits should reflect immediately, not on the next tick. + refreshPositionState(force: true) + } + .onChange(of: regionRefreshKey) { + // Pan/zoom settled: re-filter to the new region now; the state key dedupes + // the rebuild when the visible set is unchanged. + refreshPositionState() } .onChange(of: accessoryManager.activeDeviceNum) { syncFallbackLocation() @@ -534,7 +569,7 @@ struct MeshMapMK: View { .onChange(of: accessoryManager.isInBackground) { // Foreground/background flips isMapVisible; refresh so the overlay-bearing // snapshots are dropped when backgrounded and rebuilt when foregrounded. - refreshVisiblePositionSnapshots(from: positionState.positions) + refreshPositionState(force: true) } .onChange(of: coverageRunner.importedResult) { _, result in guard let result else { return } @@ -571,7 +606,7 @@ struct MeshMapMK: View { case .offline: mapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) } - refreshVisiblePositionSnapshots(from: positionState.positions) + refreshPositionState(force: true) } .onDisappear(perform: { UIApplication.shared.isIdleTimerDisabled = false @@ -583,7 +618,7 @@ struct MeshMapMK: View { syncFallbackLocation() refreshMapWindowOpenState() UIApplication.shared.isIdleTimerDisabled = true - refreshVisiblePositionSnapshots() + refreshPositionState(force: true) applyTraceRouteSelection() consumeCoverageEstimateState() } else { @@ -718,6 +753,19 @@ struct MeshMapMK: View { isMapWindowOpen = attachedScenes.count > 1 } + /// Quantized camera key so `.onChange` can react to pan/zoom settles even though + /// `MKCoordinateRegion` isn't `Equatable`. Same quantization the position-state key uses. + private var regionRefreshKey: Int64 { + var key: Int64 = 0 + if let visibleRegion { + combine(&key, Int64((visibleRegion.center.latitude * 10_000).rounded(.towardZero))) + combine(&key, Int64((visibleRegion.center.longitude * 10_000).rounded(.towardZero))) + combine(&key, Int64((visibleRegion.span.latitudeDelta * 10_000).rounded(.towardZero))) + combine(&key, Int64((visibleRegion.span.longitudeDelta * 10_000).rounded(.towardZero))) + } + return key + } + private var filterRefreshKey: Int64 { var key: Int64 = 0 combine(&key, preciseLocationsOnly ? 1 : 0) @@ -739,13 +787,6 @@ struct MeshMapMK: View { return key } - private func refreshVisiblePositionSnapshots() { - visiblePositionSnapshots = makePositionSnapshots(from: visiblePositions) - spreadOverrides = computeSpreadOverrides(visiblePositionSnapshots) - frameInitialRegionIfNeeded() - rebuildOverlays() - } - private func refreshVisiblePositionSnapshots(from positions: [PositionEntity]) { visiblePositionSnapshots = makePositionSnapshots(from: positions) spreadOverrides = computeSpreadOverrides(visiblePositionSnapshots) diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 088728fa3..0e26a3c53 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -213,8 +213,6 @@ private struct NodeListEntry: Identifiable { private struct FilteredNodeList: View { @EnvironmentObject var accessoryManager: AccessoryManager @EnvironmentObject var router: Router - @Query(sort: \NodeInfoEntity.lastHeard, order: .reverse) - private var allNodes: [NodeInfoEntity] @Environment(\.modelContext) private var context /// Throttled snapshot of the filtered/sorted nodes actually shown. Recomputed on a gentle /// cadence (see `.task`) instead of in `body`, so the full-node-set scan in `displayNodes` @@ -248,18 +246,25 @@ private struct FilteredNodeList: View { self._nodeForDisplayNameEdit = nodeForDisplayNameEdit self._nodeListDensity = nodeListDensity self._selectedNodeNum = selectedNodeNum + } - // Push simple filters into the SwiftData predicate to reduce in-memory work - let showIgnored = withFilters.isIgnored - let showFavorite = withFilters.isFavorite - let filterViaLoraOnly = withFilters.viaLora && !withFilters.viaMqtt - let filterViaMqttOnly = !withFilters.viaLora && withFilters.viaMqtt - let filterHopsDirect = withFilters.hopsAway == 0.0 - let filterHopsMax = withFilters.hopsAway > 0.0 - let maxHops = Int32(withFilters.hopsAway) - - _allNodes = Query( - filter: #Predicate { node in + /// Fetch descriptor for the node set, built from the live shared `filters` at fetch time so + /// predicate-level filter edits apply on the next refresh tick. Applied via explicit fetches + /// on a throttled cadence (see `.task`) rather than a live `@Query` — a live query + /// re-evaluated `body` on every SwiftData write, and under heavy ingestion that meant a + /// full-node-set scan per packet, even while the Nodes tab was off-screen (TabView keeps + /// tabs alive). Simple filters are pushed into the predicate to reduce in-memory work. + private func makeNodeFetchDescriptor() -> FetchDescriptor { + let showIgnored = filters.isIgnored + let showFavorite = filters.isFavorite + let filterViaLoraOnly = filters.viaLora && !filters.viaMqtt + let filterViaMqttOnly = !filters.viaLora && filters.viaMqtt + let filterHopsDirect = filters.hopsAway == 0.0 + let filterHopsMax = filters.hopsAway > 0.0 + let maxHops = Int32(filters.hopsAway) + + return FetchDescriptor( + predicate: #Predicate { node in // Ignored filter (always applied) (showIgnored || !node.ignored) && (!showIgnored || node.ignored) && @@ -274,12 +279,11 @@ private struct FilteredNodeList: View { // Hops within range (!filterHopsMax || (node.hopsAway > 0 && node.hopsAway <= maxHops)) }, - sort: \NodeInfoEntity.lastHeard, - order: .reverse + sortBy: [SortDescriptor(\NodeInfoEntity.lastHeard, order: .reverse)] ) } - private func displayNodes(activeNodeNum: Int64?) -> [NodeListEntry] { + private func displayNodes(from allNodes: [NodeInfoEntity], activeNodeNum: Int64?) -> [NodeListEntry] { let searchText = filters.searchText.lowercased() let onlineThreshold = filters.isOnline ? Date().addingTimeInterval(-7_200) : nil let distanceBounds = filters.currentDistanceBounds @@ -366,22 +370,27 @@ private struct FilteredNodeList: View { } } .navigationTitle(String.localizedStringWithFormat("Nodes (%@)".localized, String(displayedNodes.count))) - .task { + .task(id: router.selectedTab) { // Recompute the displayed list on a gentle cadence instead of inside `body`. - // During live ingestion the node @Query invalidates on every packet; running - // displayNodes (a scan over the whole node set) per write pegged the main thread - // on reconnect with a large DB. ~3/sec is imperceptible and keeps CPU sane. + // During live ingestion every packet writes to SwiftData; running displayNodes + // (a scan over the whole node set) per write pegged the main thread on reconnect + // with a large DB. ~3/sec is imperceptible and keeps CPU sane. The scan only runs + // while the Nodes tab is frontmost — TabView keeps this view alive on other tabs + // (and this task re-fires on every tab switch), so the guard comes first; entering + // the tab re-fires the task and refreshes immediately. + guard router.selectedTab == .nodes else { return } + refreshDisplayedNodes() while !Task.isCancelled { - replaceDisplayedNodesIfNeeded(with: displayNodes(activeNodeNum: accessoryManager.activeDeviceNum)) try? await Task.sleep(for: .milliseconds(350)) + refreshDisplayedNodes() } } - .onAppear { - router.updateNodeIndex(from: allNodes) - } - .onChange(of: allNodes.count) { _, _ in - router.updateNodeIndex(from: allNodes) - } + } + + private func refreshDisplayedNodes() { + let allNodes = (try? context.fetch(makeNodeFetchDescriptor())) ?? [] + replaceDisplayedNodesIfNeeded(with: displayNodes(from: allNodes, activeNodeNum: accessoryManager.activeDeviceNum)) + router.updateNodeIndex(from: allNodes) } @ViewBuilder diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 9f8c9c52d..894e32ed5 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -15,8 +15,16 @@ struct Settings: View { @Environment(\.modelContext) private var context @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var accessoryManager: AccessoryManager - @Query(sort: \NodeInfoEntity.lastHeard, order: .reverse) - private var nodes: [NodeInfoEntity] + /// Node list for the admin picker / config gating, refreshed on a throttled cadence (see the + /// `.task` in `body`) instead of a live `@Query`. A live query re-evaluated this whole view's + /// `body` on every node write — and TabView keeps Settings alive on other tabs, so under heavy + /// ingestion (large mesh, TCP replay) it burned main-thread CPU while completely off-screen. + @State private var nodes: [NodeInfoEntity] = [] + + private func refreshNodes() { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\NodeInfoEntity.lastHeard, order: .reverse)]) + nodes = (try? context.fetch(descriptor)) ?? [] + } /// Nodes for the admin / configuration picker, ordered favorites-first while /// preserving the `@Query`'s `lastHeard`-descending order within each group. The @@ -755,11 +763,24 @@ struct Settings: View { // If the selection hasn't be initialized yet, try to initalize it. // If we are not fully connected yet, then setSelectedNode will // not select the node and it will remain 0 + refreshNodes() if self.preferredNodeNum <= 0 { self.preferredNodeNum = UserDefaults.preferredPeripheralNum setSelectedNode(to: UserDefaults.preferredPeripheralNum) } } + .task(id: router.selectedTab) { + // Refresh the node snapshot on a gentle cadence, and only while Settings is + // the frontmost tab — the stale snapshot is invisible from other tabs, this + // task re-fires on every tab switch (so the guard comes first), and switching + // here restarts it for an immediate refresh. + guard router.selectedTab == .settings else { return } + refreshNodes() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + refreshNodes() + } + } .navigationTitle("Settings") .toolbar { ToolbarItem(placement: .topBarLeading) {