Skip to content

Commit 9bb4ccc

Browse files
jamesarichclaude
andauthored
refactor(qr): apply channel imports atomically via edit-settings transaction (#6170)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6a53b08 commit 9bb4ccc

8 files changed

Lines changed: 130 additions & 247 deletions

File tree

core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ interface AdminController {
148148
* coroutine, which is required for the firmware to associate them with one transaction.
149149
*/
150150
suspend fun editSettings(destNum: Int, block: suspend AdminEditScope.() -> Unit)
151+
152+
/** Runs [block] as an [editSettings] transaction against the local node. */
153+
suspend fun editLocalSettings(block: suspend AdminEditScope.() -> Unit)
151154
}
152155

153156
/**

core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,8 @@ internal class AdminControllerImpl(
224224
commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) }
225225
}
226226

227+
override suspend fun editLocalSettings(block: suspend AdminEditScope.() -> Unit) = editSettings(myNodeNum, block)
228+
227229
/** Binds the [AdminEditScope] operations to a fixed destination, delegating to this controller's set* methods. */
228230
private inner class EditSettingsSession(private val destNum: Int) : AdminEditScope {
229231
override suspend fun setOwner(user: User) = setOwner(destNum, user, commandSender.generatePacketId())
@@ -233,8 +235,12 @@ internal class AdminControllerImpl(
233235
override suspend fun setModuleConfig(config: ModuleConfig) =
234236
setModuleConfig(destNum, config, commandSender.generatePacketId())
235237

238+
// Unlike the one-shot setRemoteChannel, a transactional channel write does NOT mirror to the local cache per
239+
// slot: importChannelSet owns the cache and writes it once after commit (replaceAllSettings), so an import
240+
// interrupted before commit leaves the local channel cache untouched. (Firmware still writes each set_channel
241+
// into its in-memory channel table on arrival; only disk persist/reload/reboot is deferred to commit.)
236242
override suspend fun setChannel(channel: Channel) =
237-
setRemoteChannel(destNum, channel, commandSender.generatePacketId())
243+
commandSender.sendAdmin(destNum) { AdminMessage(set_channel = channel) }
238244

239245
override suspend fun setFixedPosition(position: Position) =
240246
this@AdminControllerImpl.setFixedPosition(destNum, position)

core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ import org.meshtastic.core.repository.RadioInterfaceService
5151
import org.meshtastic.core.repository.ServiceRepository
5252
import org.meshtastic.core.repository.UiPrefs
5353
import org.meshtastic.proto.AdminMessage
54+
import org.meshtastic.proto.Channel
55+
import org.meshtastic.proto.ChannelSettings
5456
import org.meshtastic.proto.ClientNotification
5557
import org.meshtastic.proto.Config
5658
import org.meshtastic.proto.HamParameters
@@ -367,6 +369,26 @@ class RadioControllerImplTest {
367369
verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) }
368370
}
369371

372+
@Test
373+
fun editLocalSettingsChannelWritesDoNotMirrorToLocalCache() = runTest {
374+
val controller = createController(myNodeNum = 1234)
375+
376+
controller.editLocalSettings {
377+
setChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = ChannelSettings(name = "A")))
378+
setChannel(Channel(index = 1, role = Channel.Role.SECONDARY, settings = ChannelSettings(name = "B")))
379+
}
380+
testScope.advanceUntilIdle()
381+
382+
// Exactly 4 admin packets: begin + 2 channel writes + commit. The tight count also catches a duplicated
383+
// begin/commit or an accidental double-write per channel.
384+
verifySuspend(exactly(4)) { commandSender.sendAdmin(any(), any(), any(), any()) }
385+
// A transactional channel write must NOT eagerly mirror to the local cache the way one-shot
386+
// setRemoteChannel does for the local node. importChannelSet owns the cache and writes it once after commit
387+
// (replaceAllSettings), so an interrupted import can't leave partial channels cached. A regression to
388+
// per-slot mirroring inside the session would make this call count non-zero.
389+
verifySuspend(exactly(0)) { radioConfigRepository.updateChannelSettings(any()) }
390+
}
391+
370392
@Test
371393
fun importContactSendsAdminAndUpdatesNodeManager() = runTest {
372394
val controller = createController()

core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ class FakeRadioController :
5757
val localChannels = mutableListOf<Channel>()
5858

5959
var throwOnSend: Boolean = false
60+
61+
/**
62+
* When set, a channel write throws once [localChannels] has reached this many entries — simulates a mid-write
63+
* failure.
64+
*/
65+
var failChannelWriteAfter: Int? = null
6066
var lastSetDeviceAddress: String? = null
6167
var lastSetOwnerUser: User? = null
6268
var editSettingsCalled = false
@@ -73,6 +79,7 @@ class FakeRadioController :
7379
localConfigs.clear()
7480
localChannels.clear()
7581
throwOnSend = false
82+
failChannelWriteAfter = null
7683
lastSetDeviceAddress = null
7784
lastSetOwnerUser = null
7885
editSettingsCalled = false
@@ -124,11 +131,16 @@ class FakeRadioController :
124131

125132
override suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) {}
126133

127-
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {}
134+
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
135+
localConfigs.add(config)
136+
}
128137

129138
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {}
130139

131-
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {}
140+
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
141+
failChannelWriteAfter?.let { if (localChannels.size >= it) error("Fake channel write failure") }
142+
localChannels.add(channel)
143+
}
132144

133145
override suspend fun setFixedPosition(destNum: Int, position: Position) {}
134146

@@ -196,6 +208,8 @@ class FakeRadioController :
196208
scope.block()
197209
}
198210

211+
override suspend fun editLocalSettings(block: suspend AdminEditScope.() -> Unit) = editSettings(0, block)
212+
199213
override fun generatePacketId(): Int = 1
200214

201215
override fun startProvideLocation() {

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ import org.koin.core.annotation.KoinViewModel
2222
import org.meshtastic.core.repository.NodeRepository
2323
import org.meshtastic.core.repository.RadioConfigRepository
2424
import org.meshtastic.core.repository.RadioController
25-
import org.meshtastic.core.ui.util.applyImportedLoraConfigAfterChannelReplacement
26-
import org.meshtastic.core.ui.util.applyReplacementChannelSet
25+
import org.meshtastic.core.ui.util.importChannelSet
2726
import org.meshtastic.core.ui.viewmodel.safeLaunch
2827
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
2928
import org.meshtastic.proto.ChannelSet
@@ -47,12 +46,6 @@ class ScannedQrCodeViewModel(
4746
)
4847

4948
/** Set the radio config (also updates our saved copy in preferences). */
50-
fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") {
51-
val currentLoraConfig = applyReplacementChannelSet(channelSet, radioController, radioConfigRepository)
52-
applyImportedLoraConfigAfterChannelReplacement(
53-
importedLoraConfig = channelSet.lora_config,
54-
currentLoraConfig = currentLoraConfig,
55-
radioController = radioController,
56-
)
57-
}
49+
fun setChannels(channelSet: ChannelSet) =
50+
safeLaunch(tag = "setChannels") { importChannelSet(channelSet, radioController, radioConfigRepository) }
5851
}

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt

Lines changed: 29 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ package org.meshtastic.core.ui.util
1919
import androidx.compose.runtime.Composable
2020
import co.touchlab.kermit.Logger
2121
import kotlinx.coroutines.NonCancellable
22-
import kotlinx.coroutines.delay
2322
import kotlinx.coroutines.flow.first
2423
import kotlinx.coroutines.withContext
2524
import okio.ByteString
@@ -36,20 +35,14 @@ import org.meshtastic.proto.ChannelSettings
3635
import org.meshtastic.proto.Config
3736
import org.meshtastic.proto.MeshPacket
3837
import org.meshtastic.proto.Position
39-
import kotlin.time.Duration
4038
import kotlin.time.Duration.Companion.days
41-
import kotlin.time.Duration.Companion.seconds
4239
import org.meshtastic.core.model.Channel as ModelChannel
4340

4441
private const val SECONDS_TO_MILLIS = 1000L
4542

4643
// Firmware channel files expose eight slots: one primary plus up to seven secondary channels.
4744
private const val CHANNEL_REPLACEMENT_SLOT_COUNT = 8
4845

49-
// Full channel replacement writes need conservative settle windows so hardware can persist each slot.
50-
private val CHANNEL_REPLACEMENT_WRITE_DELAY = 1.seconds
51-
private val LORA_CONFIG_SETTLE_DELAY = 2.seconds
52-
5346
@Composable
5447
fun Position.formatPositionTime(): String {
5548
val currentTime = nowMillis
@@ -198,37 +191,38 @@ fun normalizeReplacementSettings(
198191
private fun ChannelSettings.isPlaceholder(): Boolean = name.isNullOrBlank() && psk.size == 0
199192

200193
/**
201-
* Applies an imported [ChannelSet] as an authoritative replacement to the radio and local cache.
194+
* Imports a [ChannelSet] as an authoritative REPLACE: writes every channel and — when present and actually different —
195+
* the imported LoRa config, all inside one [RadioController.editLocalSettings] transaction, then replaces the local
196+
* channel cache.
202197
*
203198
* Reads the current LoRa config and channel set from [radioConfigRepository]'s flows (avoiding the StateFlow
204-
* placeholder window), builds the authoritative replacement list via [getChannelReplacementList], enqueues each channel
205-
* write to the radio via [radioController], pauses between writes so the radio can persist and reconfigure each slot,
206-
* then atomically replaces the local cached settings.
199+
* placeholder window) and builds the authoritative replacement list via [getChannelReplacementList]. The edit-settings
200+
* transaction defers disk persistence, radio reload/reconfiguration, and reboot until the closing commit, so channels +
201+
* LoRa land in a single reboot with no per-slot reconfigure to pace against. (Firmware still writes each `set_channel`
202+
* into its in-memory channel table as it arrives — the transaction is not a full staging of channel state — but the
203+
* expensive persist/reload path runs once at commit.) Writing LoRa inside the same session mirrors
204+
* `InstallProfileUseCase` and is why the old pre/post settle delays are gone: the begin/commit boundary is the settle.
207205
*
208-
* setLocalChannel returns once the packet is enqueued, not after firmware ACK. The pacing avoids enqueueing a complete
209-
* channel replacement plus LoRa reconfiguration faster than real hardware can materialize the later channel slots. If
210-
* the sequence is interrupted after one or more successful writes, the local cache is reconciled to the successfully
211-
* enqueued channel settings before the original cancellation or failure continues.
206+
* The local channel cache is commit-shaped: transactional channel writes deliberately do not mirror per slot (see
207+
* `AdminControllerImpl.EditSettingsSession.setChannel`), and this function replaces the cached channel list once, after
208+
* the session succeeds — so an import interrupted before that point leaves the local channel cache untouched. (The
209+
* imported LoRa config is the one exception: it still writes through the cache-mirroring `setConfig`, so its local
210+
* cache update is not itself deferred to commit — a single trailing write that self-heals on the device's next config
211+
* re-send. Making `setConfig` transaction-aware is future work.)
212212
*
213213
* Imported settings are normalized via [normalizeReplacementSettings] before any write or bounds check, so blank
214214
* placeholder secondaries and semantic duplicates never reach the radio or the local cache.
215215
*
216-
* Does NOT handle LoRa config — callers are responsible for comparing and sending `lora_config` if present.
217-
*
218-
* @param channelSet The imported [ChannelSet] to apply as a replacement.
219-
* @param radioController The [RadioController] used to enqueue channel writes.
220-
* @param radioConfigRepository The [RadioConfigRepository] providing the current channel flow and cache.
221-
* @param writeDelay Delay after each channel write. Exposed for fast unit tests.
222-
* @param delayFn Delay implementation. Exposed for fast unit tests.
223-
* @return The device's current LoRa config snapshot used by callers to compare against an imported LoRa config.
216+
* @param channelSet The imported [ChannelSet] to apply as a replacement. Its `lora_config`, if present and different
217+
* from the device's current LoRa config, is written inside the same transaction.
218+
* @param radioController The [RadioController] used to run the edit transaction.
219+
* @param radioConfigRepository The [RadioConfigRepository] providing the current channel/LoRa flows and cache.
224220
*/
225-
suspend fun applyReplacementChannelSet(
221+
suspend fun importChannelSet(
226222
channelSet: ChannelSet,
227223
radioController: RadioController,
228224
radioConfigRepository: RadioConfigRepository,
229-
writeDelay: Duration = CHANNEL_REPLACEMENT_WRITE_DELAY,
230-
delayFn: suspend (Duration) -> Unit = { delay(it) },
231-
): Config.LoRaConfig? {
225+
) {
232226
// Resolve the LoRa preset used for semantic identity: prefer the imported config, fall back to the device's current
233227
// local config so duplicate detection stays correct when the import omits lora_config (e.g. a non-default preset).
234228
val currentLoraConfig = radioConfigRepository.localConfigFlow.first().lora
@@ -245,82 +239,25 @@ suspend fun applyReplacementChannelSet(
245239
minimumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT,
246240
maximumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT,
247241
)
242+
// Only write LoRa when the import carries one that actually differs from the device — avoids a redundant
243+
// reconfigure.
244+
val importedLoraConfig = channelSet.lora_config?.takeIf { it != currentLoraConfig }
248245
Logger.i {
249246
"Applying imported channel replacement writes=${replacements.size} " +
250-
"importedSettings=${channelSet.settings.size} normalizedSettings=${normalizedSettings.size}"
247+
"importedSettings=${channelSet.settings.size} normalizedSettings=${normalizedSettings.size} " +
248+
"writesLora=${importedLoraConfig != null}"
251249
}
252-
val appliedSettings = currentSettings.take(CHANNEL_REPLACEMENT_SLOT_COUNT).toMutableList()
253-
var appliedWriteCount = 0
254-
var replacementComplete = false
255-
try {
250+
radioController.editLocalSettings {
256251
for (channel in replacements) {
257252
Logger.i {
258253
"Writing imported channel index=${channel.index} role=${channel.role} " +
259254
"hasName=${channel.settings?.name?.isNotBlank() == true}"
260255
}
261-
radioController.setLocalChannel(channel)
262-
while (appliedSettings.size <= channel.index) {
263-
appliedSettings.add(ChannelSettings())
264-
}
265-
appliedSettings[channel.index] =
266-
if (channel.role == Channel.Role.DISABLED) {
267-
ChannelSettings()
268-
} else {
269-
channel.settings ?: ChannelSettings()
270-
}
271-
appliedWriteCount++
272-
delayFn(writeDelay)
273-
}
274-
replacementComplete = true
275-
} finally {
276-
if (!replacementComplete) {
277-
radioConfigRepository.reconcileInterruptedReplacement(
278-
appliedWriteCount = appliedWriteCount,
279-
totalWriteCount = replacements.size,
280-
appliedSettings = appliedSettings,
281-
normalizedSettings = normalizedSettings,
282-
)
256+
setChannel(channel)
283257
}
258+
importedLoraConfig?.let { setConfig(Config(lora = it)) }
284259
}
285260
withContext(NonCancellable) { radioConfigRepository.replaceAllSettings(normalizedSettings) }
286-
return currentLoraConfig
287-
}
288-
289-
private suspend fun RadioConfigRepository.reconcileInterruptedReplacement(
290-
appliedWriteCount: Int,
291-
totalWriteCount: Int,
292-
appliedSettings: List<ChannelSettings>,
293-
normalizedSettings: List<ChannelSettings>,
294-
) {
295-
if (appliedWriteCount == 0) return
296-
val replacementSettings = if (appliedWriteCount == totalWriteCount) normalizedSettings else appliedSettings
297-
Logger.w {
298-
"Reconciling interrupted channel replacement appliedWrites=$appliedWriteCount totalWrites=$totalWriteCount"
299-
}
300-
withContext(NonCancellable) { replaceAllSettings(replacementSettings) }
301-
}
302-
303-
/**
304-
* Applies an imported LoRa config after channel replacement writes have had time to settle.
305-
*
306-
* LoRa reconfiguration is expensive on firmware and can race with channel persistence if sent immediately after a full
307-
* channel replacement. The pre/post settle delays give the radio time to materialize the imported channels before and
308-
* after the LoRa write.
309-
*/
310-
suspend fun applyImportedLoraConfigAfterChannelReplacement(
311-
importedLoraConfig: Config.LoRaConfig?,
312-
currentLoraConfig: Config.LoRaConfig?,
313-
radioController: RadioController,
314-
settleDelay: Duration = LORA_CONFIG_SETTLE_DELAY,
315-
delayFn: suspend (Duration) -> Unit = { delay(it) },
316-
) {
317-
if (importedLoraConfig == null || currentLoraConfig == importedLoraConfig) return
318-
319-
Logger.i { "Settling before imported LoRa config write" }
320-
delayFn(settleDelay)
321-
radioController.setLocalConfig(Config(lora = importedLoraConfig))
322-
Logger.i { "Settling after imported LoRa config write" }
323-
delayFn(settleDelay)
324261
}
325262

326263
/**

0 commit comments

Comments
 (0)