From 58ca7644ab7ae45102e1a9185ea5e46e978c72d2 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:50:26 -0700 Subject: [PATCH 1/4] refactor(remote-nodes): extract shared helpers and add Room management UI Extract shared node management logic from Repeater-specific views into reusable helpers (NodeSettingsHelper, NodeStatusHelper, SharedNodeViews) and create parallel Room management views with RoomAdminService. Fix two pre-existing MeshCore spec alignment bugs: - flood.advert.interval validation now accepts 0 (disabled) and 3-168h, matching firmware (was incorrectly limited to 3-48h) - password change response now recognized as success when firmware echoes "password now: {pw}" instead of "OK" Remove dead code from MessageEventBroadcaster (unused handler methods). Consistent DeviceInfo section ordering across Room and Repeater settings. --- MC1/Resources/Generated/L10n.swift | 8 +- .../Localization/de.lproj/RemoteNodes.strings | 74 +- .../Localization/en.lproj/RemoteNodes.strings | 74 +- .../Localization/es.lproj/RemoteNodes.strings | 74 +- .../Localization/fr.lproj/RemoteNodes.strings | 74 +- .../Localization/nl.lproj/RemoteNodes.strings | 74 +- .../Localization/pl.lproj/RemoteNodes.strings | 74 +- .../Localization/ru.lproj/RemoteNodes.strings | 74 +- .../Localization/uk.lproj/RemoteNodes.strings | 74 +- .../zh-Hans.lproj/RemoteNodes.strings | 74 +- MC1/Services/MessageEventBroadcaster.swift | 26 - MC1/Views/Chats/RoomInfoSheet.swift | 24 +- .../ExpandableSettingsSection.swift | 12 +- MC1/Views/Contacts/ContactDetailView.swift | 142 ++- .../RemoteNodes/NodeAuthenticationSheet.swift | 6 +- .../RemoteNodes/NodeSettingsHelper.swift | 729 ++++++++++++++ MC1/Views/RemoteNodes/NodeStatusHelper.swift | 423 ++++++++ .../RemoteNodes/NodeStatusHistoryView.swift | 29 +- .../RemoteNodes/RepeaterSettingsView.swift | 531 +--------- .../RepeaterSettingsViewModel.swift | 913 +++--------------- .../RemoteNodes/RepeaterStatusView.swift | 279 +----- .../RemoteNodes/RepeaterStatusViewModel.swift | 500 +--------- MC1/Views/RemoteNodes/RoomSettingsView.swift | 249 +++++ .../RemoteNodes/RoomSettingsViewModel.swift | 284 ++++++ MC1/Views/RemoteNodes/RoomStatusView.swift | 152 +++ .../RemoteNodes/RoomStatusViewModel.swift | 108 +++ MC1/Views/RemoteNodes/SharedNodeViews.swift | 662 +++++++++++++ .../TelemetryHistoryOverviewView.swift | 19 +- .../Sources/MC1Services/MC1Services.swift | 3 + .../Models/NodeStatusSnapshot.swift | 17 + .../MC1Services/Models/OCVPreset.swift | 4 +- .../Protocols/PersistenceStoreProtocol.swift | 4 +- .../MC1Services/ServiceContainer.swift | 7 + .../Services/NodeSnapshotService.swift | 8 +- .../PersistenceStore+Diagnostics.swift | 16 +- .../Services/RoomAdminService.swift | 156 +++ .../SyncCoordinator+MessageHandlers.swift | 6 +- .../Mocks/MockPersistenceStore.swift | 14 +- MC1Tests/Services/LinkPreviewCacheTests.swift | 2 +- .../MessageEventBroadcasterTests.swift | 2 - .../ChatViewModelPaginationTests.swift | 2 +- .../LineOfSightViewModelTests.swift | 2 +- .../RepeaterStatusViewModelTests.swift | 43 +- 43 files changed, 3893 insertions(+), 2155 deletions(-) create mode 100644 MC1/Views/RemoteNodes/NodeSettingsHelper.swift create mode 100644 MC1/Views/RemoteNodes/NodeStatusHelper.swift create mode 100644 MC1/Views/RemoteNodes/RoomSettingsView.swift create mode 100644 MC1/Views/RemoteNodes/RoomSettingsViewModel.swift create mode 100644 MC1/Views/RemoteNodes/RoomStatusView.swift create mode 100644 MC1/Views/RemoteNodes/RoomStatusViewModel.swift create mode 100644 MC1/Views/RemoteNodes/SharedNodeViews.swift create mode 100644 MC1Services/Sources/MC1Services/Services/RoomAdminService.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index f8856f482..a028aec5e 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2596,7 +2596,7 @@ public enum L10n { /// Location: RepeaterSettingsView.swift - Firmware label public static let firmware = L10n.tr("RemoteNodes", "remoteNodes.settings.firmware", fallback: "Firmware") /// Location: RepeaterSettingsViewModel.swift - Flood interval validation error - public static let floodIntervalValidation = L10n.tr("RemoteNodes", "remoteNodes.settings.floodIntervalValidation", fallback: "Accepts 3-48 hours") + public static let floodIntervalValidation = L10n.tr("RemoteNodes", "remoteNodes.settings.floodIntervalValidation", fallback: "Accepts 0 (off) or 3-168 hours") /// Location: RepeaterSettingsViewModel.swift - Flood max hops validation error public static let floodMaxValidation = L10n.tr("RemoteNodes", "remoteNodes.settings.floodMaxValidation", fallback: "Accepts 0-64 hops") /// Location: RepeaterSettingsView.swift - Frequency label @@ -2627,8 +2627,8 @@ public enum L10n { public static let min = L10n.tr("RemoteNodes", "remoteNodes.settings.min", fallback: "min") /// Location: RepeaterSettingsView.swift - New password placeholder public static let newPassword = L10n.tr("RemoteNodes", "remoteNodes.settings.newPassword", fallback: "New Password") - /// Location: RepeaterSettingsViewModel.swift - No service error - public static let noService = L10n.tr("RemoteNodes", "remoteNodes.settings.noService", fallback: "Repeater service not available") + /// Location: NodeSettingsHelper.swift - No service error + public static let noService = L10n.tr("RemoteNodes", "remoteNodes.settings.noService", fallback: "Service not available") /// Location: RepeaterSettingsViewModel.swift - Not connected error public static let notConnected = L10n.tr("RemoteNodes", "remoteNodes.settings.notConnected", fallback: "Not connected to repeater") /// Location: RepeaterSettingsView.swift - OK button @@ -2809,6 +2809,8 @@ public enum L10n { public static let packetsSent = L10n.tr("RemoteNodes", "remoteNodes.status.packetsSent", fallback: "Packets Sent") /// Location: RepeaterStatusView.swift - Receive errors label public static let receiveErrors = L10n.tr("RemoteNodes", "remoteNodes.status.receiveErrors", fallback: "Packet Errors Received") + /// Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label + public static let refresh = L10n.tr("RemoteNodes", "remoteNodes.status.refresh", fallback: "Refresh") /// Location: RepeaterStatusViewModel.swift - Request timed out public static let requestTimedOut = L10n.tr("RemoteNodes", "remoteNodes.status.requestTimedOut", fallback: "Request timed out") /// Location: RepeaterStatusView.swift - Seconds ago format diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 099ce29e5..27273cdc6 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Befehl-Zeitüberschreitung"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Repeater-Dienst nicht verfügbar"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Dienst nicht verfügbar"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Akzeptiert 0 (deaktiviert) oder 60-240 Min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Akzeptiert 3-48 Stunden"; +"remoteNodes.settings.floodIntervalValidation" = "Akzeptiert 0 (aus) oder 3–168 Stunden"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Akzeptiert 0-64 Sprünge"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Spannungs-Prozent-Zuordnung zur Schätzung des Batteriestands."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Aktualisieren"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Nachricht zugestellt"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index c31e5c0bc..523adbc7f 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Command timed out"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Repeater service not available"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Service not available"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Accepts 0 (disabled) or 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Accepts 3-48 hours"; +"remoteNodes.settings.floodIntervalValidation" = "Accepts 0 (off) or 3-168 hours"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepts 0-64 hops"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Voltage-to-percentage mapping used for battery level estimation."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Refresh"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Message delivered"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 8c175c8cd..c020c1c34 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Tiempo de espera del comando agotado"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Servicio de repetidor no disponible"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Servicio no disponible"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Acepta 0 (desactivado) o 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Acepta 3-48 horas"; +"remoteNodes.settings.floodIntervalValidation" = "Acepta 0 (desactivado) o 3–168 horas"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Acepta 0-64 saltos"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Mapeo de voltaje a porcentaje para estimar el nivel de batería."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Actualizar"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Mensaje entregado"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index f37dff9ff..de4723a6b 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "La commande a expiré"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Service répéteur non disponible"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Service non disponible"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Accepte 0 (désactivé) ou 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Accepte 3-48 heures"; +"remoteNodes.settings.floodIntervalValidation" = "Accepte 0 (désactivé) ou 3–168 heures"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepte 0-64 sauts"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Correspondance tension-pourcentage pour l'estimation du niveau de batterie."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Actualiser"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -658,6 +661,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Message distribué"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 957e2c459..4c8ee5bc4 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Opdracht time-out"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Repeaterservice niet beschikbaar"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Service niet beschikbaar"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Accepteert 0 (uitgeschakeld) of 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Accepteert 3-48 uur"; +"remoteNodes.settings.floodIntervalValidation" = "Accepteert 0 (uit) of 3–168 uur"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Accepteert 0-64 sprongen"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Spanning-naar-percentage-toewijzing voor schatting van het batterijniveau."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Vernieuwen"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Bericht afgeleverd"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 60a94b5f4..aed514d45 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -298,14 +298,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Upłynął limit czasu polecenia"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Usługa przekaźnika niedostępna"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Usługa niedostępna"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Akceptuje 0 (wyłączone) lub 60-240 min"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Akceptuje 3-48 godzin"; +"remoteNodes.settings.floodIntervalValidation" = "Akceptuje 0 (wył.) lub 3–168 godzin"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Akceptuje 0-64 skoków"; @@ -481,6 +481,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Mapowanie napięcia na procent do szacowania poziomu baterii."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Odśwież"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -580,7 +583,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -658,6 +661,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Wiadomość dostarczona"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 1e198243b..e33efcb5a 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -298,14 +298,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Истекло время ожидания команды"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Сервис ретранслятора недоступен"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Сервис недоступен"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Допустимые значения: 0 (отключено) или 60-240 мин"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Допустимые значения: 3-48 часов"; +"remoteNodes.settings.floodIntervalValidation" = "Допустимые значения: 0 (откл.) или 3–168 часов"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Допустимые значения: 0-64 переходов"; @@ -481,6 +481,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Соответствие напряжения и процента для оценки уровня заряда батареи."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Обновить"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -580,7 +583,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -658,6 +661,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Сообщение доставлено"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index e77de19f2..c26768d45 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -298,14 +298,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "Час очікування команди вичерпано"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "Сервіс ретранслятора недоступний"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "Сервіс недоступний"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "Допустимі значення: 0 (вимкнено) або 60–240 хв"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "Допустимі значення: 3–48 годин"; +"remoteNodes.settings.floodIntervalValidation" = "Допустимі значення: 0 (вимк.) або 3–168 годин"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "Допустимі значення: 0–64 переходів"; @@ -481,6 +481,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "Відповідність напруги та відсотка для оцінки рівня заряду батареї."; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Оновити"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -580,7 +583,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -658,6 +661,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "Повідомлення доставлено"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 21e68442a..8b1d7de92 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -301,14 +301,14 @@ /* Location: RepeaterSettingsViewModel.swift - Timeout error */ "remoteNodes.settings.timeout" = "命令超时"; -/* Location: RepeaterSettingsViewModel.swift - No service error */ -"remoteNodes.settings.noService" = "转发节点服务不可用"; +/* Location: NodeSettingsHelper.swift - No service error */ +"remoteNodes.settings.noService" = "服务不可用"; /* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ "remoteNodes.settings.advertIntervalValidation" = "接受 0(禁用)或 60-240 分钟"; /* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ -"remoteNodes.settings.floodIntervalValidation" = "接受 3-48 小时"; +"remoteNodes.settings.floodIntervalValidation" = "接受 0(关闭)或 3–168 小时"; /* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ "remoteNodes.settings.floodMaxValidation" = "接受 0-64 跳"; @@ -484,6 +484,9 @@ /* Location: RepeaterStatusView.swift - Battery curve section footer */ "remoteNodes.status.batteryCurveFooter" = "用于估算电池电量的电压百分比"; +/* Location: RepeaterStatusView.swift, RoomStatusView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "刷新"; + // MARK: - History /* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ @@ -583,7 +586,7 @@ "remoteNodes.history.neighborsSection" = "Neighbors"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Connect to this repeater at least once to see history."; +"remoteNodes.history.noSnapshotsMessage" = "Connect to this node at least once to see history."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "This data is captured when you view the %@ section during a live telemetry session."; @@ -661,6 +664,69 @@ /* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ "remoteNodes.room.message.status.deliveredLabel" = "消息已送达"; +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Room Status"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Posts Received"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Posts Pushed"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Room Settings"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Guest access, advertisement intervals, and flood hops."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Guest Password"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Allow Read-Only"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Allow users without a password to connect in read-only mode."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Apply Room Settings"; + +/* Location: RoomSettingsView.swift - Identity section footer */ +"remoteNodes.roomSettings.identityFooter" = "Room name and GPS coordinates for map display."; + +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reboot Room?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "The room will restart and be temporarily unavailable."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Applying these changes will restart the room"; + +/* Location: RoomSettingsView.swift - No service error */ +"remoteNodes.roomSettings.noService" = "Room service not available"; + +/* Location: RoomSettingsView.swift - Not connected error */ +"remoteNodes.roomSettings.notConnected" = "Not connected to room"; + +/* Location: RoomSettingsView.swift - Clock ahead error */ +"remoteNodes.roomSettings.clockAheadError" = "Room clock is ahead of phone time. If it's too far forward, reboot the room then sync time again."; + +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +"remoteNodes.room.telemetry" = "Telemetry"; + +/* Location: RoomInfoSheet.swift - Management button */ +"remoteNodes.room.management" = "Management"; + // MARK: - Shared /* Location: Multiple files - Cancel button */ diff --git a/MC1/Services/MessageEventBroadcaster.swift b/MC1/Services/MessageEventBroadcaster.swift index 0027d5bfd..1b491cb70 100644 --- a/MC1/Services/MessageEventBroadcaster.swift +++ b/MC1/Services/MessageEventBroadcaster.swift @@ -62,9 +62,6 @@ public final class MessageEventBroadcaster { /// Reference to binary protocol service for handling binary responses var binaryProtocolService: BinaryProtocolService? - /// Reference to repeater admin service for telemetry and CLI handling - var repeaterAdminService: RepeaterAdminService? - // MARK: - Initialization public init() {} @@ -204,7 +201,6 @@ public final class MessageEventBroadcaster { dataStore = services.dataStore roomServerService = services.roomServerService binaryProtocolService = services.binaryProtocolService - repeaterAdminService = services.repeaterAdminService // Wire message event callbacks for real-time chat updates await services.syncCoordinator.setMessageEventCallbacks( @@ -293,26 +289,4 @@ public final class MessageEventBroadcaster { } } - // MARK: - Status Response Handling - - /// Handle status response from remote node - func handleStatusResponse(_ status: StatusResponse) async { - await repeaterAdminService?.invokeStatusHandler(status) - - let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() - logger.info("Received status response from node: \(prefixHex)") - } - - // Note: Login results and binary responses are handled internally by - // MC1Services via MeshCore event monitoring. No external handlers needed. - - /// Handle telemetry response - func handleTelemetryResponse(_ response: TelemetryResponse) async { - await repeaterAdminService?.invokeTelemetryHandler(response) - } - - /// Handle CLI response - func handleCLIResponse(_ message: ContactMessage, fromContact contact: ContactDTO) async { - await repeaterAdminService?.invokeCLIHandler(message, fromContact: contact) - } } diff --git a/MC1/Views/Chats/RoomInfoSheet.swift b/MC1/Views/Chats/RoomInfoSheet.swift index 96cbf748d..57c302510 100644 --- a/MC1/Views/Chats/RoomInfoSheet.swift +++ b/MC1/Views/Chats/RoomInfoSheet.swift @@ -13,6 +13,8 @@ struct RoomInfoSheet: View { @State private var isFavorite: Bool @State private var notificationTask: Task? @State private var favoriteTask: Task? + @State private var showTelemetry = false + @State private var showSettings = false init(session: RemoteNodeSessionDTO) { self.session = session @@ -54,6 +56,19 @@ struct RoomInfoSheet: View { favoriteTask?.cancel() } + if session.isConnected { + Section { + Button { showTelemetry = true } label: { + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + if session.isAdmin { + Button { showSettings = true } label: { + Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + } + } + } + } + Section(Strings.details) { LabeledContent(L10n.RemoteNodes.RemoteNodes.name, value: session.name) LabeledContent(Strings.permission, value: session.permissionLevel.displayName) @@ -89,6 +104,13 @@ struct RoomInfoSheet: View { } } } - + .sheet(isPresented: $showTelemetry) { + RoomStatusView(session: session) + } + .sheet(isPresented: $showSettings) { + NavigationStack { + RoomSettingsView(session: session) + } + } } } diff --git a/MC1/Views/Components/ExpandableSettingsSection.swift b/MC1/Views/Components/ExpandableSettingsSection.swift index aa255e0dd..a0722f629 100644 --- a/MC1/Views/Components/ExpandableSettingsSection.swift +++ b/MC1/Views/Components/ExpandableSettingsSection.swift @@ -9,7 +9,7 @@ struct ExpandableSettingsSection: View { @Binding var isExpanded: Bool let isLoaded: () -> Bool // Closure instead of binding (supports computed properties) @Binding var isLoading: Bool - @Binding var error: String? + @Binding var hasError: Bool let onLoad: () async -> Void let footer: String? @@ -21,7 +21,7 @@ struct ExpandableSettingsSection: View { isExpanded: Binding, isLoaded: @escaping () -> Bool, isLoading: Binding, - error: Binding, + hasError: Binding, onLoad: @escaping () async -> Void, footer: String? = nil, @ViewBuilder content: @escaping () -> Content @@ -31,7 +31,7 @@ struct ExpandableSettingsSection: View { self._isExpanded = isExpanded self.isLoaded = isLoaded self._isLoading = isLoading - self._error = error + self._hasError = hasError self.onLoad = onLoad self.footer = footer self.content = content @@ -45,7 +45,7 @@ struct ExpandableSettingsSection: View { content() // Show error banner if something failed - if let error, !isLoaded() { + if hasError && !isLoaded() { VStack(spacing: 12) { Label(L10n.Localizable.Common.Error.failedToLoad, systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) @@ -102,7 +102,7 @@ struct ExpandableSettingsSection: View { #Preview { @Previewable @State var isExpanded = false @Previewable @State var isLoading = false - @Previewable @State var error: String? + @Previewable @State var hasError = false @Previewable @State var data: String? Form { @@ -112,7 +112,7 @@ struct ExpandableSettingsSection: View { isExpanded: $isExpanded, isLoaded: { data != nil }, isLoading: $isLoading, - error: $error, + hasError: $hasError, onLoad: { isLoading = true try? await Task.sleep(for: .seconds(1)) diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index f4b19ab1c..b19b6dc45 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -57,13 +57,15 @@ struct ContactDetailView: View { /// Sheet types for the contact detail view private enum ActiveSheet: Identifiable, Hashable { - case repeaterAuth + case nodeAuth case repeaterStatus(RemoteNodeSessionDTO) + case roomStatus(RemoteNodeSessionDTO) var id: String { switch self { - case .repeaterAuth: return "auth" + case .nodeAuth: return "auth" case .repeaterStatus(let session): return "status-\(session.id)" + case .roomStatus(let session): return "room-status-\(session.id)" } } } @@ -80,8 +82,6 @@ struct ContactDetailView: View { @State private var showRoomJoinSheet = false @State private var activeSheet: ActiveSheet? @State private var pendingSheet: ActiveSheet? - @State private var showRoomConversation = false - @State private var connectedRoomSession: RemoteNodeSessionDTO? // Admin access navigation state (separate from telemetry sheet flow) @State private var showRepeaterAdminAuth = false @State private var adminSession: RemoteNodeSessionDTO? @@ -116,7 +116,7 @@ struct ContactDetailView: View { isTogglingFavorite: isTogglingFavorite, pingResult: pingResult, onJoinRoom: { showRoomJoinSheet = true }, - onShowTelemetry: { activeSheet = .repeaterAuth }, + onShowTelemetry: { activeSheet = .nodeAuth }, onShowAdminAccess: { adminSession = nil showRepeaterAdminAuth = true @@ -245,25 +245,26 @@ struct ContactDetailView: View { } .sheet(item: $activeSheet, onDismiss: presentPendingSheet) { sheet in switch sheet { - case .repeaterAuth: + case .nodeAuth: if let role = RemoteNodeRole(contactType: currentContact.type) { NodeAuthenticationSheet( contact: currentContact, role: role, customTitle: L10n.Contacts.Contacts.Detail.telemetryAccess ) { session in - pendingSheet = .repeaterStatus(session) + if currentContact.type == .room { + pendingSheet = .roomStatus(session) + } else { + pendingSheet = .repeaterStatus(session) + } activeSheet = nil // Triggers dismissal, then onDismiss fires } .presentationSizing(.page) } case .repeaterStatus(let session): RepeaterStatusView(session: session) - } - } - .navigationDestination(isPresented: $showRoomConversation) { - if let session = connectedRoomSession { - RoomConversationView(session: session) + case .roomStatus(let session): + RoomStatusView(session: session) } } .sheet(isPresented: $showRepeaterAdminAuth, onDismiss: { @@ -271,6 +272,8 @@ struct ContactDetailView: View { if let session = adminSession { if session.isAdmin { navigateToSettings = true + } else if session.isRoom { + activeSheet = .roomStatus(session) } else { activeSheet = .repeaterStatus(session) } @@ -296,7 +299,11 @@ struct ContactDetailView: View { } .navigationDestination(isPresented: $navigateToSettings) { if let session = adminSession { - RepeaterSettingsView(session: session) + if session.isRoom { + RoomSettingsView(session: session) + } else { + RepeaterSettingsView(session: session) + } } } } @@ -544,46 +551,28 @@ private struct ContactActionsSection: View { } .radioDisabled(for: appState.connectionState) - case .repeater: - // Telemetry button - shows read-only status sheet after auth - Button(action: onShowTelemetry) { - Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") - } - .radioDisabled(for: appState.connectionState) - - // Telemetry History - offline telemetry charts - NavigationLink { - TelemetryHistoryOverviewView( - publicKey: currentContact.publicKey, - deviceID: currentContact.deviceID - ) - } label: { - Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") - .foregroundStyle(.tint) - } - - // Admin Access - navigates to settings view after auth - Button(action: onShowAdminAccess) { - Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") - } - .radioDisabled(for: appState.connectionState) - - // Ping Repeater - Button(action: onPingRepeater) { - HStack { - Label(L10n.Contacts.Contacts.Detail.pingRepeater, systemImage: "wave.3.right") - if isPinging { - Spacer() - ProgressView() - } - } - } - .disabled(isPinging) - .radioDisabled(for: appState.connectionState) + NodeActionRows( + contact: currentContact, + pingLabel: L10n.Contacts.Contacts.Detail.ping, + isPinging: isPinging, + pingResult: pingResult, + connectionState: appState.connectionState, + onShowTelemetry: onShowTelemetry, + onShowAdminAccess: onShowAdminAccess, + onPing: onPingRepeater + ) - if let result = pingResult { - PingResultRow(result: result) - } + case .repeater: + NodeActionRows( + contact: currentContact, + pingLabel: L10n.Contacts.Contacts.Detail.pingRepeater, + isPinging: isPinging, + pingResult: pingResult, + connectionState: appState.connectionState, + onShowTelemetry: onShowTelemetry, + onShowAdminAccess: onShowAdminAccess, + onPing: onPingRepeater + ) case .chat: // Send message - only show when NOT from direct chat and NOT blocked @@ -627,6 +616,55 @@ private struct ContactActionsSection: View { } } +private struct NodeActionRows: View { + let contact: ContactDTO + let pingLabel: String + let isPinging: Bool + let pingResult: PingResult? + let connectionState: ConnectionState + let onShowTelemetry: () -> Void + let onShowAdminAccess: () -> Void + let onPing: () -> Void + + var body: some View { + Button(action: onShowTelemetry) { + Label(L10n.Contacts.Contacts.Detail.telemetry, systemImage: "chart.line.uptrend.xyaxis") + } + .radioDisabled(for: connectionState) + + NavigationLink { + TelemetryHistoryOverviewView( + publicKey: contact.publicKey, + deviceID: contact.deviceID + ) + } label: { + Label(L10n.Contacts.Contacts.Detail.savedHistory, systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .foregroundStyle(.tint) + } + + Button(action: onShowAdminAccess) { + Label(L10n.Contacts.Contacts.Detail.management, systemImage: "gearshape.2") + } + .radioDisabled(for: connectionState) + + Button(action: onPing) { + HStack { + Label(pingLabel, systemImage: "wave.3.right") + if isPinging { + Spacer() + ProgressView() + } + } + } + .disabled(isPinging) + .radioDisabled(for: connectionState) + + if let result = pingResult { + PingResultRow(result: result) + } + } +} + private struct ContactInfoSection: View { let currentContact: ContactDTO @Binding var nickname: String diff --git a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift index 1b8d33624..de12a701c 100644 --- a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift +++ b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift @@ -302,6 +302,10 @@ private struct ConnectButton: View { let isAuthenticating: Bool let onAuthenticate: () -> Void + private var buttonLabel: String { + role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.connect + } + var body: some View { Section { Button { @@ -311,7 +315,7 @@ private struct ConnectButton: View { ProgressView() .frame(maxWidth: .infinity) } else { - Text(role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.connect) + Text(buttonLabel) .frame(maxWidth: .infinity) } } diff --git a/MC1/Views/RemoteNodes/NodeSettingsHelper.swift b/MC1/Views/RemoteNodes/NodeSettingsHelper.swift new file mode 100644 index 000000000..e7e132159 --- /dev/null +++ b/MC1/Views/RemoteNodes/NodeSettingsHelper.swift @@ -0,0 +1,729 @@ +import SwiftUI +import MC1Services +import OSLog + +private let logger = Logger(subsystem: "com.mc1", category: "NodeSettingsHelper") + +/// Shared logic for repeater and room settings view models. +/// Owns CLI transport, device info, radio, identity, contact info, +/// security, and device action methods. +@Observable +@MainActor +final class NodeSettingsHelper { + + // MARK: - Session + + var session: RemoteNodeSessionDTO? + + // MARK: - Device Info + + var firmwareVersion: String? + private var deviceTimeUTC: String? + var isLoadingDeviceInfo = false + var deviceInfoError = false + var deviceInfoLoaded: Bool { deviceTimeUTC != nil } + + var deviceTime: String? { + guard let utcString = deviceTimeUTC else { return nil } + return Self.convertUTCToLocal(utcString) + } + + // swiftlint:disable:next force_try + private static let utcDateRegex = try! Regex(#"(\d{1,2}:\d{2}) - (\d{1,2}/\d{1,2}/\d{4}) UTC"#) + + private static let utcInputFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm d/M/yyyy" + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter + }() + + static func convertUTCToLocal(_ utcString: String) -> String { + guard let match = utcString.firstMatch(of: utcDateRegex), + match.count >= 3 else { + return utcString + } + + let timeStr = String(match[1].substring ?? "") + let dateStr = String(match[2].substring ?? "") + + guard let date = utcInputFormatter.date(from: "\(timeStr) \(dateStr)") else { + return utcString + } + + let timeString = date.formatted(date: .omitted, time: .shortened) + let dateString = date.formatted(.dateTime.year(.twoDigits).month(.twoDigits).day(.twoDigits)) + return "\(timeString) - \(dateString)" + } + + // MARK: - Identity + + var name: String? + var latitude: Double? + var longitude: Double? + private(set) var originalName: String? + private(set) var originalLatitude: Double? + private(set) var originalLongitude: Double? + var isLoadingIdentity = false + var identityError = false + var identityLoaded: Bool { originalLatitude != nil || originalLongitude != nil } + + var identitySettingsModified: Bool { + (name != nil && name != originalName) || + (latitude != nil && latitude != originalLatitude) || + (longitude != nil && longitude != originalLongitude) + } + + // MARK: - Radio + + var frequency: Double? + var bandwidth: Double? + var spreadingFactor: Int? + var codingRate: Int? + var txPower: Int? + var isLoadingRadio = false + var radioError = false + var radioLoaded: Bool { frequency != nil || txPower != nil } + var radioSettingsModified = false + + // MARK: - Contact Info + + var ownerInfo: String? + private(set) var originalOwnerInfo: String? + var isLoadingContactInfo = false + var contactInfoError = false + var contactInfoLoaded: Bool { originalOwnerInfo != nil } + + var contactInfoSettingsModified: Bool { + ownerInfo != originalOwnerInfo + } + + var ownerInfoCharCount: Int { + (ownerInfo ?? "").count + } + + // MARK: - Security + + var newPassword: String = "" + var confirmPassword: String = "" + + // MARK: - Expansion State + + var isDeviceInfoExpanded = false + var isRadioExpanded = false + var isIdentityExpanded = false + var isContactInfoExpanded = false + var isSecurityExpanded = false + + // MARK: - Global State + + var isApplying = false + var isRebooting = false + var errorMessage: String? + var successMessage: String? + var showSuccessAlert = false + var identityApplySuccess = false + var contactInfoApplySuccess = false + + // MARK: - Service Closures + + private var sendCommandClosure: ((UUID, String, Duration) async throws -> String)? + private var sendRawCommandClosure: ((UUID, String, Duration) async throws -> String)? + + /// Called when firmware version or node info needs pre-fetching. + /// Repeater sets this to binary requestOwnerInfo; Room sets this to CLI `ver`. + var onPreFetchNodeInfo: (() async -> Void)? + + // MARK: - Configuration + + func configure( + session: RemoteNodeSessionDTO, + sendCommand: @escaping (UUID, String, Duration) async throws -> String, + sendRawCommand: @escaping (UUID, String, Duration) async throws -> String + ) { + self.session = session + self.sendCommandClosure = sendCommand + self.sendRawCommandClosure = sendRawCommand + } + + /// Set name and owner info from an external source (e.g., binary protocol pre-fetch) + func setNodeInfo(firmwareVersion: String?, name: String?, ownerInfo: String?) { + if let firmwareVersion { self.firmwareVersion = firmwareVersion } + if let name { + self.name = name + self.originalName = name + } + if let ownerInfo { + self.ownerInfo = ownerInfo + self.originalOwnerInfo = ownerInfo + } + } + + func cleanup() { + sendCommandClosure = nil + sendRawCommandClosure = nil + onPreFetchNodeInfo = nil + } + + // MARK: - CLI Transport + + func sendAndWait( + _ command: String, + timeout: Duration = .seconds(5), + rawMatching: Bool = false + ) async throws -> String { + guard let session, let sendCmd = rawMatching ? sendRawCommandClosure : sendCommandClosure else { + throw NodeSettingsError.noService + } + + let response = try await sendCmd(session.id, command, timeout) + logger.debug("Command '\(command)' response: \(response.prefix(50))") + return response + } + + // MARK: - Fetch Methods + + func fetchDeviceInfo() async { + isLoadingDeviceInfo = true + deviceInfoError = false + + if firmwareVersion == nil { + await onPreFetchNodeInfo?() + } + + if firmwareVersion == nil { + do { + let response = try await sendAndWait("ver") + if case .version(let version) = CLIResponse.parse(response, forQuery: "ver") { + self.firmwareVersion = version + } + } catch { + if case RemoteNodeError.timeout = error { + deviceInfoError = true + } + logger.warning("Failed to get firmware version: \(error)") + } + } + + do { + let response = try await sendAndWait("clock") + if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { + self.deviceTimeUTC = time + } + } catch { + if case RemoteNodeError.timeout = error { + deviceInfoError = true + } + logger.warning("Failed to get device time: \(error)") + } + + isLoadingDeviceInfo = false + } + + func fetchIdentity() async { + isLoadingIdentity = true + identityError = false + var hadTimeout = false + + if originalName == nil { + await onPreFetchNodeInfo?() + } + + if originalName == nil { + do { + let response = try await sendAndWait("get name") + if case .name(let n) = CLIResponse.parse(response, forQuery: "get name") { + self.name = n + self.originalName = n + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get name: \(error)") + } + } + + do { + let response = try await sendAndWait("get lat") + if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { + self.latitude = lat + self.originalLatitude = lat + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get latitude: \(error)") + } + + do { + let response = try await sendAndWait("get lon") + if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { + self.longitude = lon + self.originalLongitude = lon + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get longitude: \(error)") + } + + if hadTimeout { + identityError = true + } + + isLoadingIdentity = false + } + + func fetchRadioSettings() async { + isLoadingRadio = true + radioError = false + var hadTimeout = false + + do { + let response = try await sendAndWait("get tx") + if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { + self.txPower = power + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get TX power: \(error)") + } + + do { + let response = try await sendAndWait("get radio") + if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { + self.frequency = freq + self.bandwidth = bw + self.spreadingFactor = sf + self.codingRate = cr + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get radio settings: \(error)") + } + + if hadTimeout { + radioError = true + } + + isLoadingRadio = false + } + + func fetchContactInfo() async { + if originalOwnerInfo == nil { + await onPreFetchNodeInfo?() + } + if originalOwnerInfo != nil { return } + + isLoadingContactInfo = true + contactInfoError = false + + do { + let response = try await sendAndWait("get owner.info") + if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { + let displayText = info.replacing("|", with: "\n") + self.ownerInfo = displayText + self.originalOwnerInfo = displayText + } + } catch { + if case RemoteNodeError.timeout = error { + contactInfoError = true + } + logger.warning("Failed to get owner info: \(error)") + } + + isLoadingContactInfo = false + } + + // MARK: - Apply Methods + + func applyRadioSettings() async { + guard let frequency, let bandwidth, let spreadingFactor, let codingRate, let txPower else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioNotLoaded + return + } + + isApplying = true + errorMessage = nil + + do { + var allSucceeded = true + + let radioCommand = "set radio \(frequency),\(bandwidth),\(spreadingFactor),\(codingRate)" + let radioResponse = try await sendAndWait(radioCommand) + if case .ok = CLIResponse.parse(radioResponse) { + } else { + allSucceeded = false + } + + let txCommand = "set tx \(txPower)" + let txResponse = try await sendAndWait(txCommand) + if case .ok = CLIResponse.parse(txResponse) { + } else { + allSucceeded = false + } + + if allSucceeded { + radioSettingsModified = false + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioAppliedSuccess + showSuccessAlert = true + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioApplyFailed + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + func applyIdentitySettings() async { + isApplying = true + errorMessage = nil + + do { + var allSucceeded = true + + if let name, name != originalName { + let response = try await sendAndWait("set name \(name)") + if case .ok = CLIResponse.parse(response) { + originalName = name + } else { + allSucceeded = false + } + } + + if let latitude, latitude != originalLatitude { + let response = try await sendAndWait("set lat \(latitude)") + if case .ok = CLIResponse.parse(response) { + originalLatitude = latitude + } else { + allSucceeded = false + } + } + + if let longitude, longitude != originalLongitude { + let response = try await sendAndWait("set lon \(longitude)") + if case .ok = CLIResponse.parse(response) { + originalLongitude = longitude + } else { + allSucceeded = false + } + } + + if allSucceeded { + withAnimation { + isApplying = false + identityApplySuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { identityApplySuccess = false } + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + func applyContactInfoSettings() async { + isApplying = true + errorMessage = nil + + do { + let pipeText = (ownerInfo ?? "").replacing("\n", with: "|") + let response = try await sendAndWait("set owner.info \(pipeText)") + if case .ok = CLIResponse.parse(response) { + originalOwnerInfo = ownerInfo + withAnimation { + isApplying = false + contactInfoApplySuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { contactInfoApplySuccess = false } + return + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + // MARK: - Location Picker + + func setLocationFromPicker(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + + // MARK: - Security + + func changePassword() async { + guard !newPassword.isEmpty else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordEmpty + return + } + guard newPassword == confirmPassword else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordMismatch + return + } + + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("password \(newPassword)") + let parsed = CLIResponse.parse(response) + // Firmware echoes "password now: {pw}" on success, not "OK" + let isSuccess: Bool = switch parsed { + case .ok: true + case .raw(let text) where text.hasPrefix("password now:"): true + default: false + } + if isSuccess { + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangedSuccess + showSuccessAlert = true + newPassword = "" + confirmPassword = "" + } else { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangeFailed + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + // MARK: - Device Actions + + func reboot() async { + guard session != nil else { return } + + isRebooting = true + errorMessage = nil + + do { + _ = try await sendAndWait("reboot") + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.rebootSent + showSuccessAlert = true + } catch { + errorMessage = error.localizedDescription + } + + isRebooting = false + } + + func forceAdvert() async { + do { + _ = try await sendAndWait("advert") + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.advertSent + showSuccessAlert = true + } catch { + errorMessage = error.localizedDescription + } + } + + func syncTime() async { + isApplying = true + errorMessage = nil + + do { + let response = try await sendAndWait("clock sync") + switch CLIResponse.parse(response) { + case .ok: + successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced + showSuccessAlert = true + case .error(let message): + if message.contains("clock cannot go backwards") { + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.clockAheadError + } else { + let cleanMessage = message.replacing("ERR: ", with: "") + errorMessage = cleanMessage.isEmpty ? L10n.RemoteNodes.RemoteNodes.Settings.syncTimeFailed : cleanMessage + } + default: + errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.unexpectedResponse(response) + } + } catch { + errorMessage = error.localizedDescription + } + + isApplying = false + } + + // MARK: - Shared Validation + + struct BehaviorValidationErrors { + var advertInterval: String? + var floodInterval: String? + var floodMaxHops: String? + var hasErrors: Bool { advertInterval != nil || floodInterval != nil || floodMaxHops != nil } + } + + static func validateBehaviorFields( + advertInterval: Int?, + floodInterval: Int?, + floodMaxHops: Int? + ) -> BehaviorValidationErrors { + var errors = BehaviorValidationErrors() + if let interval = advertInterval, interval != 0 && (interval < 60 || interval > 240) { + errors.advertInterval = L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalValidation + } + if let interval = floodInterval, interval != 0 && (interval < 3 || interval > 168) { + errors.floodInterval = L10n.RemoteNodes.RemoteNodes.Settings.floodIntervalValidation + } + if let hops = floodMaxHops, hops < 0 || hops > 64 { + errors.floodMaxHops = L10n.RemoteNodes.RemoteNodes.Settings.floodMaxValidation + } + return errors + } + + // MARK: - Shared Late Response Parsing + + enum BehaviorLateResponse { + case advertInterval(Int) + case floodAdvertInterval(Int) + case floodMax(Int) + } + + /// Try to parse a late response as one of the shared behavior fields. + /// Returns `nil` if the response didn't match any field that's still missing. + static func parseBehaviorLateResponse( + _ response: String, + hasAdvertInterval: Bool, + hasFloodInterval: Bool, + hasFloodMaxHops: Bool + ) -> BehaviorLateResponse? { + if !hasAdvertInterval { + if case .advertInterval(let interval) = CLIResponse.parse(response, forQuery: "get advert.interval") { + return .advertInterval(interval) + } + } + if !hasFloodInterval { + if case .floodAdvertInterval(let interval) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + return .floodAdvertInterval(interval) + } + } + if !hasFloodMaxHops { + if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + return .floodMax(hops) + } + } + return nil + } + + // MARK: - Late Response Handling + + /// Handle late CLI responses for shared sections. + /// Returns `true` if the response was consumed. + func handleCommonLateResponse(_ response: String) -> Bool { + // Radio settings + if !isLoadingRadio && radioError { + if frequency == nil { + if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { + self.frequency = freq + self.bandwidth = bw + self.spreadingFactor = sf + self.codingRate = cr + self.radioError = false + logger.info("Late response: received radio settings") + return true + } + } + + if txPower == nil { + if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { + self.txPower = power + self.radioError = false + logger.info("Late response: received TX power") + return true + } + } + } + + // Device info + if !isLoadingDeviceInfo && deviceInfoError { + if firmwareVersion == nil { + if case .version(let version) = CLIResponse.parse(response, forQuery: "ver") { + self.firmwareVersion = version + self.deviceInfoError = false + logger.info("Late response: received firmware version") + return true + } + } + + if deviceTimeUTC == nil { + if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { + self.deviceTimeUTC = time + self.deviceInfoError = false + logger.info("Late response: received device time") + return true + } + } + } + + // Identity settings (lat/lon before name to avoid numeric capture) + if !isLoadingIdentity && identityError { + if originalLatitude == nil { + if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { + self.latitude = lat + self.originalLatitude = lat + self.identityError = false + logger.info("Late response: received latitude") + return true + } + } + + if originalLongitude == nil { + if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { + self.longitude = lon + self.originalLongitude = lon + self.identityError = false + logger.info("Late response: received longitude") + return true + } + } + + if originalName == nil { + if case .name(let n) = CLIResponse.parse(response, forQuery: "get name") { + self.name = n + self.originalName = n + self.identityError = false + logger.info("Late response: received name") + return true + } + } + } + + // Contact info + if !isLoadingContactInfo && contactInfoError { + if originalOwnerInfo == nil { + if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { + let displayText = info.replacing("|", with: "\n") + self.ownerInfo = displayText + self.originalOwnerInfo = displayText + self.contactInfoError = false + logger.info("Late response: received owner info") + return true + } + } + } + + return false + } +} + +// MARK: - Shared Error Type + +enum NodeSettingsError: LocalizedError { + case noService + + var errorDescription: String? { + switch self { + case .noService: return L10n.RemoteNodes.RemoteNodes.Settings.noService + } + } +} diff --git a/MC1/Views/RemoteNodes/NodeStatusHelper.swift b/MC1/Views/RemoteNodes/NodeStatusHelper.swift new file mode 100644 index 000000000..87079b901 --- /dev/null +++ b/MC1/Views/RemoteNodes/NodeStatusHelper.swift @@ -0,0 +1,423 @@ +import OSLog +import MC1Services +import SwiftUI + +private let logger = Logger(subsystem: "com.mc1", category: "NodeStatusHelper") + +/// Shared logic for repeater and room status view models. +/// Owns retry machinery, display formatters, delta properties, OCV settings, +/// telemetry handling, and snapshot persistence. +@Observable +@MainActor +final class NodeStatusHelper { + + // MARK: - Properties + + /// Current session + var session: RemoteNodeSessionDTO? + + /// Last received status + var status: RemoteNodeStatus? + + /// Last received telemetry + var telemetry: TelemetryResponse? + + /// Cached decoded data points to avoid repeated LPP decoding. + private(set) var cachedDataPoints: [LPPDataPoint] = [] + + /// Loading states + var isLoadingStatus = false + var isLoadingTelemetry = false + + /// Whether telemetry has been loaded at least once (for refresh logic) + var telemetryLoaded = false + + /// Whether the telemetry disclosure group is expanded + var telemetryExpanded = false + + /// Error message if any + var errorMessage: String? + + // MARK: - OCV Curve Properties + + var isBatteryCurveExpanded = false + var selectedOCVPreset: OCVPreset = .liIon + var ocvValues: [Int] = OCVPreset.liIon.ocvArray + var ocvError: String? + private var contactID: UUID? + + // MARK: - Dependencies + + private var contactService: ContactService? + private(set) var nodeSnapshotService: NodeSnapshotService? + + // MARK: - Snapshot State + + /// ID of the current session's snapshot (for enrichment). + /// Because `handleStatusResponse` suspends while saving the snapshot, + /// telemetry handlers may fire before this is set. + /// In that case, enrichment data is buffered in `pendingTelemetryEntries` + /// and flushed once the ID is available. + private var currentSnapshotID: UUID? + + /// Buffered enrichment data received before `currentSnapshotID` was set. + private var pendingTelemetryEntries: [TelemetrySnapshotEntry]? + + /// Previous snapshot for delta display + private(set) var previousSnapshot: NodeStatusSnapshotDTO? + + // MARK: - Initialization + + func configure(contactService: ContactService?, nodeSnapshotService: NodeSnapshotService?) { + self.contactService = contactService + self.nodeSnapshotService = nodeSnapshotService + } + + // MARK: - Transient Retry Machinery + + private static let requestTimeout: Duration = RemoteOperationTimeoutPolicy.binaryMaximum + + private static let transientRetryDelays: [Duration] = [ + .milliseconds(500), + .seconds(1), + .seconds(2), + ] + + func isTransientError(_ error: Error) -> Bool { + guard let remoteError = error as? RemoteNodeError, + case .sessionError(let meshError) = remoteError, + case .deviceError(let code) = meshError else { + return false + } + return code == 10 + } + + private func remainingBudget(until deadline: ContinuousClock.Instant) -> Duration? { + let remaining = deadline - .now + return remaining > .zero ? remaining : nil + } + + private func waitForRetry(delay: Duration, until deadline: ContinuousClock.Instant) async throws { + guard let remaining = remainingBudget(until: deadline) else { + throw RemoteNodeError.timeout + } + try await Task.sleep(for: min(delay, remaining)) + } + + func performWithTransientRetries( + operationName: String, + operation: @escaping @Sendable (Duration) async throws -> T + ) async throws -> T { + let deadline = ContinuousClock.now.advanced(by: Self.requestTimeout) + var delayIterator = Self.transientRetryDelays.makeIterator() + + while true { + guard let timeout = remainingBudget(until: deadline) else { + logger.warning("\(operationName, privacy: .public) request exhausted its shared timeout budget") + throw RemoteNodeError.timeout + } + + do { + return try await operation(timeout) + } catch { + guard isTransientError(error), let delay = delayIterator.next() else { + throw error + } + try await waitForRetry(delay: delay, until: deadline) + } + } + } + + // MARK: - Status Response Handling + + /// Handle a status response, saving a snapshot with role-specific fields. + /// `rxAirtimeSeconds` and `receiveErrors` are present in all wire frames + /// but rooms pass `nil` to skip persistence of repeater-specific metrics. + func handleStatusResponse( + _ response: RemoteNodeStatus, + rxAirtimeSeconds: UInt32? = nil, + receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil + ) async { + guard let expectedPrefix = session?.publicKeyPrefix, + response.publicKeyPrefix == expectedPrefix else { + return + } + self.status = response + self.isLoadingStatus = false + + guard let nodeSnapshotService, let session else { return } + + let prev = await nodeSnapshotService.previousSnapshot( + for: session.publicKey, + before: .now + ) + self.previousSnapshot = prev + + let snapshotID = await nodeSnapshotService.saveStatusSnapshot( + nodePublicKey: session.publicKey, + batteryMillivolts: response.batteryMillivolts, + lastSNR: response.lastSNR, + lastRSSI: Int16(clamping: response.lastRSSI), + noiseFloor: Int16(clamping: response.noiseFloor), + uptimeSeconds: response.uptimeSeconds, + rxAirtimeSeconds: rxAirtimeSeconds, + packetsSent: response.packetsSent, + packetsReceived: response.packetsReceived, + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount + ) + if let snapshotID { + self.currentSnapshotID = snapshotID + } else if let prevID = prev?.id { + self.currentSnapshotID = prevID + } + + if let enrichmentTarget = self.currentSnapshotID { + if let pending = pendingTelemetryEntries { + pendingTelemetryEntries = nil + Task { await nodeSnapshotService.enrichWithTelemetry(pending, snapshotID: enrichmentTarget) } + } + } + } + + /// Flush buffered neighbor enrichment data. Called by repeater VM after + /// status response sets `currentSnapshotID`. + func flushPendingNeighborEntries(_ entries: [NeighborSnapshotEntry]) { + guard let snapshotID = currentSnapshotID else { return } + Task { await nodeSnapshotService?.enrichWithNeighbors(entries, snapshotID: snapshotID) } + } + + /// Enrich the current snapshot with neighbor data, or return `false` if + /// the snapshot ID isn't ready yet (caller should buffer). + func enrichWithNeighbors(_ entries: [NeighborSnapshotEntry]) -> Bool { + guard let snapshotID = currentSnapshotID else { return false } + Task { await nodeSnapshotService?.enrichWithNeighbors(entries, snapshotID: snapshotID) } + return true + } + + // MARK: - Telemetry Response Handling + + func handleTelemetryResponse(_ response: TelemetryResponse) { + guard let expectedPrefix = session?.publicKeyPrefix, + response.publicKeyPrefix == expectedPrefix else { + return + } + self.telemetry = response + self.cachedDataPoints = response.dataPoints + self.isLoadingTelemetry = false + self.telemetryLoaded = true + + let entries: [TelemetrySnapshotEntry] = cachedDataPoints.compactMap { dp in + let numericValue: Double? + switch dp.value { + case .float(let value): + numericValue = value + case .integer(let value): + numericValue = Double(value) + default: + numericValue = nil + } + guard let value = numericValue else { return nil } + return TelemetrySnapshotEntry(channel: Int(dp.channel), type: dp.typeName, value: value) + } + if !entries.isEmpty { + if let snapshotID = currentSnapshotID { + Task { await nodeSnapshotService?.enrichWithTelemetry(entries, snapshotID: snapshotID) } + } else { + pendingTelemetryEntries = entries + } + } + } + + // MARK: - Telemetry Grouping + + var hasMultipleChannels: Bool { + let channels = Set(cachedDataPoints.map(\.channel)) + return channels.count > 1 + } + + var groupedDataPoints: [(channel: UInt8, dataPoints: [LPPDataPoint])] { + Dictionary(grouping: cachedDataPoints, by: \.channel) + .sorted { $0.key < $1.key } + .map { (channel: $0.key, dataPoints: $0.value) } + } + + // MARK: - Display Formatters + + static let emDash = "—" + private static let secondsPerMinute: UInt32 = 60 + private static let secondsPerHour: UInt32 = 3_600 + private static let secondsPerDay: UInt32 = 86_400 + + var uptimeDisplay: String { + guard let uptime = status?.uptimeSeconds else { return Self.emDash } + let days = Int(uptime / Self.secondsPerDay) + let hours = Int((uptime % Self.secondsPerDay) / Self.secondsPerHour) + let minutes = Int((uptime % Self.secondsPerHour) / Self.secondsPerMinute) + + if days > 0 { + if days == 1 { + return L10n.RemoteNodes.RemoteNodes.Status.uptime1Day(hours, minutes) + } else { + return L10n.RemoteNodes.RemoteNodes.Status.uptimeDays(days, hours, minutes) + } + } else if hours > 0 { + return L10n.RemoteNodes.RemoteNodes.Status.uptimeHours(hours, minutes) + } + return L10n.RemoteNodes.RemoteNodes.Status.uptimeMinutes(minutes) + } + + var batteryDisplay: String { + guard let mv = status?.batteryMillivolts else { return Self.emDash } + let volts = Double(mv) / 1000.0 + let battery = BatteryInfo(level: Int(mv)) + let percent = battery.percentage(using: ocvValues) + return "\(volts.formatted(.number.precision(.fractionLength(2))))V (\(percent)%)" + } + + var lastRSSIDisplay: String { + guard let rssi = status?.lastRSSI else { return Self.emDash } + return "\(rssi) dBm" + } + + var lastSNRDisplay: String { + guard let snr = status?.lastSNR else { return Self.emDash } + return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB" + } + + var noiseFloorDisplay: String { + guard let nf = status?.noiseFloor else { return Self.emDash } + return "\(nf) dBm" + } + + var packetsSentDisplay: String { + guard let count = status?.packetsSent else { return Self.emDash } + return count.formatted() + } + + var packetsReceivedDisplay: String { + guard let count = status?.packetsReceived else { return Self.emDash } + return count.formatted() + } + + // MARK: - Delta Display + + var previousSnapshotTimestamp: String? { + guard let prev = previousSnapshot else { return nil } + let interval = prev.timestamp.distance(to: .now) + let secondsPerHour = TimeInterval(Self.secondsPerHour) + let secondsPerDay = TimeInterval(Self.secondsPerDay) + if interval < secondsPerHour { + return L10n.RemoteNodes.RemoteNodes.History.vsMinutesAgo(Int(interval / 60)) + } else if interval < secondsPerDay { + return L10n.RemoteNodes.RemoteNodes.History.vsHoursAgo(Int(interval / secondsPerHour)) + } else { + return L10n.RemoteNodes.RemoteNodes.History.vsDate(prev.timestamp.formatted(.dateTime.month().day())) + } + } + + var batteryDeltaMV: Int? { + guard let current = status?.batteryMillivolts, + let previous = previousSnapshot?.batteryMillivolts else { return nil } + return Int(current) - Int(previous) + } + + var snrDelta: Double? { + guard let current = status?.lastSNR, + let previous = previousSnapshot?.lastSNR else { return nil } + return current - previous + } + + var rssiDelta: Int? { + guard let current = status?.lastRSSI, + let previous = previousSnapshot?.lastRSSI else { return nil } + return Int(current) - Int(previous) + } + + var noiseFloorDelta: Int? { + guard let current = status?.noiseFloor, + let previous = previousSnapshot?.noiseFloor else { return nil } + return Int(current) - Int(previous) + } + + // MARK: - History + + func fetchHistory() async -> [NodeStatusSnapshotDTO] { + guard let nodeSnapshotService, let session else { + logger.warning("fetchHistory: nodeSnapshotService or session is nil") + return [] + } + return await nodeSnapshotService.fetchSnapshots(for: session.publicKey) + } + + // MARK: - OCV Settings + + /// Load OCV settings for a contact by public key. Skips reload if already loaded. + func loadOCVSettings(publicKey: Data, deviceID: UUID) async { + guard contactID == nil else { return } + guard let contactService else { return } + + do { + if let contact = try await contactService.getContact(deviceID: deviceID, publicKey: publicKey) { + contactID = contact.id + + if let presetName = contact.ocvPreset { + if presetName == OCVPreset.custom.rawValue, let customString = contact.customOCVArrayString { + let parsed = customString.split(separator: ",") + .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if parsed.count == 11 { + ocvValues = parsed + selectedOCVPreset = .custom + return + } + } + if let preset = OCVPreset(rawValue: presetName) { + selectedOCVPreset = preset + ocvValues = preset.ocvArray + return + } + } + + selectedOCVPreset = .liIon + ocvValues = OCVPreset.liIon.ocvArray + } + } catch { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvLoadFailed + } + } + + func saveOCVSettings(preset: OCVPreset, values: [Int]) async { + guard let contactService, + let contactID else { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveNoContact + return + } + + ocvError = nil + + do { + if preset == .custom { + let customString = values.map(String.init).joined(separator: ",") + try await contactService.updateContactOCVSettings( + contactID: contactID, + preset: OCVPreset.custom.rawValue, + customArray: customString + ) + } else { + try await contactService.updateContactOCVSettings( + contactID: contactID, + preset: preset.rawValue, + customArray: nil + ) + } + + selectedOCVPreset = preset + ocvValues = values + } catch { + ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveFailed(error.localizedDescription) + } + } +} diff --git a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift index a97cca7f5..489f39679 100644 --- a/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift +++ b/MC1/Views/RemoteNodes/NodeStatusHistoryView.swift @@ -16,12 +16,13 @@ struct NodeStatusHistoryView: View { } var body: some View { + let filtered = filteredSnapshots List { HistoryTimeRangePicker(selection: $timeRange) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.battery, unit: "V", color: .mint, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.batteryMillivolts.map { .init(id: s.id, date: s.timestamp, value: Double($0) / 1000.0) } }, yAxisDomain: ocvArray.voltageChartDomain() @@ -29,46 +30,60 @@ struct NodeStatusHistoryView: View { metricSection( title: L10n.RemoteNodes.RemoteNodes.History.snr, unit: "dB", color: .blue, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.lastSNR.map { .init(id: s.id, date: s.timestamp, value: $0) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.rssi, unit: "dBm", color: .purple, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.lastRSSI.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.noiseFloor, unit: "dBm", color: .indigo, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.noiseFloor.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.packetsSent, unit: "", color: .green, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.packetsSent.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.packetsReceived, unit: "", color: .orange, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.packetsReceived.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) metricSection( title: L10n.RemoteNodes.RemoteNodes.History.receiveErrors, unit: "", color: .red, - dataPoints: filteredSnapshots.compactMap { s in + dataPoints: filtered.compactMap { s in s.receiveErrors.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) + metricSection( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, unit: "", color: .purple, + dataPoints: filtered.compactMap { s in + s.postedCount.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + + metricSection( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, unit: "", color: .mint, + dataPoints: filtered.compactMap { s in + s.postPushCount.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + Section { } footer: { Text(L10n.RemoteNodes.RemoteNodes.History.retentionNotice) diff --git a/MC1/Views/RemoteNodes/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/RepeaterSettingsView.swift index aaf5334de..7d12800a0 100644 --- a/MC1/Views/RemoteNodes/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/RepeaterSettingsView.swift @@ -2,41 +2,26 @@ import SwiftUI import MC1Services import CoreLocation -private enum SettingsField: Hashable { - case frequency - case txPower - case advertInterval - case floodAdvertInterval - case floodMaxHops - case identityName - case contactInfo -} - struct RepeaterSettingsView: View { @Environment(\.appState) private var appState @Environment(\.dismiss) private var dismiss - @FocusState private var focusedField: SettingsField? + @FocusState private var focusedField: NodeSettingsField? let session: RemoteNodeSessionDTO @State private var viewModel = RepeaterSettingsViewModel() @State private var showRebootConfirmation = false @State private var showingLocationPicker = false - /// Bandwidth options in kHz for CLI protocol (derived from RadioOptions.bandwidthsHz) - private var bandwidthOptionsKHz: [Double] { - RadioOptions.bandwidthsHz.map { Double($0) / 1000.0 } - } - var body: some View { Form { - SettingsHeaderSection(publicKey: session.publicKey, name: session.name) + NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) + makeDeviceInfoSection() makeRadioSettingsSection() makeIdentitySection() makeContactInfoSection() makeBehaviorSection() makeRegionsSection() makeSecuritySection() - makeDeviceInfoSection() makeActionsSection() } .navigationTitle(L10n.RemoteNodes.RemoteNodes.Settings.title) @@ -57,19 +42,19 @@ struct RepeaterSettingsView: View { await viewModel.cleanup() } } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.showSuccessAlert) { + .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) { } } message: { - Text(viewModel.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) + Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) } .sheet(isPresented: $showingLocationPicker) { LocationPickerView( initialCoordinate: CLLocationCoordinate2D( - latitude: viewModel.latitude ?? 0, - longitude: viewModel.longitude ?? 0 + latitude: viewModel.helper.latitude ?? 0, + longitude: viewModel.helper.longitude ?? 0 ) ) { coordinate in - viewModel.setLocationFromPicker( + viewModel.helper.setLocationFromPicker( latitude: coordinate.latitude, longitude: coordinate.longitude ) @@ -80,27 +65,26 @@ struct RepeaterSettingsView: View { // MARK: - Subviews private func makeDeviceInfoSection() -> some View { - DeviceInfoSection(viewModel: viewModel) + NodeDeviceInfoSection(settings: viewModel.helper) } private func makeRadioSettingsSection() -> some View { - RadioSettingsSection( - viewModel: viewModel, - focusedField: $focusedField, - bandwidthOptionsKHz: bandwidthOptionsKHz + NodeRadioSettingsSection( + settings: viewModel.helper, + focusedField: $focusedField ) } private func makeIdentitySection() -> some View { - IdentitySection( - viewModel: viewModel, + RemoteNodeIdentitySection( + settings: viewModel.helper, focusedField: $focusedField, onPickLocation: { showingLocationPicker = true } ) } private func makeContactInfoSection() -> some View { - ContactInfoSection(viewModel: viewModel, focusedField: $focusedField) + NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) } private func makeBehaviorSection() -> some View { @@ -112,414 +96,22 @@ struct RepeaterSettingsView: View { } private func makeSecuritySection() -> some View { - SecuritySection(viewModel: viewModel) + NodeSecuritySection(settings: viewModel.helper) } private func makeActionsSection() -> some View { - ActionsSection( - viewModel: viewModel, + NodeActionsSection( + settings: viewModel.helper, showRebootConfirmation: $showRebootConfirmation ) } } -// MARK: - Settings Header Section - -private struct SettingsHeaderSection: View { - let publicKey: Data - let name: String - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 8) { - NodeAvatar(publicKey: publicKey, role: .repeater, size: 60) - Text(name) - .font(.headline) - } - Spacer() - } - .listRowBackground(Color.clear) - } - } -} - -// MARK: - Device Info Section - -private struct DeviceInfoSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfo, - icon: "info.circle", - isExpanded: $viewModel.isDeviceInfoExpanded, - isLoaded: { viewModel.deviceInfoLoaded }, - isLoading: $viewModel.isLoadingDeviceInfo, - error: $viewModel.deviceInfoError, - onLoad: { await viewModel.fetchDeviceInfo() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfoFooter - ) { - LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: viewModel.firmwareVersion ?? "\u{2014}") - LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: viewModel.deviceTime ?? "\u{2014}") - } - } -} - -// MARK: - Radio Settings Section - -private struct RadioSettingsSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding - let bandwidthOptionsKHz: [Double] - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.radioParameters, - icon: "antenna.radiowaves.left.and.right", - isExpanded: $viewModel.isRadioExpanded, - isLoaded: { viewModel.radioLoaded }, - isLoading: $viewModel.isLoadingRadio, - error: $viewModel.radioError, - onLoad: { await viewModel.fetchRadioSettings() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.radioFooter - ) { - if viewModel.radioSettingsModified { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - Text(L10n.RemoteNodes.RemoteNodes.Settings.radioRestartWarning) - .font(.subheadline) - } - .padding() - .frame(maxWidth: .infinity) - .background(.yellow.opacity(0.1)) - .clipShape(.rect(cornerRadius: 8)) - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.frequencyMHz) - Spacer() - if let frequency = viewModel.frequency { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.mhz, value: Binding( - get: { frequency }, - set: { viewModel.frequency = $0 } - ), format: .number.precision(.fractionLength(3)).locale(.posix)) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 100) - .focused(focusedField, equals: .frequency) - .onChange(of: viewModel.frequency) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 100, alignment: .trailing) - } - } - - if let bandwidth = viewModel.bandwidth { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz, selection: Binding( - get: { bandwidth }, - set: { viewModel.bandwidth = $0 } - )) { - ForEach(bandwidthOptionsKHz, id: \.self) { bwKHz in - Text(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000))) - .tag(bwKHz) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000)))) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthHint) - .onChange(of: viewModel.bandwidth) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz) - Spacer() - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if let spreadingFactor = viewModel.spreadingFactor { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor, selection: Binding( - get: { spreadingFactor }, - set: { viewModel.spreadingFactor = $0 } - )) { - ForEach(RadioOptions.spreadingFactors, id: \.self) { sf in - Text(sf, format: .number) - .tag(sf) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.spreadingFactorLabel(sf)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactorHint) - .onChange(of: viewModel.spreadingFactor) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor) - Spacer() - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if let codingRate = viewModel.codingRate { - Picker(L10n.RemoteNodes.RemoteNodes.Settings.codingRate, selection: Binding( - get: { codingRate }, - set: { viewModel.codingRate = $0 } - )) { - ForEach(RadioOptions.codingRates, id: \.self) { cr in - Text("\(cr)") - .tag(cr) - .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.codingRateLabel(cr)) - } - } - .pickerStyle(.menu) - .tint(.primary) - .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.codingRateHint) - .onChange(of: viewModel.codingRate) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.codingRate) - Spacer() - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.txPowerDbm) - Spacer() - if let txPower = viewModel.txPower { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.dbm, value: Binding( - get: { txPower }, - set: { viewModel.txPower = $0 } - ), format: .number) - .keyboardType(.numbersAndPunctuation) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused(focusedField, equals: .txPower) - .onChange(of: viewModel.txPower) { _, _ in - viewModel.radioSettingsModified = true - } - } else { - Text(viewModel.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.radioError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 60, alignment: .trailing) - } - } - - Button { - Task { await viewModel.applyRadioSettings() } - } label: { - HStack { - Spacer() - if viewModel.isApplying { - ProgressView() - } else { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyRadioSettings) - .foregroundStyle(viewModel.radioSettingsModified ? Color.accentColor : .secondary) - } - Spacer() - } - } - .disabled(viewModel.isApplying || !viewModel.radioSettingsModified) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - } - } -} - -// MARK: - Identity Section - -private struct IdentitySection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding - let onPickLocation: () -> Void - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.identityLocation, - icon: "person.text.rectangle", - isExpanded: $viewModel.isIdentityExpanded, - isLoaded: { viewModel.identityLoaded }, - isLoading: $viewModel.isLoadingIdentity, - error: $viewModel.identityError, - onLoad: { await viewModel.fetchIdentity() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.identityFooter - ) { - if viewModel.isLoadingIdentity && viewModel.name == nil { - HStack { - Text(L10n.RemoteNodes.RemoteNodes.name) - .foregroundStyle(.secondary) - Spacer() - Text(L10n.RemoteNodes.RemoteNodes.Settings.loading) - .font(.caption) - .foregroundStyle(.secondary) - } - } else { - TextField(L10n.RemoteNodes.RemoteNodes.name, text: Binding( - get: { viewModel.name ?? "" }, - set: { viewModel.name = $0 } - )) - .textContentType(.name) - .submitLabel(.done) - .focused(focusedField, equals: .identityName) - .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.latitude) - Spacer() - if let latitude = viewModel.latitude { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.lat, value: Binding( - get: { latitude }, - set: { viewModel.latitude = $0 } - ), format: .number.precision(.fractionLength(6))) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 120) - } else { - Text(viewModel.isLoadingIdentity ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.identityError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 120, alignment: .trailing) - } - } - - HStack { - Text(L10n.RemoteNodes.RemoteNodes.Settings.longitude) - Spacer() - if let longitude = viewModel.longitude { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.lon, value: Binding( - get: { longitude }, - set: { viewModel.longitude = $0 } - ), format: .number.precision(.fractionLength(6))) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 120) - } else { - Text(viewModel.isLoadingIdentity ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.identityError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 120, alignment: .trailing) - } - } - - Button { - onPickLocation() - } label: { - Label(L10n.RemoteNodes.RemoteNodes.Settings.pickOnMap, systemImage: "mappin.and.ellipse") - } - .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } - - Button { - Task { await viewModel.applyIdentitySettings() } - } label: { - HStack { - Spacer() - if viewModel.isApplying { - ProgressView() - } else if viewModel.identityApplySuccess { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.scale.combined(with: .opacity)) - } else { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyIdentitySettings) - .foregroundStyle(viewModel.identitySettingsModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - Spacer() - } - .animation(.default, value: viewModel.identityApplySuccess) - } - .disabled(viewModel.isApplying || viewModel.identityApplySuccess || !viewModel.identitySettingsModified) - } - } -} - -// MARK: - Contact Info Section - -private struct ContactInfoSection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding - - private static let maxCharacters = 119 - - var body: some View { - ExpandableSettingsSection( - title: L10n.RemoteNodes.RemoteNodes.Settings.contactInfo, - icon: "person.crop.rectangle", - isExpanded: $viewModel.isContactInfoExpanded, - isLoaded: { viewModel.contactInfoLoaded }, - isLoading: $viewModel.isLoadingContactInfo, - error: $viewModel.contactInfoError, - onLoad: { await viewModel.fetchContactInfo() }, - footer: L10n.RemoteNodes.RemoteNodes.Settings.contactInfoFooter - ) { - TextField( - L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, - text: Binding( - get: { viewModel.ownerInfo ?? "" }, - set: { viewModel.ownerInfo = $0 } - ), - axis: .vertical - ) - .lineLimit(3...6) - .focused(focusedField, equals: .contactInfo) - .overlay(alignment: .bottomTrailing) { - let count = viewModel.ownerInfoCharCount - Text(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoCharCount(count)) - .font(.caption2) - .foregroundStyle(count > Self.maxCharacters ? Color.red : Color.secondary.opacity(0.6)) - .padding(4) - } - - Button { - Task { await viewModel.applyContactInfoSettings() } - } label: { - HStack { - Spacer() - if viewModel.isApplying { - ProgressView() - } else if viewModel.contactInfoApplySuccess { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .transition(.scale.combined(with: .opacity)) - } else { - Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) - .foregroundStyle(viewModel.contactInfoSettingsModified ? Color.accentColor : .secondary) - .transition(.opacity) - } - Spacer() - } - .animation(.default, value: viewModel.contactInfoApplySuccess) - } - .disabled(viewModel.isApplying || viewModel.contactInfoApplySuccess || !viewModel.contactInfoSettingsModified || viewModel.ownerInfoCharCount > Self.maxCharacters) - } - } -} - // MARK: - Behavior Section private struct BehaviorSection: View { @Bindable var viewModel: RepeaterSettingsViewModel - var focusedField: FocusState.Binding + var focusedField: FocusState.Binding var body: some View { ExpandableSettingsSection( @@ -528,7 +120,7 @@ private struct BehaviorSection: View { isExpanded: $viewModel.isBehaviorExpanded, isLoaded: { viewModel.behaviorLoaded }, isLoading: $viewModel.isLoadingBehavior, - error: $viewModel.behaviorError, + hasError: $viewModel.behaviorError, onLoad: { await viewModel.fetchBehaviorSettings() }, footer: L10n.RemoteNodes.RemoteNodes.Settings.behaviorFooter ) { @@ -560,7 +152,7 @@ private struct BehaviorSection: View { Text(L10n.RemoteNodes.RemoteNodes.Settings.min) .foregroundStyle(.secondary) } else { - Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) .font(.caption) .foregroundStyle(.secondary) } @@ -587,7 +179,7 @@ private struct BehaviorSection: View { Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) .foregroundStyle(.secondary) } else { - Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) .font(.caption) .foregroundStyle(.secondary) } @@ -614,7 +206,7 @@ private struct BehaviorSection: View { Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) .foregroundStyle(.secondary) } else { - Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError != nil ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + Text(viewModel.isLoadingBehavior ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.behaviorError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) .font(.caption) .foregroundStyle(.secondary) } @@ -631,7 +223,7 @@ private struct BehaviorSection: View { } label: { HStack { Spacer() - if viewModel.isApplying { + if viewModel.helper.isApplying { ProgressView() } else if viewModel.behaviorApplySuccess { Image(systemName: "checkmark.circle.fill") @@ -646,7 +238,7 @@ private struct BehaviorSection: View { } .animation(.default, value: viewModel.behaviorApplySuccess) } - .disabled(viewModel.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) + .disabled(viewModel.helper.isApplying || viewModel.behaviorApplySuccess || !viewModel.behaviorSettingsModified) } } } @@ -679,7 +271,7 @@ private struct RegionsSection: View { isExpanded: $viewModel.isRegionsExpanded, isLoaded: { viewModel.regionsLoaded }, isLoading: $viewModel.isLoadingRegions, - error: $viewModel.regionsError, + hasError: $viewModel.regionsError, onLoad: { await viewModel.fetchRegions() }, footer: L10n.RemoteNodes.RemoteNodes.Settings.regionsFooter ) { @@ -725,7 +317,7 @@ private struct RegionsSection: View { : region.name ) .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.Regions.floodToggleHint) - .disabled(viewModel.isApplying) + .disabled(viewModel.helper.isApplying) } .onDelete { offsets in let sorted = sortedRegions @@ -740,7 +332,7 @@ private struct RegionsSection: View { Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { viewModel.isAddingRegion = true } - .disabled(viewModel.isApplying) + .disabled(viewModel.helper.isApplying) // Save to device button if viewModel.regionsLoaded { @@ -749,7 +341,7 @@ private struct RegionsSection: View { } label: { HStack { Spacer() - if viewModel.isApplying { + if viewModel.helper.isApplying { ProgressView() } else if viewModel.regionsSaveSuccess { Image(systemName: "checkmark.circle.fill") @@ -764,7 +356,7 @@ private struct RegionsSection: View { } .animation(.default, value: viewModel.regionsSaveSuccess) } - .disabled(viewModel.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) + .disabled(viewModel.helper.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) } } .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $viewModel.isAddingRegion) { @@ -781,69 +373,6 @@ private struct RegionsSection: View { } } -// MARK: - Security Section - -private struct SecuritySection: View { - @Bindable var viewModel: RepeaterSettingsViewModel - - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.isSecurityExpanded) { - SecureField(L10n.RemoteNodes.RemoteNodes.Settings.newPassword, text: $viewModel.newPassword) - SecureField(L10n.RemoteNodes.RemoteNodes.Settings.confirmPassword, text: $viewModel.confirmPassword) - - Button(L10n.RemoteNodes.RemoteNodes.Settings.changePassword) { - Task { await viewModel.changePassword() } - } - .disabled(viewModel.isApplying || viewModel.newPassword.isEmpty || viewModel.newPassword != viewModel.confirmPassword) - } label: { - Label(L10n.RemoteNodes.RemoteNodes.Settings.security, systemImage: "lock") - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Settings.securityFooter) - } - } -} - -// MARK: - Actions Section - -private struct ActionsSection: View { - let viewModel: RepeaterSettingsViewModel - @Binding var showRebootConfirmation: Bool - - var body: some View { - Section(L10n.RemoteNodes.RemoteNodes.Settings.deviceActions) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.sendAdvert) { - Task { await viewModel.forceAdvert() } - } - - Button(L10n.RemoteNodes.RemoteNodes.Settings.syncTime) { - Task { await viewModel.syncTime() } - } - .disabled(viewModel.isApplying) - - Button(L10n.RemoteNodes.RemoteNodes.Settings.rebootDevice, role: .destructive) { - showRebootConfirmation = true - } - .disabled(viewModel.isRebooting) - .confirmationDialog(L10n.RemoteNodes.RemoteNodes.Settings.rebootConfirmTitle, isPresented: $showRebootConfirmation) { - Button(L10n.RemoteNodes.RemoteNodes.Settings.reboot, role: .destructive) { - Task { await viewModel.reboot() } - } - Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { } - } message: { - Text(L10n.RemoteNodes.RemoteNodes.Settings.rebootMessage) - } - - if let error = viewModel.errorMessage { - Text(error) - .foregroundStyle(.red) - .font(.caption) - } - } - } -} - #Preview { NavigationStack { RepeaterSettingsView( diff --git a/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift index 9d5fb321c..a9335d062 100644 --- a/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/RepeaterSettingsViewModel.swift @@ -6,88 +6,12 @@ import OSLog @MainActor final class RepeaterSettingsViewModel { - // MARK: - Properties + // MARK: - Shared Helper - var session: RemoteNodeSessionDTO? + var helper = NodeSettingsHelper() - // Device info (read-only from ver/clock) - var firmwareVersion: String? - private var deviceTimeUTC: String? - var isLoadingDeviceInfo = false - var deviceInfoError: String? - var deviceInfoLoaded: Bool { deviceTimeUTC != nil } + // MARK: - Repeater-Only: Behavior Settings - /// Device time converted to user's local timezone and locale - var deviceTime: String? { - guard let utcString = deviceTimeUTC else { return nil } - return Self.convertUTCToLocal(utcString) - } - - /// Convert UTC time string (e.g., "06:40 - 18/4/2025 UTC") to local time using user's locale - private static func convertUTCToLocal(_ utcString: String) -> String { - // Format: "HH:mm - d/M/yyyy UTC" - let pattern = #"(\d{1,2}:\d{2}) - (\d{1,2}/\d{1,2}/\d{4}) UTC"# - guard let regex = try? Regex(pattern), - let match = utcString.firstMatch(of: regex), - match.count >= 3 else { - return utcString - } - - let timeStr = String(match[1].substring ?? "") - let dateStr = String(match[2].substring ?? "") - - let inputFormatter = DateFormatter() - inputFormatter.dateFormat = "HH:mm d/M/yyyy" - inputFormatter.timeZone = TimeZone(identifier: "UTC") - - guard let date = inputFormatter.date(from: "\(timeStr) \(dateStr)") else { - return utcString - } - - let timeString = date.formatted(date: .omitted, time: .shortened) - let dateString = date.formatted(.dateTime.year(.twoDigits).month(.twoDigits).day(.twoDigits)) - return "\(timeString) - \(dateString)" - } - - // Identity settings (from get name, get lat, get lon) - var name: String? - var latitude: Double? - var longitude: Double? - private var originalName: String? - private var originalLatitude: Double? - private var originalLongitude: Double? - var isLoadingIdentity = false - var identityError: String? - var identityLoaded: Bool { originalLatitude != nil || originalLongitude != nil } - - // Radio settings (from get radio, get tx) - var frequency: Double? - var bandwidth: Double? - var spreadingFactor: Int? - var codingRate: Int? - var txPower: Int? - var isLoadingRadio = false - var radioError: String? - var radioLoaded: Bool { frequency != nil || txPower != nil } - - // Contact info settings (from get owner.info) - var ownerInfo: String? - private var originalOwnerInfo: String? - var isLoadingContactInfo = false - var contactInfoError: String? - var contactInfoLoaded: Bool { originalOwnerInfo != nil } - - /// Track if contact info has been modified - var contactInfoSettingsModified: Bool { - ownerInfo != originalOwnerInfo - } - - /// Character count (newlines and pipes are both single characters, so count is the same) - var ownerInfoCharCount: Int { - (ownerInfo ?? "").count - } - - // Behavior settings (from get repeat, get advert.interval, get flood.max) var advertIntervalMinutes: Int? var floodAdvertIntervalHours: Int? var floodMaxHops: Int? @@ -97,66 +21,39 @@ final class RepeaterSettingsViewModel { private var originalFloodMaxHops: Int? private var originalRepeaterEnabled: Bool? var isLoadingBehavior = false - var behaviorError: String? + var behaviorError = false var behaviorLoaded: Bool { repeaterEnabled != nil || advertIntervalMinutes != nil } - // Validation errors for behavior fields var advertIntervalError: String? var floodAdvertIntervalError: String? var floodMaxHopsError: String? - // Region settings (from bare "region" CLI command) + var behaviorApplySuccess = false + + var behaviorSettingsModified: Bool { + (repeaterEnabled != nil && repeaterEnabled != originalRepeaterEnabled) || + (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || + (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || + (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + } + + // MARK: - Repeater-Only: Region Settings + nonisolated static let wildcardName = "*" var regions: [RepeaterRegionEntry] = [] private var originalRegions: [RepeaterRegionEntry]? var isLoadingRegions = false - var regionsError: String? + var regionsError = false var regionsLoaded: Bool { originalRegions != nil } var hasUnsavedRegionChanges = false var isAddingRegion = false var newRegionName = "" var regionsSaveSuccess = false - // Password change (no query available) - var newPassword: String = "" - var confirmPassword: String = "" + // MARK: - Expansion State (repeater-only sections) - // Expansion state for DisclosureGroups - var isDeviceInfoExpanded = false - var isRadioExpanded = false - var isIdentityExpanded = false - var isContactInfoExpanded = false var isBehaviorExpanded = false var isRegionsExpanded = false - var isSecurityExpanded = false - - // State - var isApplying = false - var isRebooting = false - var errorMessage: String? - var successMessage: String? - var showSuccessAlert = false - var identityApplySuccess = false - var behaviorApplySuccess = false - var contactInfoApplySuccess = false - - /// Track if radio settings have been modified (requires restart) - var radioSettingsModified = false - - /// Track if identity settings have been modified - var identitySettingsModified: Bool { - (name != nil && name != originalName) || - (latitude != nil && latitude != originalLatitude) || - (longitude != nil && longitude != originalLongitude) - } - - /// Track if behavior settings have been modified - var behaviorSettingsModified: Bool { - (repeaterEnabled != nil && repeaterEnabled != originalRepeaterEnabled) || - (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || - (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || - (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) - } // MARK: - Dependencies @@ -165,231 +62,110 @@ final class RepeaterSettingsViewModel { // MARK: - Cleanup - /// Cancel any pending operations when view disappears func cleanup() async { - // Clear CLI handler to stop receiving responses await repeaterAdminService?.setCLIHandler { _, _ in } - } - - // MARK: - Synchronous Command-Response - - /// Send a CLI command and wait for its response - /// - Parameters: - /// - command: The CLI command to send (e.g., "get name", "ver") - /// - timeout: Maximum time to wait for response (default 5 seconds) - /// - rawMatching: Use FIFO matching instead of content-based matching. - /// Required for commands whose responses don't match any CLIResponse pattern - /// (e.g., bare `region` tree output, `region home` responses). - /// - Returns: The raw response text from the repeater - /// - Throws: RepeaterSettingsError.timeout if no response received - private func sendAndWait( - _ command: String, - timeout: Duration = .seconds(5), - rawMatching: Bool = false - ) async throws -> String { - guard let session, let service = repeaterAdminService else { - throw RepeaterSettingsError.noService - } - - let response: String - if rawMatching { - response = try await service.sendRawCommand(sessionID: session.id, command: command, timeout: timeout) - } else { - response = try await service.sendCommand(sessionID: session.id, command: command, timeout: timeout) - } - logger.debug("Command '\(command)' response: \(response.prefix(50))") - return response + helper.cleanup() } // MARK: - Configuration func configure(appState: AppState, session: RemoteNodeSessionDTO) async { self.repeaterAdminService = appState.services?.repeaterAdminService - self.session = session - self.name = session.name - // Register CLI handler to receive late responses - await repeaterAdminService?.setCLIHandler { [weak self] message, _ in + guard let repeaterAdminService else { return } + + helper.configure( + session: session, + sendCommand: { [repeaterAdminService] id, cmd, timeout in + try await repeaterAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) + }, + sendRawCommand: { [repeaterAdminService] id, cmd, timeout in + try await repeaterAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) + } + ) + + helper.name = session.name + + helper.onPreFetchNodeInfo = { [weak self] in + await self?.fetchNodeInfo() + } + + // Register CLI handler for late responses + await repeaterAdminService.setCLIHandler { [weak self] message, _ in await MainActor.run { self?.handleLateResponse(message.text) } } - // Pre-fetch firmware version, node name, and owner info via binary protocol await fetchNodeInfo() } private var isLoadingNodeInfo = false - /// Fetch firmware version, node name, and owner info via a single binary request. private func fetchNodeInfo() async { - guard !isLoadingNodeInfo, let session, let repeaterAdminService else { return } + guard !isLoadingNodeInfo, let session = helper.session, let repeaterAdminService else { return } isLoadingNodeInfo = true defer { isLoadingNodeInfo = false } do { let response = try await repeaterAdminService.requestOwnerInfo(sessionID: session.id) - firmwareVersion = response.firmwareVersion - name = response.nodeName - originalName = response.nodeName - ownerInfo = response.ownerInfo - originalOwnerInfo = response.ownerInfo + helper.setNodeInfo( + firmwareVersion: response.firmwareVersion, + name: response.nodeName, + ownerInfo: response.ownerInfo + ) } catch { logger.warning("Failed to fetch node info via binary: \(error)") } } - /// Handle late CLI responses that arrive after timeout - private func handleLateResponse(_ response: String) { - // Only process responses for sections that: - // 1. Have finished loading (not currently loading) - // 2. Had an error (so we're actually expecting late responses) - // This prevents responses from being incorrectly parsed as other field types. - - // Radio settings - only process if finished loading with error - if !isLoadingRadio && radioError != nil { - if frequency == nil { - if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { - self.frequency = freq - self.bandwidth = bw - self.spreadingFactor = sf - self.codingRate = cr - self.radioError = nil - logger.info("Late response: received radio settings") - return - } - } - - if txPower == nil { - if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { - self.txPower = power - self.radioError = nil - logger.info("Late response: received TX power") - return - } - } - } - - // Device info - only process if finished loading with error - if !isLoadingDeviceInfo && deviceInfoError != nil { - if firmwareVersion == nil { - if case .version(let version) = CLIResponse.parse(response, forQuery: "ver") { - self.firmwareVersion = version - self.deviceInfoError = nil - logger.info("Late response: received firmware version") - return - } - } - - if deviceTimeUTC == nil { - if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { - self.deviceTimeUTC = time - self.deviceInfoError = nil - logger.info("Late response: received device time") - return - } - } - } - - // Identity settings - only process if finished loading with error - // Check lat/lon before name: lat/lon require valid Double parsing, - // while name accepts any string and would incorrectly capture numeric values. - if !isLoadingIdentity && identityError != nil { - if originalLatitude == nil { - if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { - self.latitude = lat - self.originalLatitude = lat - self.identityError = nil - logger.info("Late response: received latitude") - return - } - } - - if originalLongitude == nil { - if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { - self.longitude = lon - self.originalLongitude = lon - self.identityError = nil - logger.info("Late response: received longitude") - return - } - } + // MARK: - Late Response Handling - if originalName == nil { - if case .name(let n) = CLIResponse.parse(response, forQuery: "get name") { - self.name = n - self.originalName = n - self.identityError = nil - logger.info("Late response: received name") - return - } - } - } + private func handleLateResponse(_ response: String) { + // Try shared sections first + if helper.handleCommonLateResponse(response) { return } - // Behavior settings - only process if finished loading with error - if !isLoadingBehavior && behaviorError != nil { + // Behavior settings + if !isLoadingBehavior && behaviorError { if originalRepeaterEnabled == nil { if case .repeatMode(let enabled) = CLIResponse.parse(response, forQuery: "get repeat") { self.repeaterEnabled = enabled self.originalRepeaterEnabled = enabled - self.behaviorError = nil + self.behaviorError = false logger.info("Late response: received repeat mode") return } } - if originalAdvertIntervalMinutes == nil { - if case .advertInterval(let interval) = CLIResponse.parse(response, forQuery: "get advert.interval") { + if let result = NodeSettingsHelper.parseBehaviorLateResponse( + response, + hasAdvertInterval: originalAdvertIntervalMinutes != nil, + hasFloodInterval: originalFloodAdvertIntervalHours != nil, + hasFloodMaxHops: originalFloodMaxHops != nil + ) { + switch result { + case .advertInterval(let interval): self.advertIntervalMinutes = interval self.originalAdvertIntervalMinutes = interval - self.behaviorError = nil - logger.info("Late response: received advert interval") - return - } - } - - if originalFloodAdvertIntervalHours == nil { - if case .floodAdvertInterval(let interval) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + case .floodAdvertInterval(let interval): self.floodAdvertIntervalHours = interval self.originalFloodAdvertIntervalHours = interval - self.behaviorError = nil - logger.info("Late response: received flood advert interval") - return - } - } - - if originalFloodMaxHops == nil { - if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + case .floodMax(let hops): self.floodMaxHops = hops self.originalFloodMaxHops = hops - self.behaviorError = nil - logger.info("Late response: received flood max hops") - return - } - } - } - - // Contact info - only process if finished loading with error - if !isLoadingContactInfo && contactInfoError != nil { - if originalOwnerInfo == nil { - if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { - let displayText = info.replacing("|", with: "\n") - self.ownerInfo = displayText - self.originalOwnerInfo = displayText - self.contactInfoError = nil - logger.info("Late response: received owner info") - return } + self.behaviorError = false + return } } - // Regions - only process if finished loading with error - if !isLoadingRegions && regionsError != nil { + // Regions + if !isLoadingRegions && regionsError { if originalRegions == nil { let parsed = Self.parseRegionTree(response) if !parsed.isEmpty { self.regions = parsed self.originalRegions = parsed - self.regionsError = nil + self.regionsError = false logger.info("Late response: received region tree (\(parsed.count) regions)") return } @@ -397,378 +173,84 @@ final class RepeaterSettingsViewModel { } } - // MARK: - Fetch Methods (Pull-to-Load) - - /// Fetch device info (device time; firmware version is pre-fetched via binary) - func fetchDeviceInfo() async { - isLoadingDeviceInfo = true - deviceInfoError = nil - - if firmwareVersion == nil { - await fetchNodeInfo() - } - - // Get device time - do { - let response = try await sendAndWait("clock") - if case .deviceTime(let time) = CLIResponse.parse(response, forQuery: "clock") { - self.deviceTimeUTC = time - logger.debug("Received device time: \(time)") - } - } catch { - if case RemoteNodeError.timeout = error { - deviceInfoError = "error" - } - logger.warning("Failed to get device time: \(error)") - } - - isLoadingDeviceInfo = false - } - - /// Fetch identity settings (latitude, longitude; name is pre-fetched via binary) - func fetchIdentity() async { - isLoadingIdentity = true - identityError = nil - var hadTimeout = false - - if originalName == nil { - await fetchNodeInfo() - } - - // Get latitude - do { - let response = try await sendAndWait("get lat") - if case .latitude(let lat) = CLIResponse.parse(response, forQuery: "get lat") { - self.latitude = lat - self.originalLatitude = lat - logger.debug("Received latitude: \(lat)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get latitude: \(error)") - } - - // Get longitude - do { - let response = try await sendAndWait("get lon") - if case .longitude(let lon) = CLIResponse.parse(response, forQuery: "get lon") { - self.longitude = lon - self.originalLongitude = lon - logger.debug("Received longitude: \(lon)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get longitude: \(error)") - } - - if hadTimeout { - identityError = "error" - } - - isLoadingIdentity = false - } - - /// Fetch radio settings (frequency, bandwidth, SF, CR, TX power) - func fetchRadioSettings() async { - isLoadingRadio = true - radioError = nil - var hadTimeout = false - - // Get TX power first - do { - let response = try await sendAndWait("get tx") - if case .txPower(let power) = CLIResponse.parse(response, forQuery: "get tx") { - self.txPower = power - logger.debug("Received TX power: \(power)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get TX power: \(error)") - } - - // Get radio parameters - do { - let response = try await sendAndWait("get radio") - if case .radio(let freq, let bw, let sf, let cr) = CLIResponse.parse(response, forQuery: "get radio") { - self.frequency = freq - self.bandwidth = bw - self.spreadingFactor = sf - self.codingRate = cr - logger.debug("Received radio: \(freq),\(bw),\(sf),\(cr)") - } - } catch { - if case RemoteNodeError.timeout = error { hadTimeout = true } - logger.warning("Failed to get radio settings: \(error)") - } - - // Show error if any request timed out (even if some succeeded) - if hadTimeout { - radioError = "error" - } - - isLoadingRadio = false - } + // MARK: - Behavior Fetch/Apply - /// Fetch behavior settings (repeat mode, advert intervals, flood max) func fetchBehaviorSettings() async { isLoadingBehavior = true - behaviorError = nil + behaviorError = false var hadTimeout = false - // Get repeat mode do { - let response = try await sendAndWait("get repeat") + let response = try await helper.sendAndWait("get repeat") if case .repeatMode(let enabled) = CLIResponse.parse(response, forQuery: "get repeat") { self.repeaterEnabled = enabled self.originalRepeaterEnabled = enabled - logger.debug("Received repeat mode: \(enabled)") } } catch { if case RemoteNodeError.timeout = error { hadTimeout = true } logger.warning("Failed to get repeat mode: \(error)") } - // Get advert interval do { - let response = try await sendAndWait("get advert.interval") + let response = try await helper.sendAndWait("get advert.interval") if case .advertInterval(let minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { self.advertIntervalMinutes = minutes self.originalAdvertIntervalMinutes = minutes - logger.debug("Received advert interval: \(minutes)") } } catch { if case RemoteNodeError.timeout = error { hadTimeout = true } logger.warning("Failed to get advert interval: \(error)") } - // Get flood advert interval do { - let response = try await sendAndWait("get flood.advert.interval") + let response = try await helper.sendAndWait("get flood.advert.interval") if case .floodAdvertInterval(let hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { self.floodAdvertIntervalHours = hours self.originalFloodAdvertIntervalHours = hours - logger.debug("Received flood advert interval: \(hours) hours") } } catch { if case RemoteNodeError.timeout = error { hadTimeout = true } logger.warning("Failed to get flood advert interval: \(error)") } - // Get flood max do { - let response = try await sendAndWait("get flood.max") + let response = try await helper.sendAndWait("get flood.max") if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { self.floodMaxHops = hops self.originalFloodMaxHops = hops - logger.debug("Received flood max: \(hops)") } } catch { if case RemoteNodeError.timeout = error { hadTimeout = true } logger.warning("Failed to get flood max: \(error)") } - // Show error if any request timed out (even if some succeeded) if hadTimeout { - behaviorError = "error" + behaviorError = true } isLoadingBehavior = false } - /// Fetch contact info (owner.info) - func fetchContactInfo() async { - if originalOwnerInfo == nil { - await fetchNodeInfo() - } - if originalOwnerInfo != nil { return } - - isLoadingContactInfo = true - contactInfoError = nil - - do { - let response = try await sendAndWait("get owner.info") - if case .ownerInfo(let info) = CLIResponse.parse(response, forQuery: "get owner.info") { - let displayText = info.replacing("|", with: "\n") - self.ownerInfo = displayText - self.originalOwnerInfo = displayText - logger.debug("Received owner info: \(info.prefix(50))") - } - } catch { - if case RemoteNodeError.timeout = error { - contactInfoError = "error" - } - logger.warning("Failed to get owner info: \(error)") - } - - isLoadingContactInfo = false - } - - // MARK: - Settings Actions - - /// Apply all radio settings including TX power (requires restart) - func applyRadioSettings() async { - guard let frequency, let bandwidth, let spreadingFactor, let codingRate, let txPower else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioNotLoaded - return - } - - isApplying = true - errorMessage = nil - - do { - var allSucceeded = true - - let radioCommand = "set radio \(frequency),\(bandwidth),\(spreadingFactor),\(codingRate)" - let radioResponse = try await sendAndWait(radioCommand) - if case .ok = CLIResponse.parse(radioResponse) { - // Radio params accepted - } else { - allSucceeded = false - } - - let txCommand = "set tx \(txPower)" - let txResponse = try await sendAndWait(txCommand) - if case .ok = CLIResponse.parse(txResponse) { - // TX power accepted - } else { - allSucceeded = false - } - - if allSucceeded { - radioSettingsModified = false - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioAppliedSuccess - showSuccessAlert = true - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.radioApplyFailed - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - /// Apply only changed identity settings (name, latitude, longitude) - func applyIdentitySettings() async { - isApplying = true - errorMessage = nil - - do { - var allSucceeded = true - - if let name, name != originalName { - let response = try await sendAndWait("set name \(name)") - if case .ok = CLIResponse.parse(response) { - originalName = name - } else { - allSucceeded = false - } - } - - if let latitude, latitude != originalLatitude { - let response = try await sendAndWait("set lat \(latitude)") - if case .ok = CLIResponse.parse(response) { - originalLatitude = latitude - } else { - allSucceeded = false - } - } - - if let longitude, longitude != originalLongitude { - let response = try await sendAndWait("set lon \(longitude)") - if case .ok = CLIResponse.parse(response) { - originalLongitude = longitude - } else { - allSucceeded = false - } - } - - if allSucceeded { - withAnimation { - isApplying = false - identityApplySuccess = true - } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { identityApplySuccess = false } - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - /// Apply contact info (owner.info) - func applyContactInfoSettings() async { - isApplying = true - errorMessage = nil - - do { - let pipeText = (ownerInfo ?? "").replacing("\n", with: "|") - let response = try await sendAndWait("set owner.info \(pipeText)") - if case .ok = CLIResponse.parse(response) { - originalOwnerInfo = ownerInfo - withAnimation { - isApplying = false - contactInfoApplySuccess = true - } - try? await Task.sleep(for: .seconds(1.5)) - withAnimation { contactInfoApplySuccess = false } - return - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - /// Apply only changed behavior settings (repeat mode, intervals, flood max) func applyBehaviorSettings() async { - // Clear previous validation errors - advertIntervalError = nil - floodAdvertIntervalError = nil - floodMaxHopsError = nil - - // Validate 0-hop interval: accepts 0 (disabled) or 60-240 - if let interval = advertIntervalMinutes { - if interval != 0 && (interval < 60 || interval > 240) { - advertIntervalError = L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalValidation - } - } - - // Validate flood interval: accepts 3-48 - if let interval = floodAdvertIntervalHours { - if interval < 3 || interval > 48 { - floodAdvertIntervalError = L10n.RemoteNodes.RemoteNodes.Settings.floodIntervalValidation - } - } - - // Validate flood max hops: accepts 0-64 - if let hops = floodMaxHops { - if hops < 0 || hops > 64 { - floodMaxHopsError = L10n.RemoteNodes.RemoteNodes.Settings.floodMaxValidation - } - } + let validation = NodeSettingsHelper.validateBehaviorFields( + advertInterval: advertIntervalMinutes, + floodInterval: floodAdvertIntervalHours, + floodMaxHops: floodMaxHops + ) + advertIntervalError = validation.advertInterval + floodAdvertIntervalError = validation.floodInterval + floodMaxHopsError = validation.floodMaxHops - // Don't proceed if validation failed - if advertIntervalError != nil || floodAdvertIntervalError != nil || floodMaxHopsError != nil { - return - } + if validation.hasErrors { return } - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { var allSucceeded = true if let repeaterEnabled, repeaterEnabled != originalRepeaterEnabled { - let response = try await sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") + let response = try await helper.sendAndWait("set repeat \(repeaterEnabled ? "on" : "off")") if case .ok = CLIResponse.parse(response) { originalRepeaterEnabled = repeaterEnabled } else { @@ -777,7 +259,7 @@ final class RepeaterSettingsViewModel { } if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { - let response = try await sendAndWait("set advert.interval \(advertIntervalMinutes)") + let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") if case .ok = CLIResponse.parse(response) { originalAdvertIntervalMinutes = advertIntervalMinutes } else { @@ -786,7 +268,7 @@ final class RepeaterSettingsViewModel { } if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { - let response = try await sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") + let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") if case .ok = CLIResponse.parse(response) { originalFloodAdvertIntervalHours = floodAdvertIntervalHours } else { @@ -795,7 +277,7 @@ final class RepeaterSettingsViewModel { } if let floodMaxHops, floodMaxHops != originalFloodMaxHops { - let response = try await sendAndWait("set flood.max \(floodMaxHops)") + let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") if case .ok = CLIResponse.parse(response) { originalFloodMaxHops = floodMaxHops } else { @@ -805,141 +287,36 @@ final class RepeaterSettingsViewModel { if allSucceeded { withAnimation { - isApplying = false + helper.isApplying = false behaviorApplySuccess = true } try? await Task.sleep(for: .seconds(1.5)) withAnimation { behaviorApplySuccess = false } return } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - // MARK: - Location Picker Support - - /// Update location from map picker (triggers modified detection via computed property) - func setLocationFromPicker(latitude: Double, longitude: Double) { - self.latitude = latitude - self.longitude = longitude - } - - /// Change admin password (requires explicit action due to security) - func changePassword() async { - guard !newPassword.isEmpty else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordEmpty - return - } - guard newPassword == confirmPassword else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordMismatch - return - } - - isApplying = true - errorMessage = nil - - do { - let response = try await sendAndWait("password \(newPassword)") - if case .ok = CLIResponse.parse(response) { - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangedSuccess - showSuccessAlert = true - newPassword = "" - confirmPassword = "" - } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.passwordChangeFailed - } - } catch { - errorMessage = error.localizedDescription - } - - isApplying = false - } - - // MARK: - Device Actions - - /// Reboot the repeater - func reboot() async { - guard let session, let service = repeaterAdminService else { return } - - isRebooting = true - errorMessage = nil - - do { - _ = try await service.sendCommand(sessionID: session.id, command: "reboot") - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.rebootSent - showSuccessAlert = true - } catch { - errorMessage = error.localizedDescription - } - - isRebooting = false - } - - /// Force advertisement - func forceAdvert() async { - guard let session, let service = repeaterAdminService else { return } - - do { - _ = try await service.sendCommand(sessionID: session.id, command: "advert") - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.advertSent - showSuccessAlert = true - } catch { - errorMessage = error.localizedDescription - } - } - - /// Sync repeater time with phone time - func syncTime() async { - isApplying = true - errorMessage = nil - - do { - let response = try await sendAndWait("clock sync") - switch CLIResponse.parse(response) { - case .ok: - successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced - showSuccessAlert = true - case .error(let message): - // Extract message after "ERR: " prefix if present - if message.contains("clock cannot go backwards") { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.clockAheadError - } else { - let cleanMessage = message.replacing("ERR: ", with: "") - errorMessage = cleanMessage.isEmpty ? L10n.RemoteNodes.RemoteNodes.Settings.syncTimeFailed : cleanMessage - } - - default: - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.unexpectedResponse(response) - + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } // MARK: - Region Methods - /// Fetch regions from the repeater using bare `region` command (tree output) func fetchRegions() async { isLoadingRegions = true - regionsError = nil + regionsError = false do { - let treeResponse = try await sendAndWait("region", timeout: .seconds(10), rawMatching: true) + let treeResponse = try await helper.sendAndWait("region", timeout: .seconds(10), rawMatching: true) let parsed = Self.parseRegionTree(treeResponse) self.regions = parsed self.originalRegions = parsed - logger.debug("Fetched \(parsed.count) regions from tree output") } catch { if case RemoteNodeError.timeout = error { - regionsError = "error" + regionsError = true } logger.warning("Failed to fetch regions: \(error)") } @@ -947,25 +324,15 @@ final class RepeaterSettingsViewModel { isLoadingRegions = false } - /// Parse the tree output from bare `region` command into region entries. - /// - /// Format per line: `{spaces}{name}{^?}{ F?}` - /// - Leading spaces = hierarchy depth - /// - `^` suffix = home region - /// - ` F` suffix (with trailing newline stripped) = flood allowed - /// - `*` = wildcard root static func parseRegionTree(_ response: String) -> [RepeaterRegionEntry] { var entries: [RepeaterRegionEntry] = [] let lines = response.split(separator: "\n", omittingEmptySubsequences: true) for line in lines { var text = String(line) - - // Strip leading spaces text = String(text.drop(while: { $0 == " " })) guard !text.isEmpty else { continue } - // Check for " F" suffix (flood allowed) let floodAllowed: Bool if text.hasSuffix(" F") { floodAllowed = true @@ -974,7 +341,6 @@ final class RepeaterSettingsViewModel { floodAllowed = false } - // Check for "^" suffix (home region) let isHome: Bool if text.hasSuffix("^") { isHome = true @@ -995,74 +361,69 @@ final class RepeaterSettingsViewModel { return entries } - /// Toggle flood allow/deny for a region func toggleRegionFlood(name: String) async { guard let index = regions.firstIndex(where: { $0.name == name }) else { return } let currentlyAllowed = regions[index].floodAllowed let command = currentlyAllowed ? "region denyf \(name)" : "region allowf \(name)" - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait(command) + let response = try await helper.sendAndWait(command) if case .ok = CLIResponse.parse(response) { regions[index].floodAllowed = !currentlyAllowed hasUnsavedRegionChanges = true } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - /// Set the home region func setHomeRegion(name: String) async { let command = "region home \(name)" - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait(command, rawMatching: true) + let response = try await helper.sendAndWait(command, rawMatching: true) if response.contains("home is now") { - // Clear old home, set new for i in regions.indices { regions[i].isHome = (regions[i].name == name) } hasUnsavedRegionChanges = true } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - /// Add a new region to the repeater func addRegion(name: String) async { let trimmed = name.trimmingCharacters(in: .whitespaces) if let validationError = RegionNameValidator.validate(trimmed, existingRegions: regions.map(\.name)) { switch validationError { case .empty: return case .invalidCharacters, .invalidPrefix, .duplicate: - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed } return } - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait("region put \(trimmed)") + let response = try await helper.sendAndWait("region put \(trimmed)") if case .ok = CLIResponse.parse(response) { - // New regions default to flood-denied on the firmware regions.append(RepeaterRegionEntry( name: trimmed, floodAllowed: false, @@ -1071,61 +432,59 @@ final class RepeaterSettingsViewModel { hasUnsavedRegionChanges = true newRegionName = "" } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - /// Remove a region from the repeater func removeRegion(name: String) async { - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait("region remove \(name)") + let response = try await helper.sendAndWait("region remove \(name)") if case .ok = CLIResponse.parse(response) { regions.removeAll { $0.name == name } hasUnsavedRegionChanges = true } else if response.contains("not empty") { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.removeFailed } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } - /// Save region configuration to device flash func saveRegions() async { - isApplying = true - errorMessage = nil + helper.isApplying = true + helper.errorMessage = nil do { - let response = try await sendAndWait("region save") + let response = try await helper.sendAndWait("region save") if case .ok = CLIResponse.parse(response) { hasUnsavedRegionChanges = false withAnimation { - isApplying = false + helper.isApplying = false regionsSaveSuccess = true } try? await Task.sleep(for: .seconds(1.5)) withAnimation { regionsSaveSuccess = false } return } else { - errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.saveFailed } } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription } - isApplying = false + helper.isApplying = false } } @@ -1138,19 +497,3 @@ struct RepeaterRegionEntry: Identifiable, Equatable { var isHome: Bool var isWildcard: Bool { name == RepeaterSettingsViewModel.wildcardName } } - -// MARK: - Error Types - -enum RepeaterSettingsError: LocalizedError { - case notConnected - case timeout - case noService - - var errorDescription: String? { - switch self { - case .notConnected: return L10n.RemoteNodes.RemoteNodes.Settings.notConnected - case .timeout: return L10n.RemoteNodes.RemoteNodes.Settings.timeout - case .noService: return L10n.RemoteNodes.RemoteNodes.Settings.noService - } - } -} diff --git a/MC1/Views/RemoteNodes/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/RepeaterStatusView.swift index b972f6e40..4de5e6a27 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusView.swift @@ -35,9 +35,10 @@ struct RepeaterStatusView: View { } label: { Image(systemName: "arrow.clockwise") } + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.refresh) .radioDisabled( for: appState.connectionState, - or: viewModel.isLoadingStatus || viewModel.isLoadingNeighbors || viewModel.isLoadingTelemetry || viewModel.isLoadingOwnerInfo + or: viewModel.helper.isLoadingStatus || viewModel.isLoadingNeighbors || viewModel.helper.isLoadingTelemetry || viewModel.isLoadingOwnerInfo ) } @@ -57,13 +58,14 @@ struct RepeaterStatusView: View { viewModel.configure(appState: appState) await viewModel.registerHandlers(appState: appState) - // Request Status first (includes clock query) - await viewModel.requestStatus(for: session) - // Note: Telemetry and Neighbors are NOT auto-loaded - user must expand the section + // Only request status on first load; user can refresh via toolbar/pull-to-refresh + if viewModel.helper.status == nil { + await viewModel.requestStatus(for: session) + } // Pre-load OCV settings and contacts for neighbor matching if let deviceID = appState.connectedDevice?.id { - await viewModel.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) + await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) if let dataStore = appState.services?.dataStore { contacts = (try? await dataStore.fetchContacts(deviceID: deviceID)) ?? [] discoveredNodes = (try? await dataStore.fetchDiscoveredNodes(deviceID: deviceID)) ?? [] @@ -77,7 +79,7 @@ struct RepeaterStatusView: View { await viewModel.requestOwnerInfo(for: session) } // Refresh telemetry only if already loaded - if viewModel.telemetryLoaded { + if viewModel.helper.telemetryLoaded { await viewModel.requestTelemetry(for: session) } // Refresh neighbors only if already loaded @@ -92,7 +94,7 @@ struct RepeaterStatusView: View { // MARK: - Subviews private func makeHeaderSection() -> some View { - HeaderSection(session: session) + NodeStatusHeaderSection(session: session) } private func makeOwnerInfoSection() -> some View { @@ -100,7 +102,7 @@ struct RepeaterStatusView: View { } private func makeStatusSection() -> some View { - StatusSection(viewModel: viewModel, session: session) + StatusSection(viewModel: viewModel) } private func makeNeighborsSection() -> some View { @@ -113,8 +115,8 @@ struct RepeaterStatusView: View { } private func makeBatteryCurveSection() -> some View { - BatteryCurveDisclosureSection( - viewModel: viewModel, + NodeBatteryCurveDisclosureSection( + helper: viewModel.helper, session: session, connectionState: appState.connectionState, connectedDeviceID: appState.connectedDevice?.id @@ -122,7 +124,9 @@ struct RepeaterStatusView: View { } private func makeTelemetrySection() -> some View { - TelemetrySection(viewModel: viewModel, session: session) + NodeTelemetryDisclosureSection(helper: viewModel.helper) { + await viewModel.requestTelemetry(for: session) + } } // MARK: - Actions @@ -135,7 +139,7 @@ struct RepeaterStatusView: View { await viewModel.requestOwnerInfo(for: session) } // Refresh telemetry only if already loaded - if viewModel.telemetryLoaded { + if viewModel.helper.telemetryLoaded { await viewModel.requestTelemetry(for: session) } // Refresh neighbors only if already loaded @@ -146,34 +150,6 @@ struct RepeaterStatusView: View { } } -// MARK: - Header Section - -private struct HeaderSection: View { - let session: RemoteNodeSessionDTO - - var body: some View { - Section { - HStack { - Spacer() - VStack(spacing: 8) { - NodeAvatar(publicKey: session.publicKey, role: .repeater, size: 60) - - Text(session.name) - .font(.headline) - - if session.permissionLevel == .guest { - Text(L10n.RemoteNodes.RemoteNodes.Status.guestMode) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - Spacer() - } - .listRowBackground(Color.clear) - } - } -} - // MARK: - Owner Info Section private struct OwnerInfoSection: View { @@ -216,34 +192,10 @@ private struct OwnerInfoSection: View { private struct StatusSection: View { let viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO var body: some View { - Section(L10n.RemoteNodes.RemoteNodes.Status.statusSection) { - if viewModel.isLoadingStatus && viewModel.status == nil { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if let errorMessage = viewModel.errorMessage, viewModel.status == nil { - Text(errorMessage) - .foregroundStyle(.red) - } else { - StatusRows(viewModel: viewModel) - - if let timestamp = viewModel.previousSnapshotTimestamp { - Text(timestamp) - .font(.caption) - .foregroundStyle(.secondary) - } - - NavigationLink { - NodeStatusHistoryView(fetchSnapshots: viewModel.fetchHistory, ocvArray: viewModel.ocvValues) - } label: { - Text(L10n.RemoteNodes.RemoteNodes.History.title) - } - } + NodeStatusSection(helper: viewModel.helper) { + StatusRows(viewModel: viewModel) } } } @@ -254,38 +206,7 @@ private struct StatusRows: View { let viewModel: RepeaterStatusViewModel var body: some View { - MetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.battery, - value: viewModel.batteryDisplay, - delta: viewModel.batteryDeltaMV.map { Double($0) / 1000.0 }, - higherIsBetter: true, unit: " V", fractionDigits: 2 - ) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.uptime, value: viewModel.uptimeDisplay) - - MetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.lastRssi, - value: viewModel.lastRSSIDisplay, - delta: viewModel.rssiDelta.map(Double.init), - higherIsBetter: true, unit: " dBm", fractionDigits: 0 - ) - - MetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.lastSnr, - value: viewModel.lastSNRDisplay, - delta: viewModel.snrDelta, - higherIsBetter: true, unit: " dB", fractionDigits: 1 - ) - - MetricRow( - label: L10n.RemoteNodes.RemoteNodes.Status.noiseFloor, - value: viewModel.noiseFloorDisplay, - delta: viewModel.noiseFloorDelta.map(Double.init), - higherIsBetter: false, unit: " dBm", fractionDigits: 0 - ) - - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsSent, value: viewModel.packetsSentDisplay) - LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsReceived, value: viewModel.packetsReceivedDisplay) + NodeCommonStatusRows(helper: viewModel.helper) if let receiveErrors = viewModel.receiveErrorsDisplay { LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.receiveErrors, value: receiveErrors) @@ -322,21 +243,21 @@ private struct NeighborsSection: View { NeighborSNRChartView( name: resolvedName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, neighborPrefix: neighbor.publicKeyPrefix, - fetchSnapshots: viewModel.fetchHistory + fetchSnapshots: viewModel.helper.fetchHistory ) } label: { NeighborRow( neighbor: neighbor, contact: contact, - previousNeighbor: viewModel.previousSnapshot?.neighborSnapshots?.first { + previousNeighbor: viewModel.helper.previousSnapshot?.neighborSnapshots?.first { $0.publicKeyPrefix == neighbor.publicKeyPrefix }, - hasPreviousSnapshot: viewModel.previousSnapshot?.neighborSnapshots != nil + hasPreviousSnapshot: viewModel.helper.previousSnapshot?.neighborSnapshots != nil ) } } - if let previousNeighbors = viewModel.previousSnapshot?.neighborSnapshots { + if let previousNeighbors = viewModel.helper.previousSnapshot?.neighborSnapshots { let currentPrefixes = Set(viewModel.neighbors.map(\.publicKeyPrefix)) let disappeared = previousNeighbors.filter { !currentPrefixes.contains($0.publicKeyPrefix) } ForEach(disappeared, id: \.publicKeyPrefix) { old in @@ -371,133 +292,6 @@ private struct NeighborsSection: View { } } -// MARK: - Battery Curve Disclosure Section - -private struct BatteryCurveDisclosureSection: View { - @Bindable var viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - let connectionState: ConnectionState - let connectedDeviceID: UUID? - - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.isBatteryCurveExpanded) { - BatteryCurveSection( - availablePresets: OCVPreset.repeaterPresets, - headerText: "", - footerText: "", - selectedPreset: $viewModel.selectedOCVPreset, - voltageValues: $viewModel.ocvValues, - onSave: viewModel.saveOCVSettings, - isDisabled: connectionState != .ready - ) - - if let error = viewModel.ocvError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - } label: { - Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurve) - } - .onChange(of: viewModel.isBatteryCurveExpanded) { _, isExpanded in - if isExpanded, let deviceID = connectedDeviceID { - Task { - await viewModel.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) - } - } - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurveFooter) - } - } -} - -// MARK: - Telemetry Section - -private struct TelemetrySection: View { - @Bindable var viewModel: RepeaterStatusViewModel - let session: RemoteNodeSessionDTO - - var body: some View { - Section { - DisclosureGroup(isExpanded: $viewModel.telemetryExpanded) { - if viewModel.isLoadingTelemetry { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if viewModel.telemetry != nil { - if viewModel.cachedDataPoints.isEmpty { - Text(L10n.RemoteNodes.RemoteNodes.Status.noSensorData) - .foregroundStyle(.secondary) - } else if viewModel.hasMultipleChannels { - ForEach(viewModel.groupedDataPoints, id: \.channel) { group in - Section { - ForEach(group.dataPoints, id: \.self) { dataPoint in - TelemetryRow(dataPoint: dataPoint, ocvArray: viewModel.ocvValues) - } - } header: { - Text(L10n.RemoteNodes.RemoteNodes.Status.channel(Int(group.channel))) - .fontWeight(.semibold) - } - } - } else { - ForEach(viewModel.cachedDataPoints, id: \.self) { dataPoint in - TelemetryRow(dataPoint: dataPoint, ocvArray: viewModel.ocvValues) - } - } - - NavigationLink { - TelemetryHistoryView(fetchSnapshots: viewModel.fetchHistory, ocvArray: viewModel.ocvValues) - } label: { - Text(L10n.RemoteNodes.RemoteNodes.History.title) - } - } else { - Text(L10n.RemoteNodes.RemoteNodes.Status.noTelemetryData) - .foregroundStyle(.secondary) - } - } label: { - Text(L10n.RemoteNodes.RemoteNodes.Status.telemetry) - } - .onChange(of: viewModel.telemetryExpanded) { _, isExpanded in - if isExpanded && !viewModel.telemetryLoaded { - Task { - await viewModel.requestTelemetry(for: session) - } - } - } - } footer: { - Text(L10n.RemoteNodes.RemoteNodes.Status.telemetryFooter) - } - } -} - -// MARK: - Metric Row - -private struct MetricRow: View { - let label: String - let value: String - let delta: Double? - let higherIsBetter: Bool - let unit: String - let fractionDigits: Int - - var body: some View { - LabeledContent { - VStack(alignment: .trailing, spacing: 2) { - Text(value) - if let delta { - StatusDeltaView(delta: delta, higherIsBetter: higherIsBetter, unit: unit, fractionDigits: fractionDigits) - } - } - } label: { - Text(label) - } - } -} - // MARK: - Neighbor SNR Chart private struct NeighborSNRChartView: View { @@ -657,33 +451,6 @@ private struct DisappearedNeighborRow: View { } } -// MARK: - Telemetry Row - -private struct TelemetryRow: View { - let dataPoint: LPPDataPoint - let ocvArray: [Int] - - var body: some View { - if dataPoint.type == .voltage, case .float(let voltage) = dataPoint.value { - // Calculate percentage using OCV array - let millivolts = Int(voltage * 1000) - let battery = BatteryInfo(level: millivolts) - let percentage = battery.percentage(using: ocvArray) - - LabeledContent(dataPoint.typeName) { - VStack(alignment: .trailing, spacing: 2) { - Text(dataPoint.formattedValue) - Text("\(percentage)%") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } else { - LabeledContent(dataPoint.typeName, value: dataPoint.formattedValue) - } - } -} - #Preview { RepeaterStatusView( session: RemoteNodeSessionDTO( diff --git a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift index a3bbf411f..a87e142b2 100644 --- a/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/RepeaterStatusViewModel.swift @@ -9,29 +9,17 @@ private let logger = Logger(subsystem: "com.mc1", category: "RepeaterStatusVM") @MainActor final class RepeaterStatusViewModel { - // MARK: - Properties + // MARK: - Shared Helper - /// Current session - var session: RemoteNodeSessionDTO? + var helper = NodeStatusHelper() - /// Last received status - var status: RemoteNodeStatus? + // MARK: - Repeater-Only Properties /// Neighbor entries var neighbors: [NeighbourInfo] = [] - /// Last received telemetry - var telemetry: TelemetryResponse? - - /// Cached decoded data points to avoid repeated LPP decoding. - /// The TelemetryResponse.dataPoints computed property decodes on every access, - /// which causes memory pressure during SwiftUI re-renders. - private(set) var cachedDataPoints: [LPPDataPoint] = [] - /// Loading states - var isLoadingStatus = false var isLoadingNeighbors = false - var isLoadingTelemetry = false /// Whether neighbors have been loaded at least once (for refresh logic) var neighborsLoaded = false @@ -39,12 +27,6 @@ final class RepeaterStatusViewModel { /// Whether the neighbors disclosure group is expanded var neighborsExpanded = false - /// Whether telemetry has been loaded at least once (for refresh logic) - var telemetryLoaded = false - - /// Whether the telemetry disclosure group is expanded - var telemetryExpanded = false - /// Owner info text var ownerInfo: String? @@ -54,65 +36,28 @@ final class RepeaterStatusViewModel { var ownerInfoExpanded = false var ownerInfoError: String? - /// Error message if any - var errorMessage: String? - - // MARK: - OCV Curve Properties - - /// Whether the battery curve disclosure group is expanded - var isBatteryCurveExpanded = false - - /// Selected OCV preset - var selectedOCVPreset: OCVPreset = .liIon - - /// Current OCV voltage values - var ocvValues: [Int] = OCVPreset.liIon.ocvArray - - /// Error from OCV save operation - var ocvError: String? - - /// Contact ID for saving OCV settings - private var contactID: UUID? - // MARK: - Dependencies private var repeaterAdminService: RepeaterAdminService? - private var contactService: ContactService? - var nodeSnapshotService: NodeSnapshotService? - - /// ID of the current session's snapshot (for enrichment). - /// Because `handleStatusResponse` suspends while saving the snapshot, - /// neighbor/telemetry handlers may fire before this is set. - /// In that case, enrichment data is buffered in `pendingNeighborEntries` - /// / `pendingTelemetryEntries` and flushed once the ID is available. - private var currentSnapshotID: UUID? - /// Buffered enrichment data received before `currentSnapshotID` was set. + /// Buffered neighbor enrichment data received before snapshot ID was set. private var pendingNeighborEntries: [NeighborSnapshotEntry]? - private var pendingTelemetryEntries: [TelemetrySnapshotEntry]? - - /// Previous snapshot for delta display - private(set) var previousSnapshot: NodeStatusSnapshotDTO? // MARK: - Initialization init() {} - /// Configure with services from AppState func configure(appState: AppState) { self.repeaterAdminService = appState.services?.repeaterAdminService - self.contactService = appState.services?.contactService - self.nodeSnapshotService = appState.services?.nodeSnapshotService - // Handler registration moved to registerHandlers() called from view's .task modifier + helper.configure( + contactService: appState.services?.contactService, + nodeSnapshotService: appState.services?.nodeSnapshotService + ) } - /// Register for push notification handlers - /// Called from view's .task modifier to ensure proper lifecycle management - /// This method is idempotent - it clears existing handlers before registering new ones func registerHandlers(appState: AppState) async { guard let repeaterAdminService = appState.services?.repeaterAdminService else { return } - // Clear any existing handlers first (idempotent setup) await repeaterAdminService.clearHandlers() await repeaterAdminService.setStatusHandler { [weak self] status in @@ -120,263 +65,121 @@ final class RepeaterStatusViewModel { } await repeaterAdminService.setNeighboursHandler { [weak self] response in - await MainActor.run { - self?.handleNeighboursResponse(response) - } + await self?.handleNeighboursResponse(response) } await repeaterAdminService.setTelemetryHandler { [weak self] response in - await MainActor.run { - self?.handleTelemetryResponse(response) - } + await self?.helper.handleTelemetryResponse(response) } - } // MARK: - Status - /// Timeout duration for status/neighbors requests - private static let requestTimeout: Duration = RemoteOperationTimeoutPolicy.binaryMaximum - - /// Check if error is a transient "not ready" error that should be retried. - /// Error code 10 occurs when the firmware isn't fully ready after login. - private func isTransientError(_ error: Error) -> Bool { - guard let remoteError = error as? RemoteNodeError, - case .sessionError(let meshError) = remoteError, - case .deviceError(let code) = meshError else { - return false - } - return code == 10 - } - - private static let transientRetryDelays: [Duration] = [ - .milliseconds(500), - .seconds(1), - .seconds(2), - ] - - private func remainingBudget(until deadline: ContinuousClock.Instant) -> Duration? { - let remaining = deadline - .now - return remaining > .zero ? remaining : nil - } - - private func waitForRetry(delay: Duration, until deadline: ContinuousClock.Instant) async throws { - guard let remaining = remainingBudget(until: deadline) else { - throw RemoteNodeError.timeout - } - try await Task.sleep(for: min(delay, remaining)) - } - - private func performWithTransientRetries( - operationName: String, - operation: @escaping @Sendable (Duration) async throws -> T - ) async throws -> T { - let deadline = ContinuousClock.now.advanced(by: Self.requestTimeout) - var delayIterator = Self.transientRetryDelays.makeIterator() - - while true { - guard let timeout = remainingBudget(until: deadline) else { - logger.warning("\(operationName, privacy: .public) request exhausted its shared timeout budget") - throw RemoteNodeError.timeout - } - - do { - return try await operation(timeout) - } catch { - guard isTransientError(error), let delay = delayIterator.next() else { - throw error - } - try await waitForRetry(delay: delay, until: deadline) - } - } - } - - /// Request status from the repeater func requestStatus(for session: RemoteNodeSessionDTO) async { guard let repeaterAdminService else { return } - self.session = session - isLoadingStatus = true - errorMessage = nil + if helper.session == nil { helper.session = session } + helper.isLoadingStatus = true + helper.errorMessage = nil do { - let response = try await performWithTransientRetries(operationName: "status") { [repeaterAdminService] timeout in + let response = try await helper.performWithTransientRetries(operationName: "status") { [repeaterAdminService] timeout in return try await repeaterAdminService.requestStatus(sessionID: session.id, timeout: timeout) } await handleStatusResponse(response) } catch RemoteNodeError.timeout { - errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut - isLoadingStatus = false + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.isLoadingStatus = false } catch { - errorMessage = error.localizedDescription - isLoadingStatus = false + helper.errorMessage = error.localizedDescription + helper.isLoadingStatus = false } } - /// Request neighbors from the repeater + private func handleStatusResponse(_ response: RemoteNodeStatus) async { + await helper.handleStatusResponse( + response, + rxAirtimeSeconds: response.repeaterRxAirtimeSeconds, + receiveErrors: response.receiveErrors + ) + + // Flush any buffered neighbor entries now that snapshot ID is set + if let pending = pendingNeighborEntries { + pendingNeighborEntries = nil + helper.flushPendingNeighborEntries(pending) + } + } + + // MARK: - Neighbors + func requestNeighbors(for session: RemoteNodeSessionDTO) async { guard let repeaterAdminService else { return } - self.session = session + if helper.session == nil { helper.session = session } isLoadingNeighbors = true - errorMessage = nil + helper.errorMessage = nil do { - let response = try await performWithTransientRetries(operationName: "neighbors") { [repeaterAdminService] timeout in + let response = try await helper.performWithTransientRetries(operationName: "neighbors") { [repeaterAdminService] timeout in return try await repeaterAdminService.requestNeighbors(sessionID: session.id, timeout: timeout) } handleNeighboursResponse(response) } catch RemoteNodeError.timeout { - errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut isLoadingNeighbors = false } catch { - errorMessage = error.localizedDescription + helper.errorMessage = error.localizedDescription isLoadingNeighbors = false } } - /// Handle status response from push notification - /// Validates response matches current session before updating - func handleStatusResponse(_ response: RemoteNodeStatus) async { - // Session validation: only accept responses for our session - guard let expectedPrefix = session?.publicKeyPrefix, - response.publicKeyPrefix == expectedPrefix else { - return // Ignore responses for other sessions - } - self.status = response - self.isLoadingStatus = false - - // Capture snapshot for history - guard let nodeSnapshotService, let session else { return } - - // Fetch previous snapshot BEFORE saving so we compare against the last visit - let prev = await nodeSnapshotService.previousSnapshot( - for: session.publicKey, - before: .now - ) - self.previousSnapshot = prev - - let snapshotID = await nodeSnapshotService.saveStatusSnapshot( - nodePublicKey: session.publicKey, - batteryMillivolts: response.batteryMillivolts, - lastSNR: response.lastSNR, - lastRSSI: Int16(clamping: response.lastRSSI), - noiseFloor: Int16(clamping: response.noiseFloor), - uptimeSeconds: response.uptimeSeconds, - rxAirtimeSeconds: response.repeaterRxAirtimeSeconds, - packetsSent: response.packetsSent, - packetsReceived: response.packetsReceived, - receiveErrors: response.receiveErrors - ) - if let snapshotID { - self.currentSnapshotID = snapshotID - } else if let prevID = prev?.id { - // Snapshot throttled — enrich the most recent existing snapshot instead - self.currentSnapshotID = prevID - } - - // Flush any enrichment data that arrived during the await - if let enrichmentTarget = self.currentSnapshotID { - if let pending = pendingNeighborEntries { - pendingNeighborEntries = nil - Task { await nodeSnapshotService.enrichWithNeighbors(pending, snapshotID: enrichmentTarget) } - } - if let pending = pendingTelemetryEntries { - pendingTelemetryEntries = nil - Task { await nodeSnapshotService.enrichWithTelemetry(pending, snapshotID: enrichmentTarget) } - } - } - } - - /// Handle neighbours response from push notification func handleNeighboursResponse(_ response: NeighboursResponse) { - // Note: NeighboursResponse may not include source prefix - validate if available self.neighbors = response.neighbours self.isLoadingNeighbors = false self.neighborsLoaded = true - // Enrich current snapshot with neighbor data let entries = response.neighbours.map { NeighborSnapshotEntry(publicKeyPrefix: $0.publicKeyPrefix, snr: $0.snr, secondsAgo: $0.secondsAgo) } - if let snapshotID = currentSnapshotID { - Task { await nodeSnapshotService?.enrichWithNeighbors(entries, snapshotID: snapshotID) } - } else { + if !helper.enrichWithNeighbors(entries) { pendingNeighborEntries = entries } } // MARK: - Telemetry - /// Request telemetry from the repeater func requestTelemetry(for session: RemoteNodeSessionDTO) async { guard let repeaterAdminService else { return } - self.session = session - isLoadingTelemetry = true - errorMessage = nil + if helper.session == nil { helper.session = session } + helper.isLoadingTelemetry = true + helper.errorMessage = nil do { - let response = try await performWithTransientRetries(operationName: "telemetry") { [repeaterAdminService] timeout in + let response = try await helper.performWithTransientRetries(operationName: "telemetry") { [repeaterAdminService] timeout in return try await repeaterAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) } - handleTelemetryResponse(response) + helper.handleTelemetryResponse(response) } catch RemoteNodeError.timeout { - errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut - isLoadingTelemetry = false + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.isLoadingTelemetry = false } catch { - errorMessage = error.localizedDescription - isLoadingTelemetry = false - } - } - - /// Handle telemetry response from push notification - func handleTelemetryResponse(_ response: TelemetryResponse) { - // Session validation: only accept responses for our session - guard let expectedPrefix = session?.publicKeyPrefix, - response.publicKeyPrefix == expectedPrefix else { - return // Ignore responses for other sessions - } - self.telemetry = response - // Decode and cache data points once to avoid repeated LPP decoding during view updates - self.cachedDataPoints = response.dataPoints - self.isLoadingTelemetry = false - self.telemetryLoaded = true - - // Enrich current snapshot with telemetry data - let entries: [TelemetrySnapshotEntry] = cachedDataPoints.compactMap { dp in - let numericValue: Double? - switch dp.value { - case .float(let value): - numericValue = value - case .integer(let value): - numericValue = Double(value) - default: - numericValue = nil - } - guard let value = numericValue else { return nil } - return TelemetrySnapshotEntry(channel: Int(dp.channel), type: dp.typeName, value: value) - } - if !entries.isEmpty { - if let snapshotID = currentSnapshotID { - Task { await nodeSnapshotService?.enrichWithTelemetry(entries, snapshotID: snapshotID) } - } else { - pendingTelemetryEntries = entries - } + helper.errorMessage = error.localizedDescription + helper.isLoadingTelemetry = false } } // MARK: - Owner Info - /// Request owner info from the repeater func requestOwnerInfo(for session: RemoteNodeSessionDTO) async { guard let repeaterAdminService else { return } + if helper.session == nil { helper.session = session } ownerInfoError = nil isLoadingOwnerInfo = true do { - let response = try await performWithTransientRetries(operationName: "ownerInfo") { [repeaterAdminService] timeout in + let response = try await helper.performWithTransientRetries(operationName: "ownerInfo") { [repeaterAdminService] timeout in return try await repeaterAdminService.requestOwnerInfo(sessionID: session.id, timeout: timeout) } ownerInfo = response.ownerInfo @@ -388,207 +191,10 @@ final class RepeaterStatusViewModel { isLoadingOwnerInfo = false } - // MARK: - Telemetry Grouping - - /// Whether cached data points span multiple channels. - var hasMultipleChannels: Bool { - let channels = Set(cachedDataPoints.map(\.channel)) - return channels.count > 1 - } - - /// Data points grouped by channel, sorted by channel number. - /// Only useful when `hasMultipleChannels` is true. - var groupedDataPoints: [(channel: UInt8, dataPoints: [LPPDataPoint])] { - Dictionary(grouping: cachedDataPoints, by: \.channel) - .sorted { $0.key < $1.key } - .map { (channel: $0.key, dataPoints: $0.value) } - } - - // MARK: - Computed Properties - - /// Em-dash for missing data (cleaner than "Unavailable") - private static let emDash = "—" - - private static let secondsPerMinute: UInt32 = 60 - private static let secondsPerHour: UInt32 = 3_600 - private static let secondsPerDay: UInt32 = 86_400 - - var uptimeDisplay: String { - guard let uptime = status?.uptimeSeconds else { return Self.emDash } - let days = Int(uptime / Self.secondsPerDay) - let hours = Int((uptime % Self.secondsPerDay) / Self.secondsPerHour) - let minutes = Int((uptime % Self.secondsPerHour) / Self.secondsPerMinute) - - if days > 0 { - if days == 1 { - return L10n.RemoteNodes.RemoteNodes.Status.uptime1Day(hours, minutes) - } else { - return L10n.RemoteNodes.RemoteNodes.Status.uptimeDays(days, hours, minutes) - } - } else if hours > 0 { - return L10n.RemoteNodes.RemoteNodes.Status.uptimeHours(hours, minutes) - } - return L10n.RemoteNodes.RemoteNodes.Status.uptimeMinutes(minutes) - } - - var batteryDisplay: String { - guard let mv = status?.batteryMillivolts else { return Self.emDash } - let volts = Double(mv) / 1000.0 - let battery = BatteryInfo(level: Int(mv)) - let percent = battery.percentage(using: ocvValues) - return "\(volts.formatted(.number.precision(.fractionLength(2))))V (\(percent)%)" - } - - var lastRSSIDisplay: String { - guard let rssi = status?.lastRSSI else { return Self.emDash } - return "\(rssi) dBm" - } - - var lastSNRDisplay: String { - guard let snr = status?.lastSNR else { return Self.emDash } - return "\(snr.formatted(.number.precision(.fractionLength(1)))) dB" - } - - var noiseFloorDisplay: String { - guard let nf = status?.noiseFloor else { return Self.emDash } - return "\(nf) dBm" - } - - var packetsSentDisplay: String { - guard let count = status?.packetsSent else { return Self.emDash } - return count.formatted() - } - - var packetsReceivedDisplay: String { - guard let count = status?.packetsReceived else { return Self.emDash } - return count.formatted() - } + // MARK: - Repeater-Only Display var receiveErrorsDisplay: String? { - guard let count = status?.receiveErrors, count > 0 else { return nil } + guard let count = helper.status?.receiveErrors, count > 0 else { return nil } return count.formatted() } - - // MARK: - Delta Display - - /// Format a delta timestamp relative to now. - var previousSnapshotTimestamp: String? { - guard let prev = previousSnapshot else { return nil } - let interval = prev.timestamp.distance(to: .now) - let secondsPerHour: TimeInterval = 3_600 - let secondsPerDay: TimeInterval = 86_400 - if interval < secondsPerHour { - return L10n.RemoteNodes.RemoteNodes.History.vsMinutesAgo(Int(interval / 60)) - } else if interval < secondsPerDay { - return L10n.RemoteNodes.RemoteNodes.History.vsHoursAgo(Int(interval / secondsPerHour)) - } else { - return L10n.RemoteNodes.RemoteNodes.History.vsDate(prev.timestamp.formatted(.dateTime.month().day())) - } - } - - /// Battery delta from previous snapshot (in millivolts, positive = increase) - var batteryDeltaMV: Int? { - guard let current = status?.batteryMillivolts, - let previous = previousSnapshot?.batteryMillivolts else { return nil } - return Int(current) - Int(previous) - } - - /// SNR delta from previous snapshot - var snrDelta: Double? { - guard let current = status?.lastSNR, - let previous = previousSnapshot?.lastSNR else { return nil } - return current - previous - } - - /// RSSI delta from previous snapshot - var rssiDelta: Int? { - guard let current = status?.lastRSSI, - let previous = previousSnapshot?.lastRSSI else { return nil } - return Int(current) - Int(previous) - } - - /// Noise floor delta from previous snapshot - var noiseFloorDelta: Int? { - guard let current = status?.noiseFloor, - let previous = previousSnapshot?.noiseFloor else { return nil } - return Int(current) - Int(previous) - } - - /// Fetch all snapshots for the current node - func fetchHistory() async -> [NodeStatusSnapshotDTO] { - guard let nodeSnapshotService, let session else { - logger.warning("fetchHistory: nodeSnapshotService or session is nil") - return [] - } - return await nodeSnapshotService.fetchSnapshots(for: session.publicKey) - } - - // MARK: - OCV Settings - - /// Load OCV settings for a contact by public key - func loadOCVSettings(publicKey: Data, deviceID: UUID) async { - guard let contactService else { return } - - do { - if let contact = try await contactService.getContact(deviceID: deviceID, publicKey: publicKey) { - contactID = contact.id - - if let presetName = contact.ocvPreset { - if presetName == OCVPreset.custom.rawValue, let customString = contact.customOCVArrayString { - let parsed = customString.split(separator: ",") - .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } - if parsed.count == 11 { - ocvValues = parsed - selectedOCVPreset = .custom - return - } - } - if let preset = OCVPreset(rawValue: presetName) { - selectedOCVPreset = preset - ocvValues = preset.ocvArray - return - } - } - - selectedOCVPreset = .liIon - ocvValues = OCVPreset.liIon.ocvArray - } - } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvLoadFailed - } - } - - /// Save OCV settings for the current contact - func saveOCVSettings(preset: OCVPreset, values: [Int]) async { - guard let contactService, - let contactID else { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveNoContact - return - } - - ocvError = nil - - do { - if preset == .custom { - let customString = values.map(String.init).joined(separator: ",") - try await contactService.updateContactOCVSettings( - contactID: contactID, - preset: OCVPreset.custom.rawValue, - customArray: customString - ) - } else { - try await contactService.updateContactOCVSettings( - contactID: contactID, - preset: preset.rawValue, - customArray: nil - ) - } - - // Update local state - selectedOCVPreset = preset - ocvValues = values - } catch { - ocvError = L10n.RemoteNodes.RemoteNodes.Status.ocvSaveFailed(error.localizedDescription) - } - } } diff --git a/MC1/Views/RemoteNodes/RoomSettingsView.swift b/MC1/Views/RemoteNodes/RoomSettingsView.swift new file mode 100644 index 000000000..47ec1246b --- /dev/null +++ b/MC1/Views/RemoteNodes/RoomSettingsView.swift @@ -0,0 +1,249 @@ +import SwiftUI +import MC1Services +import CoreLocation + +struct RoomSettingsView: View { + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + @FocusState private var focusedField: NodeSettingsField? + + let session: RemoteNodeSessionDTO + @State private var viewModel = RoomSettingsViewModel() + @State private var showRebootConfirmation = false + @State private var showingLocationPicker = false + + var body: some View { + Form { + NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) + NodeDeviceInfoSection(settings: viewModel.helper) + NodeRadioSettingsSection( + settings: viewModel.helper, + focusedField: $focusedField, + radioRestartWarning: L10n.RemoteNodes.RemoteNodes.RoomSettings.radioRestartWarning + ) + RemoteNodeIdentitySection( + settings: viewModel.helper, + focusedField: $focusedField, + onPickLocation: { showingLocationPicker = true } + ) + NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) + RoomBehaviorSection(viewModel: viewModel, focusedField: $focusedField) + NodeSecuritySection(settings: viewModel.helper) + NodeActionsSection( + settings: viewModel.helper, + showRebootConfirmation: $showRebootConfirmation, + rebootConfirmTitle: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootConfirmTitle, + rebootMessage: L10n.RemoteNodes.RemoteNodes.RoomSettings.rebootMessage + ) + } + .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomSettings.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { + focusedField = nil + } + } + } + .task { + await viewModel.configure(appState: appState, session: session) + } + .onDisappear { + Task { + await viewModel.cleanup() + } + } + .alert(L10n.RemoteNodes.RemoteNodes.Settings.success, isPresented: $viewModel.helper.showSuccessAlert) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.ok, role: .cancel) { } + } message: { + Text(viewModel.helper.successMessage ?? L10n.RemoteNodes.RemoteNodes.Settings.settingsApplied) + } + .sheet(isPresented: $showingLocationPicker) { + LocationPickerView( + initialCoordinate: CLLocationCoordinate2D( + latitude: viewModel.helper.latitude ?? 0, + longitude: viewModel.helper.longitude ?? 0 + ) + ) { coordinate in + viewModel.helper.setLocationFromPicker( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + } + } + } +} + +// MARK: - Room Behavior Section + +private struct RoomBehaviorSection: View { + @Bindable var viewModel: RoomSettingsViewModel + var focusedField: FocusState.Binding + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsSection, + icon: "slider.horizontal.3", + isExpanded: $viewModel.isRoomSettingsExpanded, + isLoaded: { viewModel.roomSettingsLoaded }, + isLoading: $viewModel.isLoadingRoomSettings, + hasError: $viewModel.roomSettingsError, + onLoad: { await viewModel.fetchRoomSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.RoomSettings.roomSettingsFooter + ) { + SecureField(L10n.RemoteNodes.RemoteNodes.RoomSettings.guestPassword, text: Binding( + get: { viewModel.guestPassword ?? "" }, + set: { viewModel.guestPassword = $0 } + )) + .focused(focusedField, equals: .guestPassword) + .overlay(alignment: .trailing) { + if viewModel.guestPassword == nil && viewModel.isLoadingRoomSettings { + Text(L10n.RemoteNodes.RemoteNodes.Settings.loading) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + } + + Toggle(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnly, isOn: Binding( + get: { viewModel.allowReadOnly ?? false }, + set: { viewModel.allowReadOnly = $0 } + )) + .overlay(alignment: .trailing) { + if viewModel.allowReadOnly == nil && viewModel.isLoadingRoomSettings { + Text(L10n.RemoteNodes.RemoteNodes.Settings.loading) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.trailing, 60) + } + } + + Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.allowReadOnlyFooter) + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertInterval0Hop) + Spacer() + if let interval = viewModel.advertIntervalMinutes { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.min, value: Binding( + get: { interval }, + set: { viewModel.advertIntervalMinutes = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .advertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.min) + .foregroundStyle(.secondary) + } else { + Text(viewModel.isLoadingRoomSettings ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.roomSettingsError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let error = viewModel.advertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.advertIntervalFlood) + Spacer() + if let interval = viewModel.floodAdvertIntervalHours { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hrs, value: Binding( + get: { interval }, + set: { viewModel.floodAdvertIntervalHours = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodAdvertInterval) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hrs) + .foregroundStyle(.secondary) + } else { + Text(viewModel.isLoadingRoomSettings ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.roomSettingsError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let error = viewModel.floodAdvertIntervalError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.maxFloodHops) + Spacer() + if let hops = viewModel.floodMaxHops { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.hops, value: Binding( + get: { hops }, + set: { viewModel.floodMaxHops = $0 } + ), format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + .focused(focusedField, equals: .floodMaxHops) + Text(L10n.RemoteNodes.RemoteNodes.Settings.hops) + .foregroundStyle(.secondary) + } else { + Text(viewModel.isLoadingRoomSettings ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (viewModel.roomSettingsError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let error = viewModel.floodMaxHopsError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Button { + Task { await viewModel.applyRoomSettings() } + } label: { + HStack { + Spacer() + if viewModel.helper.isApplying { + ProgressView() + } else if viewModel.roomSettingsApplySuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Text(L10n.RemoteNodes.RemoteNodes.RoomSettings.applyRoomSettings) + .foregroundStyle(viewModel.roomSettingsModified ? Color.accentColor : .secondary) + .transition(.opacity) + } + Spacer() + } + .animation(.default, value: viewModel.roomSettingsApplySuccess) + } + .disabled(viewModel.helper.isApplying || viewModel.roomSettingsApplySuccess || !viewModel.roomSettingsModified) + } + } +} + +#Preview { + NavigationStack { + RoomSettingsView( + session: RemoteNodeSessionDTO( + id: UUID(), + deviceID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Community Room", + role: .roomServer, + latitude: 37.7749, + longitude: -122.4194, + isConnected: true, + permissionLevel: .admin + ) + ) + .environment(\.appState, AppState()) + } +} diff --git a/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift b/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift new file mode 100644 index 000000000..684c0a579 --- /dev/null +++ b/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift @@ -0,0 +1,284 @@ +import SwiftUI +import MC1Services +import OSLog + +@Observable +@MainActor +final class RoomSettingsViewModel { + + // MARK: - Shared Helper + + var helper = NodeSettingsHelper() + + // MARK: - Room-Only: Room Settings + + var guestPassword: String? + var allowReadOnly: Bool? + var advertIntervalMinutes: Int? + var floodAdvertIntervalHours: Int? + var floodMaxHops: Int? + private var originalGuestPassword: String? + private var originalAllowReadOnly: Bool? + private var originalAdvertIntervalMinutes: Int? + private var originalFloodAdvertIntervalHours: Int? + private var originalFloodMaxHops: Int? + var isLoadingRoomSettings = false + var roomSettingsError = false + var roomSettingsLoaded: Bool { allowReadOnly != nil || advertIntervalMinutes != nil } + + var advertIntervalError: String? + var floodAdvertIntervalError: String? + var floodMaxHopsError: String? + + var roomSettingsApplySuccess = false + + var roomSettingsModified: Bool { + (guestPassword != nil && guestPassword != originalGuestPassword) || + (allowReadOnly != nil && allowReadOnly != originalAllowReadOnly) || + (advertIntervalMinutes != nil && advertIntervalMinutes != originalAdvertIntervalMinutes) || + (floodAdvertIntervalHours != nil && floodAdvertIntervalHours != originalFloodAdvertIntervalHours) || + (floodMaxHops != nil && floodMaxHops != originalFloodMaxHops) + } + + // MARK: - Expansion State (room-only sections) + + var isRoomSettingsExpanded = false + + // MARK: - Dependencies + + private var roomAdminService: RoomAdminService? + private let logger = Logger(subsystem: "MC1", category: "RoomSettings") + + // MARK: - Cleanup + + func cleanup() async { + await roomAdminService?.clearHandlers() + helper.cleanup() + } + + // MARK: - Configuration + + func configure(appState: AppState, session: RemoteNodeSessionDTO) async { + self.roomAdminService = appState.services?.roomAdminService + + guard let roomAdminService else { return } + + helper.configure( + session: session, + sendCommand: { [roomAdminService] id, cmd, timeout in + try await roomAdminService.sendCommand(sessionID: id, command: cmd, timeout: timeout) + }, + sendRawCommand: { [roomAdminService] id, cmd, timeout in + try await roomAdminService.sendRawCommand(sessionID: id, command: cmd, timeout: timeout) + } + ) + + helper.setNodeInfo(firmwareVersion: nil, name: session.name, ownerInfo: nil) + + // Room doesn't have binary protocol for node info — firmware fetched via CLI + helper.onPreFetchNodeInfo = nil + + // Register CLI handler for late responses + await roomAdminService.setCLIHandler { [weak self] message, _ in + await MainActor.run { + self?.handleLateResponse(message.text) + } + } + + Task { await helper.fetchDeviceInfo() } + } + + // MARK: - Late Response Handling + + private func handleLateResponse(_ response: String) { + // Try shared sections first + if helper.handleCommonLateResponse(response) { return } + + // Room settings + if !isLoadingRoomSettings && roomSettingsError { + if let result = NodeSettingsHelper.parseBehaviorLateResponse( + response, + hasAdvertInterval: originalAdvertIntervalMinutes != nil, + hasFloodInterval: originalFloodAdvertIntervalHours != nil, + hasFloodMaxHops: originalFloodMaxHops != nil + ) { + switch result { + case .advertInterval(let interval): + self.advertIntervalMinutes = interval + self.originalAdvertIntervalMinutes = interval + case .floodAdvertInterval(let interval): + self.floodAdvertIntervalHours = interval + self.originalFloodAdvertIntervalHours = interval + case .floodMax(let hops): + self.floodMaxHops = hops + self.originalFloodMaxHops = hops + } + self.roomSettingsError = false + return + } + } + } + + // MARK: - Room Settings Fetch/Apply + + func fetchRoomSettings() async { + isLoadingRoomSettings = true + roomSettingsError = false + var hadTimeout = false + + do { + let response = try await helper.sendAndWait("get guest.password") + let parsed = CLIResponse.parse(response, forQuery: "get guest.password") + switch parsed { + case .ok, .error, .unknownCommand: + self.guestPassword = "" + self.originalGuestPassword = "" + default: + let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) + let value = trimmed.hasPrefix("> ") ? String(trimmed.dropFirst(2)) : trimmed + self.guestPassword = value + self.originalGuestPassword = value + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get guest password: \(error)") + } + + do { + let response = try await helper.sendAndWait("get allow.read.only") + let parsed = CLIResponse.parse(response, forQuery: "get allow.read.only") + switch parsed { + case .raw(let value): + let isOn = value.lowercased() == "on" + self.allowReadOnly = isOn + self.originalAllowReadOnly = isOn + default: + break + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get allow read only: \(error)") + } + + do { + let response = try await helper.sendAndWait("get advert.interval") + if case .advertInterval(let minutes) = CLIResponse.parse(response, forQuery: "get advert.interval") { + self.advertIntervalMinutes = minutes + self.originalAdvertIntervalMinutes = minutes + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get advert interval: \(error)") + } + + do { + let response = try await helper.sendAndWait("get flood.advert.interval") + if case .floodAdvertInterval(let hours) = CLIResponse.parse(response, forQuery: "get flood.advert.interval") { + self.floodAdvertIntervalHours = hours + self.originalFloodAdvertIntervalHours = hours + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood advert interval: \(error)") + } + + do { + let response = try await helper.sendAndWait("get flood.max") + if case .floodMax(let hops) = CLIResponse.parse(response, forQuery: "get flood.max") { + self.floodMaxHops = hops + self.originalFloodMaxHops = hops + } + } catch { + if case RemoteNodeError.timeout = error { hadTimeout = true } + logger.warning("Failed to get flood max: \(error)") + } + + if hadTimeout { + roomSettingsError = true + } + + isLoadingRoomSettings = false + } + + func applyRoomSettings() async { + let validation = NodeSettingsHelper.validateBehaviorFields( + advertInterval: advertIntervalMinutes, + floodInterval: floodAdvertIntervalHours, + floodMaxHops: floodMaxHops + ) + advertIntervalError = validation.advertInterval + floodAdvertIntervalError = validation.floodInterval + floodMaxHopsError = validation.floodMaxHops + + if validation.hasErrors { return } + + helper.isApplying = true + helper.errorMessage = nil + + do { + var allSucceeded = true + + if let guestPassword, guestPassword != originalGuestPassword { + let response = try await helper.sendAndWait("set guest.password \(guestPassword)") + if case .ok = CLIResponse.parse(response) { + originalGuestPassword = guestPassword + } else { + allSucceeded = false + } + } + + if let allowReadOnly, allowReadOnly != originalAllowReadOnly { + let response = try await helper.sendAndWait("set allow.read.only \(allowReadOnly ? "on" : "off")") + if case .ok = CLIResponse.parse(response) { + originalAllowReadOnly = allowReadOnly + } else { + allSucceeded = false + } + } + + if let advertIntervalMinutes, advertIntervalMinutes != originalAdvertIntervalMinutes { + let response = try await helper.sendAndWait("set advert.interval \(advertIntervalMinutes)") + if case .ok = CLIResponse.parse(response) { + originalAdvertIntervalMinutes = advertIntervalMinutes + } else { + allSucceeded = false + } + } + + if let floodAdvertIntervalHours, floodAdvertIntervalHours != originalFloodAdvertIntervalHours { + let response = try await helper.sendAndWait("set flood.advert.interval \(floodAdvertIntervalHours)") + if case .ok = CLIResponse.parse(response) { + originalFloodAdvertIntervalHours = floodAdvertIntervalHours + } else { + allSucceeded = false + } + } + + if let floodMaxHops, floodMaxHops != originalFloodMaxHops { + let response = try await helper.sendAndWait("set flood.max \(floodMaxHops)") + if case .ok = CLIResponse.parse(response) { + originalFloodMaxHops = floodMaxHops + } else { + allSucceeded = false + } + } + + if allSucceeded { + withAnimation { + helper.isApplying = false + roomSettingsApplySuccess = true + } + try? await Task.sleep(for: .seconds(1.5)) + withAnimation { roomSettingsApplySuccess = false } + return + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.someSettingsFailedToApply + } + } catch { + helper.errorMessage = error.localizedDescription + } + + helper.isApplying = false + } + +} diff --git a/MC1/Views/RemoteNodes/RoomStatusView.swift b/MC1/Views/RemoteNodes/RoomStatusView.swift new file mode 100644 index 000000000..dee7dccd1 --- /dev/null +++ b/MC1/Views/RemoteNodes/RoomStatusView.swift @@ -0,0 +1,152 @@ +import MC1Services +import SwiftUI + +/// Display view for room server stats, telemetry, and battery curve +struct RoomStatusView: View { + @Environment(\.appState) private var appState + @Environment(\.dismiss) private var dismiss + + let session: RemoteNodeSessionDTO + @State private var viewModel = RoomStatusViewModel() + + var body: some View { + NavigationStack { + List { + makeHeaderSection() + makeStatusSection() + makeTelemetrySection() + makeBatteryCurveSection() + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomStatus.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.RemoteNodes.RemoteNodes.done) { dismiss() } + } + + ToolbarItem(placement: .primaryAction) { + Button { + refresh() + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Status.refresh) + .radioDisabled( + for: appState.connectionState, + or: viewModel.helper.isLoadingStatus || viewModel.helper.isLoadingTelemetry + ) + } + + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.done) { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + } + } + .task { + viewModel.configure(appState: appState) + await viewModel.registerHandlers(appState: appState) + + // Only request status on first load; user can refresh via toolbar/pull-to-refresh + if viewModel.helper.status == nil { + await viewModel.requestStatus(for: session) + } + + // Pre-load OCV settings + if let deviceID = appState.connectedDevice?.id { + await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) + } + } + .refreshable { + await viewModel.requestStatus(for: session) + // Refresh telemetry only if already loaded + if viewModel.helper.telemetryLoaded { + await viewModel.requestTelemetry(for: session) + } + } + } + .presentationDetents([.large]) + } + + // MARK: - Subviews + + private func makeHeaderSection() -> some View { + NodeStatusHeaderSection(session: session) + } + + private func makeStatusSection() -> some View { + RoomStatusSection(viewModel: viewModel) + } + + private func makeTelemetrySection() -> some View { + NodeTelemetryDisclosureSection(helper: viewModel.helper) { + await viewModel.requestTelemetry(for: session) + } + } + + private func makeBatteryCurveSection() -> some View { + NodeBatteryCurveDisclosureSection( + helper: viewModel.helper, + session: session, + connectionState: appState.connectionState, + connectedDeviceID: appState.connectedDevice?.id + ) + } + + // MARK: - Actions + + private func refresh() { + Task { + await viewModel.requestStatus(for: session) + // Refresh telemetry only if already loaded + if viewModel.helper.telemetryLoaded { + await viewModel.requestTelemetry(for: session) + } + } + } +} + +// MARK: - Status Section + +private struct RoomStatusSection: View { + let viewModel: RoomStatusViewModel + + var body: some View { + NodeStatusSection(helper: viewModel.helper) { + RoomStatusRows(viewModel: viewModel) + } + } +} + +// MARK: - Status Rows + +private struct RoomStatusRows: View { + let viewModel: RoomStatusViewModel + + var body: some View { + NodeCommonStatusRows(helper: viewModel.helper) + LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, value: viewModel.postsReceivedDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, value: viewModel.postsPushedDisplay) + } +} + +#Preview { + RoomStatusView( + session: RemoteNodeSessionDTO( + deviceID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Room", + role: .roomServer, + isConnected: true, + permissionLevel: .admin + ) + ) + .environment(\.appState, AppState()) +} diff --git a/MC1/Views/RemoteNodes/RoomStatusViewModel.swift b/MC1/Views/RemoteNodes/RoomStatusViewModel.swift new file mode 100644 index 000000000..57c34b5ab --- /dev/null +++ b/MC1/Views/RemoteNodes/RoomStatusViewModel.swift @@ -0,0 +1,108 @@ +import MC1Services +import SwiftUI + +/// ViewModel for room server status display +@Observable +@MainActor +final class RoomStatusViewModel { + + // MARK: - Shared Helper + + var helper = NodeStatusHelper() + + // MARK: - Dependencies + + private var roomAdminService: RoomAdminService? + + // MARK: - Initialization + + init() {} + + func configure(appState: AppState) { + self.roomAdminService = appState.services?.roomAdminService + helper.configure( + contactService: appState.services?.contactService, + nodeSnapshotService: appState.services?.nodeSnapshotService + ) + } + + func registerHandlers(appState: AppState) async { + guard let roomAdminService = appState.services?.roomAdminService else { return } + + await roomAdminService.clearHandlers() + + await roomAdminService.setStatusHandler { [weak self] status in + await self?.handleStatusResponse(status) + } + + await roomAdminService.setTelemetryHandler { [weak self] response in + await self?.helper.handleTelemetryResponse(response) + } + } + + // MARK: - Status + + func requestStatus(for session: RemoteNodeSessionDTO) async { + guard let roomAdminService else { return } + + if helper.session == nil { helper.session = session } + helper.isLoadingStatus = true + helper.errorMessage = nil + + do { + let response = try await helper.performWithTransientRetries(operationName: "status") { [roomAdminService] timeout in + return try await roomAdminService.requestStatus(sessionID: session.id, timeout: timeout) + } + await handleStatusResponse(response) + } catch RemoteNodeError.timeout { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.isLoadingStatus = false + } catch { + helper.errorMessage = error.localizedDescription + helper.isLoadingStatus = false + } + } + + private func handleStatusResponse(_ response: RemoteNodeStatus) async { + await helper.handleStatusResponse( + response, + postedCount: response.roomServerPostedCount, + postPushCount: response.roomServerPostPushCount + ) + } + + // MARK: - Telemetry + + func requestTelemetry(for session: RemoteNodeSessionDTO) async { + guard let roomAdminService else { return } + + if helper.session == nil { helper.session = session } + helper.isLoadingTelemetry = true + helper.errorMessage = nil + + do { + let response = try await helper.performWithTransientRetries(operationName: "telemetry") { [roomAdminService] timeout in + return try await roomAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) + } + helper.handleTelemetryResponse(response) + } catch RemoteNodeError.timeout { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + helper.isLoadingTelemetry = false + } catch { + helper.errorMessage = error.localizedDescription + helper.isLoadingTelemetry = false + } + } + + // MARK: - Room-Only Display + + var postsReceivedDisplay: String { + guard let count = helper.status?.roomServerPostedCount else { return NodeStatusHelper.emDash } + return count.formatted() + } + + var postsPushedDisplay: String { + guard let count = helper.status?.roomServerPostPushCount else { return NodeStatusHelper.emDash } + return count.formatted() + } +} diff --git a/MC1/Views/RemoteNodes/SharedNodeViews.swift b/MC1/Views/RemoteNodes/SharedNodeViews.swift new file mode 100644 index 000000000..5733e0619 --- /dev/null +++ b/MC1/Views/RemoteNodes/SharedNodeViews.swift @@ -0,0 +1,662 @@ +import MC1Services +import SwiftUI + +// MARK: - Unified Focus Field + +enum NodeSettingsField: Hashable { + case frequency, txPower, advertInterval, floodAdvertInterval, floodMaxHops + case identityName, contactInfo, guestPassword +} + +// MARK: - Status Header + +struct NodeStatusHeaderSection: View { + let session: RemoteNodeSessionDTO + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + NodeAvatar(publicKey: session.publicKey, role: session.role, size: 60) + + Text(session.name) + .font(.headline) + + if session.permissionLevel == .guest { + Text(L10n.RemoteNodes.RemoteNodes.Status.guestMode) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .listRowBackground(Color.clear) + } + } +} + +// MARK: - Settings Header + +struct NodeSettingsHeaderSection: View { + let publicKey: Data + let name: String + let role: RemoteNodeRole + + var body: some View { + Section { + HStack { + Spacer() + VStack(spacing: 8) { + NodeAvatar(publicKey: publicKey, role: role, size: 60) + Text(name) + .font(.headline) + } + Spacer() + } + .listRowBackground(Color.clear) + } + } +} + +// MARK: - Common Status Rows + +struct NodeCommonStatusRows: View { + let helper: NodeStatusHelper + + var body: some View { + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.battery, + value: helper.batteryDisplay, + delta: helper.batteryDeltaMV.map { Double($0) / 1000.0 }, + higherIsBetter: true, unit: " V", fractionDigits: 2 + ) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.uptime, value: helper.uptimeDisplay) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.lastRssi, + value: helper.lastRSSIDisplay, + delta: helper.rssiDelta.map(Double.init), + higherIsBetter: true, unit: " dBm", fractionDigits: 0 + ) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.lastSnr, + value: helper.lastSNRDisplay, + delta: helper.snrDelta, + higherIsBetter: true, unit: " dB", fractionDigits: 1 + ) + + NodeMetricRow( + label: L10n.RemoteNodes.RemoteNodes.Status.noiseFloor, + value: helper.noiseFloorDisplay, + delta: helper.noiseFloorDelta.map(Double.init), + higherIsBetter: false, unit: " dBm", fractionDigits: 0 + ) + + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsSent, value: helper.packetsSentDisplay) + LabeledContent(L10n.RemoteNodes.RemoteNodes.Status.packetsReceived, value: helper.packetsReceivedDisplay) + } +} + +// MARK: - Status Section + +struct NodeStatusSection: View { + let helper: NodeStatusHelper + @ViewBuilder let rows: () -> Rows + + var body: some View { + Section(L10n.RemoteNodes.RemoteNodes.Status.statusSection) { + if helper.isLoadingStatus && helper.status == nil { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let errorMessage = helper.errorMessage, helper.status == nil { + Text(errorMessage) + .foregroundStyle(.red) + } else { + rows() + + if let timestamp = helper.previousSnapshotTimestamp { + Text(timestamp) + .font(.caption) + .foregroundStyle(.secondary) + } + + NavigationLink { + NodeStatusHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) + } label: { + Text(L10n.RemoteNodes.RemoteNodes.History.title) + } + } + } + } +} + +// MARK: - Metric Row + +struct NodeMetricRow: View { + let label: String + let value: String + let delta: Double? + let higherIsBetter: Bool + let unit: String + let fractionDigits: Int + + var body: some View { + LabeledContent { + VStack(alignment: .trailing, spacing: 2) { + Text(value) + if let delta { + StatusDeltaView(delta: delta, higherIsBetter: higherIsBetter, unit: unit, fractionDigits: fractionDigits) + } + } + } label: { + Text(label) + } + } +} + +// MARK: - Telemetry Row + +struct NodeTelemetryRow: View { + let dataPoint: LPPDataPoint + let ocvArray: [Int] + + var body: some View { + if dataPoint.type == .voltage, case .float(let voltage) = dataPoint.value { + let millivolts = Int(voltage * 1000) + let battery = BatteryInfo(level: millivolts) + let percentage = battery.percentage(using: ocvArray) + + LabeledContent(dataPoint.typeName) { + VStack(alignment: .trailing, spacing: 2) { + Text(dataPoint.formattedValue) + Text("\(percentage)%") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + LabeledContent(dataPoint.typeName, value: dataPoint.formattedValue) + } + } +} + +// MARK: - Battery Curve Disclosure Section + +struct NodeBatteryCurveDisclosureSection: View { + @Bindable var helper: NodeStatusHelper + let session: RemoteNodeSessionDTO + let connectionState: ConnectionState + let connectedDeviceID: UUID? + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.isBatteryCurveExpanded) { + BatteryCurveSection( + availablePresets: OCVPreset.nodePresets, + headerText: "", + footerText: "", + selectedPreset: $helper.selectedOCVPreset, + voltageValues: $helper.ocvValues, + onSave: helper.saveOCVSettings, + isDisabled: connectionState != .ready + ) + + if let error = helper.ocvError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } label: { + Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurve) + } + .onChange(of: helper.isBatteryCurveExpanded) { _, isExpanded in + if isExpanded, let deviceID = connectedDeviceID { + Task { + await helper.loadOCVSettings(publicKey: session.publicKey, deviceID: deviceID) + } + } + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.batteryCurveFooter) + } + } +} + +// MARK: - Telemetry Disclosure Section + +struct NodeTelemetryDisclosureSection: View { + @Bindable var helper: NodeStatusHelper + let onRequestTelemetry: () async -> Void + + var body: some View { + Section { + DisclosureGroup(isExpanded: $helper.telemetryExpanded) { + if helper.isLoadingTelemetry { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if helper.telemetry != nil { + if helper.cachedDataPoints.isEmpty { + Text(L10n.RemoteNodes.RemoteNodes.Status.noSensorData) + .foregroundStyle(.secondary) + } else if helper.hasMultipleChannels { + ForEach(helper.groupedDataPoints, id: \.channel) { group in + Section { + ForEach(group.dataPoints, id: \.self) { dataPoint in + NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) + } + } header: { + Text(L10n.RemoteNodes.RemoteNodes.Status.channel(Int(group.channel))) + .fontWeight(.semibold) + } + } + } else { + ForEach(helper.cachedDataPoints, id: \.self) { dataPoint in + NodeTelemetryRow(dataPoint: dataPoint, ocvArray: helper.ocvValues) + } + } + + NavigationLink { + TelemetryHistoryView(fetchSnapshots: helper.fetchHistory, ocvArray: helper.ocvValues) + } label: { + Text(L10n.RemoteNodes.RemoteNodes.History.title) + } + } else { + Text(L10n.RemoteNodes.RemoteNodes.Status.noTelemetryData) + .foregroundStyle(.secondary) + } + } label: { + Text(L10n.RemoteNodes.RemoteNodes.Status.telemetry) + } + .onChange(of: helper.telemetryExpanded) { _, isExpanded in + if isExpanded && !helper.telemetryLoaded { + Task { + await onRequestTelemetry() + } + } + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Status.telemetryFooter) + } + } +} + +// MARK: - Device Info Section + +struct NodeDeviceInfoSection: View { + @Bindable var settings: NodeSettingsHelper + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfo, + icon: "info.circle", + isExpanded: $settings.isDeviceInfoExpanded, + isLoaded: { settings.deviceInfoLoaded }, + isLoading: $settings.isLoadingDeviceInfo, + hasError: $settings.deviceInfoError, + onLoad: { await settings.fetchDeviceInfo() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.deviceInfoFooter + ) { + LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: settings.firmwareVersion ?? "\u{2014}") + LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: settings.deviceTime ?? "\u{2014}") + } + } +} + +// MARK: - Radio Settings Section + +struct NodeRadioSettingsSection: View { + @Bindable var settings: NodeSettingsHelper + var focusedField: FocusState.Binding + var radioRestartWarning: String = L10n.RemoteNodes.RemoteNodes.Settings.radioRestartWarning + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.radioParameters, + icon: "antenna.radiowaves.left.and.right", + isExpanded: $settings.isRadioExpanded, + isLoaded: { settings.radioLoaded }, + isLoading: $settings.isLoadingRadio, + hasError: $settings.radioError, + onLoad: { await settings.fetchRadioSettings() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.radioFooter + ) { + if settings.radioSettingsModified { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(radioRestartWarning) + .font(.subheadline) + } + .padding() + .frame(maxWidth: .infinity) + .background(.yellow.opacity(0.1)) + .clipShape(.rect(cornerRadius: 8)) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.frequencyMHz) + Spacer() + if let frequency = settings.frequency { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.mhz, value: Binding( + get: { frequency }, + set: { settings.frequency = $0 } + ), format: .number.precision(.fractionLength(3)).locale(.posix)) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 100) + .focused(focusedField, equals: .frequency) + .onChange(of: settings.frequency) { _, _ in + settings.radioSettingsModified = true + } + } else { + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 100, alignment: .trailing) + } + } + + if let bandwidth = settings.bandwidth { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz, selection: Binding( + get: { bandwidth }, + set: { settings.bandwidth = $0 } + )) { + ForEach(RadioOptions.bandwidthsKHz, id: \.self) { bwKHz in + Text(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000))) + .tag(bwKHz) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.bandwidthLabel(RadioOptions.formatBandwidth(UInt32(bwKHz * 1000)))) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthHint) + .onChange(of: settings.bandwidth) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.bandwidthKHz) + Spacer() + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let spreadingFactor = settings.spreadingFactor { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor, selection: Binding( + get: { spreadingFactor }, + set: { settings.spreadingFactor = $0 } + )) { + ForEach(RadioOptions.spreadingFactors, id: \.self) { sf in + Text(sf, format: .number) + .tag(sf) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.spreadingFactorLabel(sf)) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactorHint) + .onChange(of: settings.spreadingFactor) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.spreadingFactor) + Spacer() + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let codingRate = settings.codingRate { + Picker(L10n.RemoteNodes.RemoteNodes.Settings.codingRate, selection: Binding( + get: { codingRate }, + set: { settings.codingRate = $0 } + )) { + ForEach(RadioOptions.codingRates, id: \.self) { cr in + Text("\(cr)") + .tag(cr) + .accessibilityLabel(L10n.RemoteNodes.RemoteNodes.Settings.Accessibility.codingRateLabel(cr)) + } + } + .pickerStyle(.menu) + .tint(.primary) + .accessibilityHint(L10n.RemoteNodes.RemoteNodes.Settings.codingRateHint) + .onChange(of: settings.codingRate) { _, _ in + settings.radioSettingsModified = true + } + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.codingRate) + Spacer() + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.txPowerDbm) + Spacer() + if let txPower = settings.txPower { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.dbm, value: Binding( + get: { txPower }, + set: { settings.txPower = $0 } + ), format: .number) + .keyboardType(.numbersAndPunctuation) + .multilineTextAlignment(.trailing) + .frame(width: 80) + .focused(focusedField, equals: .txPower) + .onChange(of: settings.txPower) { _, _ in + settings.radioSettingsModified = true + } + } else { + Text(settings.isLoadingRadio ? L10n.RemoteNodes.RemoteNodes.Settings.loading : (settings.radioError ? L10n.RemoteNodes.RemoteNodes.Settings.failedToLoad : "—")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 80, alignment: .trailing) + } + } + + Button(L10n.RemoteNodes.RemoteNodes.Settings.applyRadioSettings) { + Task { await settings.applyRadioSettings() } + } + .disabled(!settings.radioSettingsModified || settings.isApplying) + } + } +} + +// MARK: - Identity Section + +struct RemoteNodeIdentitySection: View { + @Bindable var settings: NodeSettingsHelper + var focusedField: FocusState.Binding + var onPickLocation: () -> Void + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.identityLocation, + icon: "person.text.rectangle", + isExpanded: $settings.isIdentityExpanded, + isLoaded: { settings.identityLoaded }, + isLoading: $settings.isLoadingIdentity, + hasError: $settings.identityError, + onLoad: { await settings.fetchIdentity() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.identityFooter + ) { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.name) + Spacer() + TextField(L10n.RemoteNodes.RemoteNodes.name, text: Binding( + get: { settings.name ?? "" }, + set: { settings.name = $0 } + )) + .multilineTextAlignment(.trailing) + .focused(focusedField, equals: .identityName) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.latitude) + Spacer() + TextField(L10n.RemoteNodes.RemoteNodes.Settings.latitude, value: Binding( + get: { settings.latitude ?? 0 }, + set: { settings.latitude = $0 } + ), format: .number.precision(.fractionLength(6))) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 140) + } + + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.longitude) + Spacer() + TextField(L10n.RemoteNodes.RemoteNodes.Settings.longitude, value: Binding( + get: { settings.longitude ?? 0 }, + set: { settings.longitude = $0 } + ), format: .number.precision(.fractionLength(6))) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 140) + } + + Button(L10n.RemoteNodes.RemoteNodes.Settings.pickOnMap, systemImage: "map") { + onPickLocation() + } + + Button { + Task { await settings.applyIdentitySettings() } + } label: { + if settings.identityApplySuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyIdentitySettings) + } + } + .disabled(!settings.identitySettingsModified || settings.isApplying) + } + } +} + +// MARK: - Contact Info Section + +struct NodeContactInfoSection: View { + @Bindable var settings: NodeSettingsHelper + var focusedField: FocusState.Binding + + var body: some View { + ExpandableSettingsSection( + title: L10n.RemoteNodes.RemoteNodes.Settings.contactInfo, + icon: "person.crop.rectangle", + isExpanded: $settings.isContactInfoExpanded, + isLoaded: { settings.contactInfoLoaded }, + isLoading: $settings.isLoadingContactInfo, + hasError: $settings.contactInfoError, + onLoad: { await settings.fetchContactInfo() }, + footer: L10n.RemoteNodes.RemoteNodes.Settings.contactInfoFooter + ) { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, text: Binding( + get: { settings.ownerInfo ?? "" }, + set: { settings.ownerInfo = $0 } + ), axis: .vertical) + .lineLimit(3...6) + .focused(focusedField, equals: .contactInfo) + .overlay(alignment: .bottomTrailing) { + Text("\(settings.ownerInfoCharCount)/119") + .font(.caption2) + .foregroundStyle(settings.ownerInfoCharCount > 119 ? .red : .secondary) + .padding(4) + } + + Button { + Task { await settings.applyContactInfoSettings() } + } label: { + if settings.contactInfoApplySuccess { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Text(L10n.RemoteNodes.RemoteNodes.Settings.applyContactInfo) + } + } + .disabled(!settings.contactInfoSettingsModified || settings.isApplying || settings.ownerInfoCharCount > 119) + } + } +} + +// MARK: - Security Section + +struct NodeSecuritySection: View { + @Bindable var settings: NodeSettingsHelper + + var body: some View { + Section { + DisclosureGroup(isExpanded: $settings.isSecurityExpanded) { + SecureField(L10n.RemoteNodes.RemoteNodes.Settings.newPassword, text: $settings.newPassword) + SecureField(L10n.RemoteNodes.RemoteNodes.Settings.confirmPassword, text: $settings.confirmPassword) + + Button(L10n.RemoteNodes.RemoteNodes.Settings.changePassword) { + Task { await settings.changePassword() } + } + .disabled(settings.isApplying || settings.newPassword.isEmpty || settings.newPassword != settings.confirmPassword) + } label: { + Label(L10n.RemoteNodes.RemoteNodes.Settings.security, systemImage: "lock") + } + } footer: { + Text(L10n.RemoteNodes.RemoteNodes.Settings.securityFooter) + } + } +} + +// MARK: - Actions Section + +struct NodeActionsSection: View { + let settings: NodeSettingsHelper + @Binding var showRebootConfirmation: Bool + var rebootConfirmTitle: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootConfirmTitle + var rebootMessage: String = L10n.RemoteNodes.RemoteNodes.Settings.rebootMessage + + var body: some View { + Section(L10n.RemoteNodes.RemoteNodes.Settings.deviceActions) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.sendAdvert) { + Task { await settings.forceAdvert() } + } + + Button(L10n.RemoteNodes.RemoteNodes.Settings.syncTime) { + Task { await settings.syncTime() } + } + .disabled(settings.isApplying) + + Button(L10n.RemoteNodes.RemoteNodes.Settings.rebootDevice, role: .destructive) { + showRebootConfirmation = true + } + .disabled(settings.isRebooting) + .confirmationDialog(rebootConfirmTitle, isPresented: $showRebootConfirmation) { + Button(L10n.RemoteNodes.RemoteNodes.Settings.reboot, role: .destructive) { + Task { await settings.reboot() } + } + Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { } + } message: { + Text(rebootMessage) + } + + if let error = settings.errorMessage { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + } +} diff --git a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift index fafcf1b10..83f6c4c66 100644 --- a/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift +++ b/MC1/Views/RemoteNodes/TelemetryHistoryOverviewView.swift @@ -54,7 +54,8 @@ struct TelemetryHistoryOverviewView: View { $0.batteryMillivolts != nil || $0.lastSNR != nil || $0.lastRSSI != nil || $0.noiseFloor != nil || $0.packetsSent != nil || $0.packetsReceived != nil || - $0.receiveErrors != nil + $0.receiveErrors != nil || + $0.postedCount != nil || $0.postPushCount != nil } if hasRadioData { @@ -121,6 +122,22 @@ struct TelemetryHistoryOverviewView: View { s.receiveErrors.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } } ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsReceived, + unit: "", color: .purple, + dataPoints: filtered.compactMap { s in + s.postedCount.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) + + metricChart( + title: L10n.RemoteNodes.RemoteNodes.RoomStatus.postsPushed, + unit: "", color: .cyan, + dataPoints: filtered.compactMap { s in + s.postPushCount.map { .init(id: s.id, date: s.timestamp, value: Double($0)) } + } + ) } } } diff --git a/MC1Services/Sources/MC1Services/MC1Services.swift b/MC1Services/Sources/MC1Services/MC1Services.swift index 1c0ed0cb0..25e7b127f 100644 --- a/MC1Services/Sources/MC1Services/MC1Services.swift +++ b/MC1Services/Sources/MC1Services/MC1Services.swift @@ -80,6 +80,9 @@ public enum RadioOptions { 7_800, 10_400, 15_600, 20_800, 31_250, 41_700, 62_500, 125_000, 250_000, 500_000 ] + /// Bandwidth options in kHz for CLI protocol display + public static let bandwidthsKHz: [Double] = bandwidthsHz.map { Double($0) / 1000.0 } + /// Valid spreading factor range (SF5-SF12) public static let spreadingFactors: ClosedRange = 5...12 diff --git a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift index 2390d4779..69b981977 100644 --- a/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift +++ b/MC1Services/Sources/MC1Services/Models/NodeStatusSnapshot.swift @@ -55,6 +55,11 @@ public final class NodeStatusSnapshot { public var packetsReceived: UInt32? public var receiveErrors: UInt32? + // MARK: - Room server metrics + + public var postedCount: UInt16? + public var postPushCount: UInt16? + // MARK: - Optional neighbor/telemetry data /// Neighbor data, only populated if the user expanded the neighbors section. @@ -76,6 +81,8 @@ public final class NodeStatusSnapshot { packetsSent: UInt32? = nil, packetsReceived: UInt32? = nil, receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil, neighborSnapshots: [NeighborSnapshotEntry]? = nil, telemetryEntries: [TelemetrySnapshotEntry]? = nil ) { @@ -91,6 +98,8 @@ public final class NodeStatusSnapshot { self.packetsSent = packetsSent self.packetsReceived = packetsReceived self.receiveErrors = receiveErrors + self.postedCount = postedCount + self.postPushCount = postPushCount self.neighborSnapshots = neighborSnapshots self.telemetryEntries = telemetryEntries } @@ -111,6 +120,8 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { public let packetsSent: UInt32? public let packetsReceived: UInt32? public let receiveErrors: UInt32? + public let postedCount: UInt16? + public let postPushCount: UInt16? public let neighborSnapshots: [NeighborSnapshotEntry]? public let telemetryEntries: [TelemetrySnapshotEntry]? @@ -127,6 +138,8 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { self.packetsSent = model.packetsSent self.packetsReceived = model.packetsReceived self.receiveErrors = model.receiveErrors + self.postedCount = model.postedCount + self.postPushCount = model.postPushCount self.neighborSnapshots = model.neighborSnapshots self.telemetryEntries = model.telemetryEntries } @@ -144,6 +157,8 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { packetsSent: UInt32? = nil, packetsReceived: UInt32? = nil, receiveErrors: UInt32? = nil, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil, neighborSnapshots: [NeighborSnapshotEntry]? = nil, telemetryEntries: [TelemetrySnapshotEntry]? = nil ) { @@ -159,6 +174,8 @@ public struct NodeStatusSnapshotDTO: Sendable, Equatable, Identifiable { self.packetsSent = packetsSent self.packetsReceived = packetsReceived self.receiveErrors = receiveErrors + self.postedCount = postedCount + self.postPushCount = postPushCount self.neighborSnapshots = neighborSnapshots self.telemetryEntries = telemetryEntries } diff --git a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift index 66f97a3cb..9a2f445f9 100644 --- a/MC1Services/Sources/MC1Services/Models/OCVPreset.swift +++ b/MC1Services/Sources/MC1Services/Models/OCVPreset.swift @@ -103,9 +103,9 @@ public enum OCVPreset: String, CaseIterable, Codable, Sendable { allCases.filter { $0.category == .batteryChemistry } } - /// Presets available for repeater/remote node configuration. + /// Presets available for remote node configuration. /// Includes battery chemistry types plus select device-specific presets. - public static var repeaterPresets: [OCVPreset] { + public static var nodePresets: [OCVPreset] { var presets = batteryChemistryPresets presets.append(.seeedSolarNode) return presets diff --git a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift index 0b5746eda..3ae67b019 100644 --- a/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift +++ b/MC1Services/Sources/MC1Services/Protocols/PersistenceStoreProtocol.swift @@ -411,7 +411,9 @@ public protocol PersistenceStoreProtocol: Actor { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? ) async throws -> UUID /// Fetch the most recent snapshot for a node diff --git a/MC1Services/Sources/MC1Services/ServiceContainer.swift b/MC1Services/Sources/MC1Services/ServiceContainer.swift index fb0853157..90709d593 100644 --- a/MC1Services/Sources/MC1Services/ServiceContainer.swift +++ b/MC1Services/Sources/MC1Services/ServiceContainer.swift @@ -103,6 +103,9 @@ public final class ServiceContainer { /// Service for repeater administration public let repeaterAdminService: RepeaterAdminService + /// Service for room server administration (telemetry, settings) + public let roomAdminService: RoomAdminService + /// Service for room server operations public let roomServerService: RoomServerService @@ -179,6 +182,10 @@ public final class ServiceContainer { remoteNodeService: remoteNodeService, dataStore: dataStore ) + self.roomAdminService = RoomAdminService( + remoteNodeService: remoteNodeService, + dataStore: dataStore + ) self.roomServerService = RoomServerService( session: session, remoteNodeService: remoteNodeService, diff --git a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift index 50977c309..d30506371 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSnapshotService.swift @@ -25,7 +25,9 @@ public actor NodeSnapshotService { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil ) async -> UUID? { do { if let latest = try await dataStore.fetchLatestNodeStatusSnapshot(nodePublicKey: nodePublicKey), @@ -44,7 +46,9 @@ public actor NodeSnapshotService { rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, packetsReceived: packetsReceived, - receiveErrors: receiveErrors + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) logger.info("Saved status snapshot for node") return id diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift index bde70b7aa..b2831a05c 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift @@ -377,7 +377,9 @@ extension PersistenceStore { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil ) throws -> UUID { try saveNodeStatusSnapshot( timestamp: .now, @@ -390,7 +392,9 @@ extension PersistenceStore { rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, packetsReceived: packetsReceived, - receiveErrors: receiveErrors + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) } @@ -407,7 +411,9 @@ extension PersistenceStore { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16? = nil, + postPushCount: UInt16? = nil ) throws -> UUID { let snapshot = NodeStatusSnapshot( timestamp: timestamp, @@ -420,7 +426,9 @@ extension PersistenceStore { rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, packetsReceived: packetsReceived, - receiveErrors: receiveErrors + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) modelContext.insert(snapshot) try modelContext.save() diff --git a/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift b/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift new file mode 100644 index 000000000..ae7a9eeef --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/RoomAdminService.swift @@ -0,0 +1,156 @@ +import Foundation +import MeshCore +import os + +/// Service for room server admin interactions. +/// Handles viewing status/telemetry and sending CLI commands to room servers. +/// Room authentication is handled by `RoomServerService.joinRoom()` via `NodeAuthenticationSheet`. +public actor RoomAdminService { + + // MARK: - Properties + + private let remoteNodeService: RemoteNodeService + private let dataStore: PersistenceStore + private let logger = PersistentLogger(subsystem: "com.mc1", category: "RoomAdmin") + private let auditLogger = CommandAuditLogger() + + private var telemetryResponseHandler: (@Sendable (TelemetryResponse) async -> Void)? + private var statusResponseHandler: (@Sendable (StatusResponse) async -> Void)? + private var cliResponseHandler: (@Sendable (ContactMessage, ContactDTO) async -> Void)? + + // MARK: - Initialization + + public init( + remoteNodeService: RemoteNodeService, + dataStore: PersistenceStore + ) { + self.remoteNodeService = remoteNodeService + self.dataStore = dataStore + } + + // MARK: - Status + + /// Request status from a room server. + public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { + try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) + } + + // MARK: - Telemetry + + /// Request telemetry from a room server. + public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { + try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) + } + + // MARK: - CLI Commands + + /// Send a CLI command to a room server and wait for response (admin only). + /// Uses content-based matching for structured CLI responses. + public func sendCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + /// Send a raw CLI command using FIFO response matching (admin only). + public func sendRawCommand( + sessionID: UUID, + command: String, + timeout: Duration = .seconds(10) + ) async throws -> String { + try await remoteNodeService.sendRawCLICommand( + sessionID: sessionID, + command: command, + timeout: timeout + ) + } + + // MARK: - Session Queries + + /// Fetch all room admin sessions for a device. + public func fetchRoomAdminSessions(deviceID: UUID) async throws -> [RemoteNodeSessionDTO] { + let sessions = try await dataStore.fetchRemoteNodeSessions(deviceID: deviceID) + return sessions.filter { $0.isRoom } + } + + /// Check if a contact is a known room with an active session. + public func getConnectedSession(publicKeyPrefix: Data) async throws -> RemoteNodeSessionDTO? { + guard let remoteSession = try await dataStore.fetchRemoteNodeSessionByPrefix(publicKeyPrefix), + remoteSession.isRoom && remoteSession.isConnected else { + return nil + } + return remoteSession + } + + // MARK: - Handler Invocation + + /// Invoke the status response handler safely from actor context + public func invokeStatusHandler(_ status: StatusResponse) async { + await auditLogger.logStatusResponse( + target: .room, + publicKey: status.publicKeyPrefix, + batteryMv: status.batteryMillivolts, + uptimeSec: status.uptimeSeconds + ) + + guard let handler = statusResponseHandler else { + let prefixHex = status.publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + logger.debug("No status handler registered for room response from \(prefixHex), ignoring") + return + } + await handler(status) + } + + /// Invoke the telemetry response handler safely from actor context + public func invokeTelemetryHandler(_ response: TelemetryResponse) async { + await auditLogger.logTelemetryResponse( + target: .room, + publicKey: response.publicKeyPrefix, + pointCount: response.dataPoints.count + ) + + guard let handler = telemetryResponseHandler else { + logger.debug("No telemetry handler registered for room, ignoring response") + return + } + await handler(response) + } + + /// Invoke the CLI response handler safely from actor context + public func invokeCLIHandler(_ message: ContactMessage, fromContact contact: ContactDTO) async { + await auditLogger.logCLIResponse(publicKey: contact.publicKey, response: message.text) + + guard let handler = cliResponseHandler else { + logger.debug("No CLI handler registered for room, ignoring response from \(contact.displayName)") + return + } + await handler(message, contact) + } + + // MARK: - Handler Setters + + public func setStatusHandler(_ handler: @escaping @Sendable (StatusResponse) async -> Void) { + self.statusResponseHandler = handler + } + + public func setTelemetryHandler(_ handler: @escaping @Sendable (TelemetryResponse) async -> Void) { + self.telemetryResponseHandler = handler + } + + public func setCLIHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO) async -> Void) { + self.cliResponseHandler = handler + } + + /// Clear all handlers (called when view disappears) + public func clearHandlers() { + self.statusResponseHandler = nil + self.telemetryResponseHandler = nil + self.cliResponseHandler = nil + } +} diff --git a/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift index 3db0e2a31..778f38455 100644 --- a/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/SyncCoordinator+MessageHandlers.swift @@ -410,7 +410,11 @@ extension SyncCoordinator { guard let self else { return } if let contact { - await services.repeaterAdminService.invokeCLIHandler(message, fromContact: contact) + if contact.type == .room { + await services.roomAdminService.invokeCLIHandler(message, fromContact: contact) + } else { + await services.repeaterAdminService.invokeCLIHandler(message, fromContact: contact) + } } else { self.logger.warning("Dropping CLI response: no contact found for sender") } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index 6aad0bcbc..da4a1de6f 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -1426,7 +1426,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, - receiveErrors: UInt32? + receiveErrors: UInt32?, + postedCount: UInt16?, + postPushCount: UInt16? ) async throws -> UUID { let dto = NodeStatusSnapshotDTO( nodePublicKey: nodePublicKey, @@ -1438,7 +1440,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { rxAirtimeSeconds: rxAirtimeSeconds, packetsSent: packetsSent, packetsReceived: packetsReceived, - receiveErrors: receiveErrors + receiveErrors: receiveErrors, + postedCount: postedCount, + postPushCount: postPushCount ) nodeStatusSnapshots.append(dto) return dto.id @@ -1479,6 +1483,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { rxAirtimeSeconds: existing.rxAirtimeSeconds, packetsSent: existing.packetsSent, packetsReceived: existing.packetsReceived, + receiveErrors: existing.receiveErrors, + postedCount: existing.postedCount, + postPushCount: existing.postPushCount, neighborSnapshots: neighbors, telemetryEntries: existing.telemetryEntries ) @@ -1500,6 +1507,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { rxAirtimeSeconds: existing.rxAirtimeSeconds, packetsSent: existing.packetsSent, packetsReceived: existing.packetsReceived, + receiveErrors: existing.receiveErrors, + postedCount: existing.postedCount, + postPushCount: existing.postPushCount, neighborSnapshots: existing.neighborSnapshots, telemetryEntries: telemetry ) diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index a0711a3bc..4de825291 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -376,7 +376,7 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { // Node Status Snapshots // swiftlint:disable:next line_length - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?) async throws -> UUID { UUID() } + func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } func fetchPreviousNodeStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { nil } diff --git a/MC1Tests/Services/MessageEventBroadcasterTests.swift b/MC1Tests/Services/MessageEventBroadcasterTests.swift index 6a60fb9de..436d96425 100644 --- a/MC1Tests/Services/MessageEventBroadcasterTests.swift +++ b/MC1Tests/Services/MessageEventBroadcasterTests.swift @@ -37,7 +37,6 @@ struct MessageEventBroadcasterTests { #expect(broadcaster.dataStore == nil) #expect(broadcaster.roomServerService == nil) #expect(broadcaster.binaryProtocolService == nil) - #expect(broadcaster.repeaterAdminService == nil) } // MARK: - Handler Methods @@ -379,7 +378,6 @@ struct MessageEventBroadcasterTests { #expect(broadcaster.dataStore != nil) #expect(broadcaster.roomServerService != nil) #expect(broadcaster.binaryProtocolService != nil) - #expect(broadcaster.repeaterAdminService != nil) } } diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index 7ddee7ae3..84d54a941 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -386,7 +386,7 @@ actor PaginationTestDataStore: PersistenceStoreProtocol { // MARK: - Node Status Snapshots // swiftlint:disable:next line_length - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?) async throws -> UUID { UUID() } + func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } func fetchPreviousNodeStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { nil } diff --git a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift index 67f94d21b..58611a1bd 100644 --- a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift +++ b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift @@ -211,7 +211,7 @@ actor MockPersistenceStore: PersistenceStoreProtocol { // MARK: - Node Status Snapshots (stubs) // swiftlint:disable:next line_length - func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?) async throws -> UUID { UUID() } + func saveNodeStatusSnapshot(nodePublicKey: Data, batteryMillivolts: UInt16?, lastSNR: Double?, lastRSSI: Int16?, noiseFloor: Int16?, uptimeSeconds: UInt32?, rxAirtimeSeconds: UInt32?, packetsSent: UInt32?, packetsReceived: UInt32?, receiveErrors: UInt32?, postedCount: UInt16?, postPushCount: UInt16?) async throws -> UUID { UUID() } func fetchLatestNodeStatusSnapshot(nodePublicKey: Data) async throws -> NodeStatusSnapshotDTO? { nil } func fetchNodeStatusSnapshots(nodePublicKey: Data, since: Date?) async throws -> [NodeStatusSnapshotDTO] { [] } func fetchPreviousNodeStatusSnapshot(nodePublicKey: Data, before: Date) async throws -> NodeStatusSnapshotDTO? { nil } diff --git a/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift b/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift index 980295576..8e51078bf 100644 --- a/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift +++ b/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift @@ -72,31 +72,42 @@ struct RepeaterStatusViewModelTests { let session = createTestSession() let viewModel = RepeaterStatusViewModel() - viewModel.nodeSnapshotService = service - viewModel.session = session + viewModel.helper.configure(contactService: nil, nodeSnapshotService: service) + viewModel.helper.session = session // Visit 1: First status response — snapshot saved (not throttled) - await viewModel.handleStatusResponse(createStatusResponse()) - let snapshots1 = await viewModel.fetchHistory() + let status = createStatusResponse() + await viewModel.helper.handleStatusResponse( + status, + rxAirtimeSeconds: status.repeaterRxAirtimeSeconds, + receiveErrors: status.receiveErrors + ) + let snapshots1 = await viewModel.helper.fetchHistory() #expect(snapshots1.count == 1, "First visit should save a snapshot") // Simulate refresh within 15 min — snapshot will be throttled - await viewModel.handleStatusResponse(createStatusResponse()) - let snapshots2 = await viewModel.fetchHistory() + await viewModel.helper.handleStatusResponse( + status, + rxAirtimeSeconds: status.repeaterRxAirtimeSeconds, + receiveErrors: status.receiveErrors + ) + let snapshots2 = await viewModel.helper.fetchHistory() #expect(snapshots2.count == 1, "Throttled save should not create a new snapshot") // User expands neighbors section — enrichment data arrives viewModel.handleNeighboursResponse(createNeighboursResponse()) - // Give fire-and-forget enrichment Task time to complete - try await Task.sleep(for: .milliseconds(50)) - - // Verify: the existing snapshot should be enriched - let snapshots3 = await viewModel.fetchHistory() - #expect(snapshots3.count == 1) - #expect( - snapshots3[0].neighborSnapshots?.isEmpty == false, - "Neighbor enrichment should persist even after throttled refresh" - ) + // Poll until enrichment completes (fire-and-forget Task) or timeout + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + var enriched = false + while ContinuousClock.now < deadline { + let snapshots = await viewModel.helper.fetchHistory() + if snapshots.first?.neighborSnapshots?.isEmpty == false { + enriched = true + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(enriched, "Neighbor enrichment should persist even after throttled refresh") } } From b272b8be5a2ce24bafa9ddb95c09650839fc872b Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:22:41 -0700 Subject: [PATCH 2/4] fix(room-settings): use raw matching for guest.password and allow.read.only - CLIResponse.parse() has no cases for these queries, so responses parsed as .raw and the content-based matcher silently discarded them, causing both fields to time out instead of loading - switch to rawMatching: true (FIFO delivery) for these two commands - align cleanup() with RepeaterSettingsViewModel: clear only the CLI handler instead of all three handlers via clearHandlers() --- MC1/Views/RemoteNodes/RoomSettingsViewModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift b/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift index 684c0a579..d36752f6a 100644 --- a/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/RoomSettingsViewModel.swift @@ -52,7 +52,7 @@ final class RoomSettingsViewModel { // MARK: - Cleanup func cleanup() async { - await roomAdminService?.clearHandlers() + await roomAdminService?.setCLIHandler { _, _ in } helper.cleanup() } @@ -127,7 +127,7 @@ final class RoomSettingsViewModel { var hadTimeout = false do { - let response = try await helper.sendAndWait("get guest.password") + let response = try await helper.sendAndWait("get guest.password", rawMatching: true) let parsed = CLIResponse.parse(response, forQuery: "get guest.password") switch parsed { case .ok, .error, .unknownCommand: @@ -145,7 +145,7 @@ final class RoomSettingsViewModel { } do { - let response = try await helper.sendAndWait("get allow.read.only") + let response = try await helper.sendAndWait("get allow.read.only", rawMatching: true) let parsed = CLIResponse.parse(response, forQuery: "get allow.read.only") switch parsed { case .raw(let value): From 366b87d30b2cc00f7caa1ed20b05c9f5161cbdd2 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:53:28 -0700 Subject: [PATCH 3/4] ui(remote-nodes): reorder settings sections in Room and Repeater views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radio → Behavior → Regions → Identity → Contact → Security → Device Info → Actions --- MC1/Views/RemoteNodes/RepeaterSettingsView.swift | 6 +++--- MC1/Views/RemoteNodes/RoomSettingsView.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/MC1/Views/RemoteNodes/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/RepeaterSettingsView.swift index 7d12800a0..65462d725 100644 --- a/MC1/Views/RemoteNodes/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/RepeaterSettingsView.swift @@ -15,13 +15,13 @@ struct RepeaterSettingsView: View { var body: some View { Form { NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) - makeDeviceInfoSection() makeRadioSettingsSection() - makeIdentitySection() - makeContactInfoSection() makeBehaviorSection() makeRegionsSection() + makeIdentitySection() + makeContactInfoSection() makeSecuritySection() + makeDeviceInfoSection() makeActionsSection() } .navigationTitle(L10n.RemoteNodes.RemoteNodes.Settings.title) diff --git a/MC1/Views/RemoteNodes/RoomSettingsView.swift b/MC1/Views/RemoteNodes/RoomSettingsView.swift index 47ec1246b..1e357eb89 100644 --- a/MC1/Views/RemoteNodes/RoomSettingsView.swift +++ b/MC1/Views/RemoteNodes/RoomSettingsView.swift @@ -15,20 +15,20 @@ struct RoomSettingsView: View { var body: some View { Form { NodeSettingsHeaderSection(publicKey: session.publicKey, name: session.name, role: session.role) - NodeDeviceInfoSection(settings: viewModel.helper) NodeRadioSettingsSection( settings: viewModel.helper, focusedField: $focusedField, radioRestartWarning: L10n.RemoteNodes.RemoteNodes.RoomSettings.radioRestartWarning ) + RoomBehaviorSection(viewModel: viewModel, focusedField: $focusedField) RemoteNodeIdentitySection( settings: viewModel.helper, focusedField: $focusedField, onPickLocation: { showingLocationPicker = true } ) NodeContactInfoSection(settings: viewModel.helper, focusedField: $focusedField) - RoomBehaviorSection(viewModel: viewModel, focusedField: $focusedField) NodeSecuritySection(settings: viewModel.helper) + NodeDeviceInfoSection(settings: viewModel.helper) NodeActionsSection( settings: viewModel.helper, showRebootConfirmation: $showRebootConfirmation, From 9e8131d36b3f4460b03921a3b0ef713a0d9b30ca Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:05:57 -0700 Subject: [PATCH 4/4] fix(node-settings): use raw matching for password command - Firmware echoes "password now: {pw}" on success, which parses as .raw and gets discarded by the content-based matcher - Same class of bug as guest.password and allow.read.only --- MC1/Views/RemoteNodes/NodeSettingsHelper.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MC1/Views/RemoteNodes/NodeSettingsHelper.swift b/MC1/Views/RemoteNodes/NodeSettingsHelper.swift index e7e132159..4cf46ee3c 100644 --- a/MC1/Views/RemoteNodes/NodeSettingsHelper.swift +++ b/MC1/Views/RemoteNodes/NodeSettingsHelper.swift @@ -475,7 +475,7 @@ final class NodeSettingsHelper { errorMessage = nil do { - let response = try await sendAndWait("password \(newPassword)") + let response = try await sendAndWait("password \(newPassword)", rawMatching: true) let parsed = CLIResponse.parse(response) // Firmware echoes "password now: {pw}" on success, not "OK" let isSuccess: Bool = switch parsed {