@@ -19,7 +19,6 @@ package org.meshtastic.core.ui.util
1919import androidx.compose.runtime.Composable
2020import co.touchlab.kermit.Logger
2121import kotlinx.coroutines.NonCancellable
22- import kotlinx.coroutines.delay
2322import kotlinx.coroutines.flow.first
2423import kotlinx.coroutines.withContext
2524import okio.ByteString
@@ -36,20 +35,14 @@ import org.meshtastic.proto.ChannelSettings
3635import org.meshtastic.proto.Config
3736import org.meshtastic.proto.MeshPacket
3837import org.meshtastic.proto.Position
39- import kotlin.time.Duration
4038import kotlin.time.Duration.Companion.days
41- import kotlin.time.Duration.Companion.seconds
4239import org.meshtastic.core.model.Channel as ModelChannel
4340
4441private const val SECONDS_TO_MILLIS = 1000L
4542
4643// Firmware channel files expose eight slots: one primary plus up to seven secondary channels.
4744private 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
5447fun Position.formatPositionTime (): String {
5548 val currentTime = nowMillis
@@ -198,37 +191,38 @@ fun normalizeReplacementSettings(
198191private 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