Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 61 additions & 27 deletions Meshtastic/Services/DiscoveryScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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..<end] {
// A device switch can reset the container mid-scan; the session/result entities die
// with it. Stop revealing rather than trap on the next relationship write.
guard session.modelContext != nil, result.modelContext != nil else { break }
let end = min(index + batchSize, snapshots.count)
for snapshot in snapshots[index..<end] {
let dn = DiscoveredNodeEntity()
dn.nodeNum = node.num
dn.shortName = node.user?.shortName ?? ""
dn.longName = node.user?.longName ?? ""
let hops = Int(node.hopsAway)
dn.hopCount = hops
dn.neighborType = hops <= 1 ? "direct" : "mesh"
dn.snr = node.snr
dn.rssi = Int(node.rssi)
// Infrastructure roles: Router (2), Router Late (11), Client Base (12)
dn.isInfrastructure = [2, 11, 12].contains(Int(node.user?.role ?? 0))
if let pos = node.positions.last {
dn.latitude = pos.latitude ?? 0.0
dn.longitude = pos.longitude ?? 0.0
if userLat != 0.0 || userLon != 0.0, dn.latitude != 0.0 || dn.longitude != 0.0 {
let userLocation = CLLocation(latitude: userLat, longitude: userLon)
let nodeLocation = CLLocation(latitude: dn.latitude, longitude: dn.longitude)
dn.distanceFromUser = userLocation.distance(from: nodeLocation)
}
dn.nodeNum = snapshot.nodeNum
dn.shortName = snapshot.shortName
dn.longName = snapshot.longName
dn.hopCount = snapshot.hopCount
dn.neighborType = snapshot.hopCount <= 1 ? "direct" : "mesh"
dn.snr = snapshot.snr
dn.rssi = snapshot.rssi
dn.isInfrastructure = snapshot.isInfrastructure
dn.latitude = snapshot.latitude
dn.longitude = snapshot.longitude
if userLat != 0.0 || userLon != 0.0, snapshot.latitude != 0.0 || snapshot.longitude != 0.0 {
let userLocation = CLLocation(latitude: userLat, longitude: userLon)
let nodeLocation = CLLocation(latitude: snapshot.latitude, longitude: snapshot.longitude)
dn.distanceFromUser = userLocation.distance(from: nodeLocation)
}
dn.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).
dn.sensorPacketCount = node.telemetries.filter { $0.metricsType == 1 || $0.metricsType == 3 }.count
dn.messageCount = snapshot.messageCount
dn.sensorPacketCount = snapshot.sensorPacketCount
dn.presetName = presetName
dn.session = session
dn.presetResult = result
Expand All @@ -1222,7 +1256,7 @@ extension DiscoveryScanEngine {
seeded += 1
}
index = end
if index < candidates.count {
if index < snapshots.count {
try? await Task.sleep(for: .seconds(tick))
}
}
Expand Down
91 changes: 66 additions & 25 deletions Meshtastic/Views/Nodes/MeshMapMK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,36 @@ struct MeshMapMK: View {
}
}

@Query(filter: #Predicate<PositionEntity> { $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<PositionEntity>(
predicate: #Predicate<PositionEntity> { $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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
syncFallbackLocation()
decodeOfflineIfVisible()
}

/// Enabled saved routes drawn as polylines + start/finish markers (parity with the old map).
@Query(filter: #Predicate<RouteEntity> { $0.enabled == true }, sort: \RouteEntity.name)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -486,7 +512,6 @@ struct MeshMapMK: View {
}

var body: some View {
let positionState = visiblePositionState
NavigationStack {
ZStack {
mapWithSheets
Expand Down Expand Up @@ -514,27 +539,37 @@ 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()
}
.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()
}
.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 }
Expand Down Expand Up @@ -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
Expand All @@ -583,7 +618,7 @@ struct MeshMapMK: View {
syncFallbackLocation()
refreshMapWindowOpenState()
UIApplication.shared.isIdleTimerDisabled = true
refreshVisiblePositionSnapshots()
refreshPositionState(force: true)
applyTraceRouteSelection()
consumeCoverageEstimateState()
} else {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
63 changes: 36 additions & 27 deletions Meshtastic/Views/Nodes/NodeList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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<NodeInfoEntity> { 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<NodeInfoEntity> {
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<NodeInfoEntity>(
predicate: #Predicate<NodeInfoEntity> { node in
// Ignored filter (always applied)
(showIgnored || !node.ignored) &&
(!showIgnored || node.ignored) &&
Expand All @@ -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
Expand Down Expand Up @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
.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
Expand Down
Loading
Loading