diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index cb5bbad0..ea5f4b1a 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -33,373 +33,371 @@ struct NodeDetail: View { var columnVisibility = NavigationSplitViewVisibility.all var body: some View { - NavigationStack { - List { - let connectedNode = getNodeInfo( - id: bleManager.connectedPeripheral?.num ?? -1, - context: context - ) + List { + let connectedNode = getNodeInfo( + id: bleManager.connectedPeripheral?.num ?? -1, + context: context + ) - Section("Hardware") { - NodeInfoItem(node: node) + Section("Hardware") { + NodeInfoItem(node: node) + } + + Section("Node") { + HStack { + Label { + Text("Node Number") + } icon: { + Image(systemName: "number") + .symbolRenderingMode(.hierarchical) + } + Spacer() + Text(String(node.num)) + .textSelection(.enabled) } - Section("Node") { - HStack { - Label { - Text("Node Number") - } icon: { - Image(systemName: "number") - .symbolRenderingMode(.hierarchical) - } - Spacer() - Text(String(node.num)) - .textSelection(.enabled) + HStack { + Label { + Text("User Id") + } icon: { + Image(systemName: "person") + .symbolRenderingMode(.multicolor) } + Spacer() + Text(node.user?.userId ?? "?") + .textSelection(.enabled) + } + if let metadata = node.metadata { HStack { Label { - Text("User Id") + Text("firmware.version") } icon: { - Image(systemName: "person") + Image(systemName: "memorychip") .symbolRenderingMode(.multicolor) } Spacer() - Text(node.user?.userId ?? "?") + Text(metadata.firmwareVersion ?? "unknown".localized) + } + } + + if let role = node.user?.role, let deviceRole = DeviceRoles(rawValue: Int(role)) { + HStack { + Label { + Text("Role") + } icon: { + Image(systemName: deviceRole.systemName) + .symbolRenderingMode(.multicolor) + } + Spacer() + Text(deviceRole.name) + } + } + + if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, dm.uptimeSeconds > 0 { + HStack { + Label { + Text("\("uptime".localized)") + } icon: { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .symbolRenderingMode(.hierarchical) + } + Spacer() + + let now = Date.now + let later = now + TimeInterval(dm.uptimeSeconds) + let uptime = (now.. 0 { + HStack { + Label { + Text("First heard") + } icon: { + Image(systemName: "clock") + .symbolRenderingMode(.multicolor) } - } - - if let role = node.user?.role, let deviceRole = DeviceRoles(rawValue: Int(role)) { - HStack { - Label { - Text("Role") - } icon: { - Image(systemName: deviceRole.systemName) - .symbolRenderingMode(.multicolor) - } - Spacer() - Text(deviceRole.name) - } - } - - if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, dm.uptimeSeconds > 0 { - HStack { - Label { - Text("\("uptime".localized)") - } icon: { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .symbolRenderingMode(.hierarchical) - } - Spacer() - - let now = Date.now - let later = now + TimeInterval(dm.uptimeSeconds) - let uptime = (now.. 0 { - HStack { - Label { - Text("First heard") - } icon: { - Image(systemName: "clock") - .symbolRenderingMode(.multicolor) - } - Spacer() - if dateFormatRelative, let text = Self.relativeFormatter.string(for: firstHeard) { - Text(text) - .textSelection(.enabled) - } else { - Text(firstHeard.formatted()) - .textSelection(.enabled) - } - }.onTapGesture { - dateFormatRelative.toggle() - } - } - - if let lastHeard = node.lastHeard, lastHeard.timeIntervalSince1970 > 0 { - HStack { - Label { - Text("Last heard") - } icon: { - Image(systemName: "clock.arrow.circlepath") - .symbolRenderingMode(.multicolor) - } - Spacer() - - if dateFormatRelative, let text = Self.relativeFormatter.string(for: lastHeard) { - Text(text) - .textSelection(.enabled) - } else { - Text(lastHeard.formatted()) - .textSelection(.enabled) - } - }.onTapGesture { - dateFormatRelative.toggle() - } + }.onTapGesture { + dateFormatRelative.toggle() } } - if node.hasPositions && UserDefaults.environmentEnableWeatherKit || node.hasEnvironmentMetrics { - Section("Environment") { - if !node.hasEnvironmentMetrics { - LocalWeatherConditions(location: node.latestPosition?.nodeLocation) + + if let lastHeard = node.lastHeard, lastHeard.timeIntervalSince1970 > 0 { + HStack { + Label { + Text("Last heard") + } icon: { + Image(systemName: "clock.arrow.circlepath") + .symbolRenderingMode(.multicolor) + } + Spacer() + + if dateFormatRelative, let text = Self.relativeFormatter.string(for: lastHeard) { + Text(text) + .textSelection(.enabled) } else { - VStack { - if node.latestEnvironmentMetrics?.iaq ?? -1 > 0 { - IndoorAirQuality(iaq: Int(node.latestEnvironmentMetrics?.iaq ?? 0), displayMode: .gradient) - .padding(.vertical) - } - LazyVGrid(columns: gridItemLayout) { - WeatherConditionsCompactWidget(temperature: String(node.latestEnvironmentMetrics?.temperature.shortFormattedTemperature() ?? "99°"), symbolName: "cloud.sun", description: "TEMP") - if node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0 > 0.0 { - HumidityCompactWidget(humidity: Int(node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0), dewPoint: String(format: "%.0f", calculateDewPoint(temp: node.latestEnvironmentMetrics?.temperature ?? 0.0, relativeHumidity: node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0)) + "°") - } - if node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 > 0.0 { - PressureCompactWidget(pressure: String(format: "%.2f", node.latestEnvironmentMetrics?.barometricPressure ?? 0.0), unit: "hPA", low: node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 <= 1009.144) - } - if node.latestEnvironmentMetrics?.windSpeed ?? 0.0 > 0.0 { - WindCompactWidget(speed: String(node.latestEnvironmentMetrics?.windSpeed ?? 0.0), gust: String(node.latestEnvironmentMetrics?.windGust ?? 0.0), direction: "") - } - } - .padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical) + Text(lastHeard.formatted()) + .textSelection(.enabled) + } + }.onTapGesture { + dateFormatRelative.toggle() + } + } + } + if node.hasPositions && UserDefaults.environmentEnableWeatherKit || node.hasEnvironmentMetrics { + Section("Environment") { + if !node.hasEnvironmentMetrics { + LocalWeatherConditions(location: node.latestPosition?.nodeLocation) + } else { + VStack { + if node.latestEnvironmentMetrics?.iaq ?? -1 > 0 { + IndoorAirQuality(iaq: Int(node.latestEnvironmentMetrics?.iaq ?? 0), displayMode: .gradient) + .padding(.vertical) } + LazyVGrid(columns: gridItemLayout) { + WeatherConditionsCompactWidget(temperature: String(node.latestEnvironmentMetrics?.temperature.shortFormattedTemperature() ?? "99°"), symbolName: "cloud.sun", description: "TEMP") + if node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0 > 0.0 { + HumidityCompactWidget(humidity: Int(node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0), dewPoint: String(format: "%.0f", calculateDewPoint(temp: node.latestEnvironmentMetrics?.temperature ?? 0.0, relativeHumidity: node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0)) + "°") + } + if node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 > 0.0 { + PressureCompactWidget(pressure: String(format: "%.2f", node.latestEnvironmentMetrics?.barometricPressure ?? 0.0), unit: "hPA", low: node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 <= 1009.144) + } + if node.latestEnvironmentMetrics?.windSpeed ?? 0.0 > 0.0 { + WindCompactWidget(speed: String(node.latestEnvironmentMetrics?.windSpeed ?? 0.0), gust: String(node.latestEnvironmentMetrics?.windGust ?? 0.0), direction: "") + } + } + .padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical) } } } - Section("Logs") { - ForEach(NodeDetails.allCases) { detail in - // List( selection: $selectedDetails) { detail in - switch detail { - case .deviceMetricsLog: - NavigationLink { - DeviceMetricsLog(node: node) - } label: { - Label { - Text("Device Metrics Log") - } icon: { - Image(systemName: "flipphone") - .symbolRenderingMode(.multicolor) - } - } - .disabled(!node.hasDeviceMetrics) - case .nodeMap: - NavigationLink { - if #available (iOS 17, macOS 14, *) { - NodeMapSwiftUI(node: node, showUserLocation: connectedNode?.num ?? 0 == node.num) - } else { - NodeMapMapkit(node: node) - } - } label: { - Label { - Text("Node Map") - } icon: { - Image(systemName: "map") - .symbolRenderingMode(.multicolor) - } - } - .disabled(!node.hasPositions) - case .positionLog: - NavigationLink { - PositionLog(node: node) - } label: { - Label { - Text("Position Log") - } icon: { - Image(systemName: "mappin.and.ellipse") - .symbolRenderingMode(.multicolor) - } - } - .disabled(!node.hasPositions) - case .environmentMetricsLog: - NavigationLink { - EnvironmentMetricsLog(node: node) - } label: { - Label { - Text("Environment Metrics Log") - } icon: { - Image(systemName: "cloud.sun.rain") - .symbolRenderingMode(.multicolor) - } - } - .disabled(!node.hasEnvironmentMetrics) - case .traceRouteLog: - if #available(iOS 17.0, macOS 14.0, *) { - NavigationLink { - TraceRouteLog(node: node) - } label: { - Label { - Text("Trace Route Log") - } icon: { - Image(systemName: "signpost.right.and.left") - .symbolRenderingMode(.multicolor) - } - } - .disabled(node.traceRoutes?.count ?? 0 == 0) - } - case .detectionSensorLog: - NavigationLink { - DetectionSensorLog(node: node) - } label: { - Label { - Text("Detection Sensor Log") - } icon: { - Image(systemName: "sensor") - .symbolRenderingMode(.multicolor) - } - } - .disabled(!node.hasDetectionSensorMetrics) - case .paxCounterLog: - if node.hasPax { - NavigationLink { - PaxCounterLog(node: node) - } label: { - Label { - Text("paxcounter.log") - } icon: { - Image(systemName: "figure.walk.motion") - .symbolRenderingMode(.multicolor) - } - } - .disabled(!node.hasPax) + } + Section("Logs") { + ForEach(NodeDetails.allCases) { detail in + // List( selection: $selectedDetails) { detail in + switch detail { + case .deviceMetricsLog: + NavigationLink { + DeviceMetricsLog(node: node) + } label: { + Label { + Text("Device Metrics Log") + } icon: { + Image(systemName: "flipphone") + .symbolRenderingMode(.multicolor) } } - } + .disabled(!node.hasDeviceMetrics) + case .nodeMap: + NavigationLink { + if #available (iOS 17, macOS 14, *) { + NodeMapSwiftUI(node: node, showUserLocation: connectedNode?.num ?? 0 == node.num) + } else { + NodeMapMapkit(node: node) + } + } label: { + Label { + Text("Node Map") + } icon: { + Image(systemName: "map") + .symbolRenderingMode(.multicolor) + } + } + .disabled(!node.hasPositions) + case .positionLog: + NavigationLink { + PositionLog(node: node) + } label: { + Label { + Text("Position Log") + } icon: { + Image(systemName: "mappin.and.ellipse") + .symbolRenderingMode(.multicolor) + } + } + .disabled(!node.hasPositions) + case .environmentMetricsLog: + NavigationLink { + EnvironmentMetricsLog(node: node) + } label: { + Label { + Text("Environment Metrics Log") + } icon: { + Image(systemName: "cloud.sun.rain") + .symbolRenderingMode(.multicolor) + } + } + .disabled(!node.hasEnvironmentMetrics) + case .traceRouteLog: + if #available(iOS 17.0, macOS 14.0, *) { + NavigationLink { + TraceRouteLog(node: node) + } label: { + Label { + Text("Trace Route Log") + } icon: { + Image(systemName: "signpost.right.and.left") + .symbolRenderingMode(.multicolor) + } + } + .disabled(node.traceRoutes?.count ?? 0 == 0) + } + case .detectionSensorLog: + NavigationLink { + DetectionSensorLog(node: node) + } label: { + Label { + Text("Detection Sensor Log") + } icon: { + Image(systemName: "sensor") + .symbolRenderingMode(.multicolor) + } + } + .disabled(!node.hasDetectionSensorMetrics) + case .paxCounterLog: + if node.hasPax { + NavigationLink { + PaxCounterLog(node: node) + } label: { + Label { + Text("paxcounter.log") + } icon: { + Image(systemName: "figure.walk.motion") + .symbolRenderingMode(.multicolor) + } + } + .disabled(!node.hasPax) + } + } + } + } + + Section("Actions") { + FavoriteNodeButton( + bleManager: bleManager, + context: context, + node: node + ) + + if let user = node.user { + NodeAlertsButton( + context: context, + node: node, + user: user + ) } - Section("Actions") { - FavoriteNodeButton( + if let connectedPeripheral = bleManager.connectedPeripheral, + node.num != connectedPeripheral.num { + ExchangePositionsButton( bleManager: bleManager, - context: context, node: node ) - if let user = node.user { - NodeAlertsButton( - context: context, - node: node, - user: user - ) - } + TraceRouteButton( + bleManager: bleManager, + node: node + ) - if let connectedPeripheral = bleManager.connectedPeripheral, - node.num != connectedPeripheral.num { - ExchangePositionsButton( - bleManager: bleManager, - node: node - ) - - TraceRouteButton( - bleManager: bleManager, - node: node - ) - - if let connectedNode { - if node.isStoreForwardRouter { - ClientHistoryButton( - bleManager: bleManager, - connectedNode: connectedNode, - node: node - ) - } - - DeleteNodeButton( + if let connectedNode { + if node.isStoreForwardRouter { + ClientHistoryButton( bleManager: bleManager, - context: context, connectedNode: connectedNode, node: node ) } + + DeleteNodeButton( + bleManager: bleManager, + context: context, + connectedNode: connectedNode, + node: node + ) } } + } - if let metadata = node.metadata, - let connectedNode, - self.bleManager.connectedPeripheral != nil { - Section("Administration") { - if connectedNode.myInfo?.hasAdmin ?? false { - Button { - let adminMessageId = bleManager.requestDeviceMetadata( - fromUser: connectedNode.user!, - toUser: node.user!, - adminIndex: connectedNode.myInfo!.adminIndex, - context: context - ) - if adminMessageId > 0 { - Logger.mesh.info("Sent node metadata request from node details") - } - } label: { - Label { - Text("Refresh device metadata") - } icon: { - Image(systemName: "arrow.clockwise") - } - } - } - - if metadata.canShutdown { - Button { - showingShutdownConfirm = true - } label: { - Label("Power Off", systemImage: "power") - }.confirmationDialog( - "are.you.sure", - isPresented: $showingShutdownConfirm - ) { - Button("Shutdown Node?", role: .destructive) { - if !bleManager.sendShutdown( - fromUser: connectedNode.user!, - toUser: node.user!, - adminIndex: connectedNode.myInfo!.adminIndex - ) { - Logger.mesh.warning("Shutdown Failed") - } - } - } - } - + if let metadata = node.metadata, + let connectedNode, + self.bleManager.connectedPeripheral != nil { + Section("Administration") { + if connectedNode.myInfo?.hasAdmin ?? false { Button { - showingRebootConfirm = true - } label: { - Label( - "reboot", - systemImage: "arrow.triangle.2.circlepath" + let adminMessageId = bleManager.requestDeviceMetadata( + fromUser: connectedNode.user!, + toUser: node.user!, + adminIndex: connectedNode.myInfo!.adminIndex, + context: context ) + if adminMessageId > 0 { + Logger.mesh.info("Sent node metadata request from node details") + } + } label: { + Label { + Text("Refresh device metadata") + } icon: { + Image(systemName: "arrow.clockwise") + } + } + } + + if metadata.canShutdown { + Button { + showingShutdownConfirm = true + } label: { + Label("Power Off", systemImage: "power") }.confirmationDialog( "are.you.sure", - isPresented: $showingRebootConfirm + isPresented: $showingShutdownConfirm ) { - Button("reboot.node", role: .destructive) { - if !bleManager.sendReboot( + Button("Shutdown Node?", role: .destructive) { + if !bleManager.sendShutdown( fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo!.adminIndex ) { - Logger.mesh.warning("Reboot Failed") + Logger.mesh.warning("Shutdown Failed") } } } } + + Button { + showingRebootConfirm = true + } label: { + Label( + "reboot", + systemImage: "arrow.triangle.2.circlepath" + ) + }.confirmationDialog( + "are.you.sure", + isPresented: $showingRebootConfirm + ) { + Button("reboot.node", role: .destructive) { + if !bleManager.sendReboot( + fromUser: connectedNode.user!, + toUser: node.user!, + adminIndex: connectedNode.myInfo!.adminIndex + ) { + Logger.mesh.warning("Reboot Failed") + } + } + } } } - .listStyle(.insetGrouped) } + .listStyle(.insetGrouped) } } diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 6f38410f..590a45d2 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -20,6 +20,7 @@ struct NodeList: View { @State private var columnVisibility = NavigationSplitViewVisibility.all @State private var selectedNode: NodeInfoEntity? + @State private var selectedDetails: NodeDetails? @State private var searchText = "" @State private var viaLora = true @State private var viaMqtt = true @@ -251,14 +252,16 @@ struct NodeList: View { await searchNodeList() } } - .onChange(of: router.navigationState) { state in + .onChange(of: router.navigationState) { _ in // Handle deep link routing if case .nodes(let selected) = router.navigationState { self.selectedNode = selected?.selectedNodeNum.flatMap { getNodeInfo(id: $0, context: context) } + self.selectedDetails = selected?.details } else { self.selectedNode = nil + self.selectedDetails = nil } } .onAppear {