From cef12c39ddda652eec872a2ccffa52a2665bd2ec Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:07:14 -0500 Subject: [PATCH] feat(settings): add remote "Set time" admin action (#5821) Co-authored-by: Claude Opus 4.8 --- .skills/compose-ui/strings-index.txt | 1 + .../usecase/settings/AdminActionsUseCase.kt | 14 ++++++++++++++ .../core/repository/AdminController.kt | 9 +++++++++ .../composeResources/values/strings.xml | 1 + .../core/service/AdminControllerImpl.kt | 7 +++++++ .../core/service/RadioControllerImplTest.kt | 19 +++++++++++++++++++ .../core/testing/FakeRadioController.kt | 2 ++ .../feature/settings/radio/RadioConfig.kt | 3 +++ .../settings/radio/RadioConfigViewModel.kt | 6 ++++++ 9 files changed, 62 insertions(+) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 53766b9b4..0e7b87e97 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1273,6 +1273,7 @@ serial_tx_pin server session_active session_refresh_required +set_time set_up_connection set_your_region settings diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt index 3957a264e..3858dee22 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt @@ -75,6 +75,20 @@ constructor( return packetId } + /** + * Syncs the node's real-time clock to the phone's current time. + * + * Lets a user correct a remote node whose RTC has drifted without being on-site or using the CLI. + * + * @param destNum The node number to update. + * @return The packet ID of the request. + */ + open suspend fun setTime(destNum: Int): Int { + val packetId = radioController.generatePacketId() + radioController.setTime(destNum, packetId) + return packetId + } + /** * Resets the NodeDB on the radio. * diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt index 72d332634..08f37c7e0 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt @@ -83,6 +83,15 @@ interface AdminController { /** Updates the canned messages configuration on a remote node. */ suspend fun setCannedMessages(destNum: Int, messages: String) + /** + * Syncs a node's real-time clock to the phone's current time via `AdminMessage.set_time_only`. + * + * Mirrors the Python CLI's `Node.setTime`: an accurate epoch-seconds timestamp is sent so a remote node whose RTC + * has drifted can be corrected without an on-site visit. Fire-and-forget — the firmware applies the value without + * an admin response (the routing ACK confirms delivery). + */ + suspend fun setTime(destNum: Int, packetId: Int) + // ── Remote queries ────────────────────────────────────────────────────── /** Requests the current owner (user info) from a remote node. */ diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 476a512e9..712effa8c 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1315,6 +1315,7 @@ Server Session active Refresh required + Set time Set up connection Set your region settings diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt index 9f0473733..3422ae3c8 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.firstOrNull import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Position import org.meshtastic.core.repository.AdminController import org.meshtastic.core.repository.AdminEditScope @@ -160,6 +161,12 @@ internal class AdminControllerImpl( commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) } } + override suspend fun setTime(destNum: Int, packetId: Int) { + Logger.i { "Set time requested for node $destNum" } + // Resolve the timestamp at send time so the value is as fresh as possible when it leaves the phone. + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_time_only = nowSeconds.toInt()) } + } + override suspend fun getCannedMessages(destNum: Int, packetId: Int) { commandSender.sendAdmin(destNum, packetId, wantResponse = true) { AdminMessage(get_canned_message_module_messages_request = true) diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt index bd7a868e1..62e3f17f7 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -339,6 +339,25 @@ class RadioControllerImplTest { verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } } + @Test + fun setTimeSendsAdminMessageWithCurrentEpochSeconds() = runTest { + val controller = createController() + + var sentMessage: AdminMessage? = null + everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } calls + { + @Suppress("UNCHECKED_CAST") + sentMessage = (it.args[3] as () -> AdminMessage)() + } + + controller.setTime(destNum = 101, packetId = 11) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + // The phone's current time is sent; assert it is populated and a plausible recent epoch (after 2020). + val setTime = sentMessage?.set_time_only + assertTrue(setTime != null && setTime > 1_577_836_800) + } + @Test fun refreshMetadataSendsAdminWithWantResponse() = runTest { val controller = createController() diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index c4a8de144..ebc96efeb 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -123,6 +123,8 @@ class FakeRadioController : override suspend fun setCannedMessages(destNum: Int, messages: String) {} + override suspend fun setTime(destNum: Int, packetId: Int) {} + override suspend fun getOwner(destNum: Int, packetId: Int) {} override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt index 868c5ff84..6ac182872 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.resources.firmware_update_title import org.meshtastic.core.resources.ic_power_settings_new import org.meshtastic.core.resources.ic_restart_alt import org.meshtastic.core.resources.ic_restore +import org.meshtastic.core.resources.ic_schedule import org.meshtastic.core.resources.ic_storage import org.meshtastic.core.resources.import_configuration import org.meshtastic.core.resources.message_device_managed @@ -51,6 +52,7 @@ import org.meshtastic.core.resources.module_settings import org.meshtastic.core.resources.nodedb_reset import org.meshtastic.core.resources.radio_configuration import org.meshtastic.core.resources.reboot +import org.meshtastic.core.resources.set_time import org.meshtastic.core.resources.shutdown import org.meshtastic.core.resources.tak_server import org.meshtastic.core.ui.component.ListItem @@ -227,6 +229,7 @@ private fun AdvancedSection(isManaged: Boolean, isOtaCapable: Boolean, enabled: } enum class AdminRoute(val icon: DrawableResource, val title: StringResource) { + SET_TIME(Res.drawable.ic_schedule, Res.string.set_time), REBOOT(Res.drawable.ic_restart_alt, Res.string.reboot), SHUTDOWN(Res.drawable.ic_power_settings_new, Res.string.shutdown), FACTORY_RESET(Res.drawable.ic_restore, Res.string.factory_reset), diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 7a082dff0..b3a0823c1 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -445,6 +445,12 @@ open class RadioConfigViewModel( val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites when (route) { + AdminRoute.SET_TIME.name -> + safeLaunch(tag = "setTime") { + val packetId = adminActionsUseCase.setTime(destNum) + registerRequestId(packetId) + } + AdminRoute.REBOOT.name -> safeLaunch(tag = "reboot") { val packetId = adminActionsUseCase.reboot(destNum)