feat(settings): add remote "Set time" admin action (#5821)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-16 21:07:14 -05:00
committed by GitHub
parent c347903c6e
commit cef12c39dd
9 changed files with 62 additions and 0 deletions

View File

@@ -1273,6 +1273,7 @@ serial_tx_pin
server
session_active
session_refresh_required
set_time
set_up_connection
set_your_region
settings

View File

@@ -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.
*

View File

@@ -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. */

View File

@@ -1315,6 +1315,7 @@
<string name="server">Server</string>
<string name="session_active">Session active</string>
<string name="session_refresh_required">Refresh required</string>
<string name="set_time">Set time</string>
<string name="set_up_connection">Set up connection</string>
<string name="set_your_region">Set your region</string>
<string name="settings">settings</string>

View File

@@ -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)

View File

@@ -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()

View File

@@ -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) {}

View File

@@ -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),

View File

@@ -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)