Verify Thread dataset state after export and auto-promote when server has none#6818
Verify Thread dataset state after export and auto-promote when server has none#6818agners wants to merge 2 commits intohome-assistant:mainfrom
Conversation
The thread/add_dataset_tlv command never promotes a dataset to preferred on the server, so a separate call is needed to make a just-added dataset the preferred one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously sendThreadDatasetExportResult treated a successful thread/add_dataset_tlv call as "credentials in sync", but the server never auto-promotes datasets added via that command. When the server already had a different preferred dataset (typically announced by a border router it manages), the next sync would re-detect the mismatch and prompt the user to export again, looping indefinitely. After a successful add, re-query the server's datasets and: - if no dataset is preferred, promote the just-exported one; - if a different dataset is preferred, leave it alone and report it; - if it is already preferred, report the in-sync state. The result type is now a sealed ExportResult so callers can distinguish "sent and now preferred" from "sent but server still prefers another network", and the developer settings screen shows a dedicated message for the latter case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the app’s Thread credential export flow to match Home Assistant Core’s updated WebSocket contract where thread/add_dataset_tlv no longer auto-promotes the added dataset to “preferred”. The app now re-verifies the server state after export and can explicitly promote the exported dataset when the server has no preferred dataset.
Changes:
- Introduces a sealed
ThreadManager.ExportResultso callers can distinguish “not sent / failed / sent (and whether the server prefers it)”. - Adds a WebSocket wrapper for
thread/set_preferred_datasetand uses it to auto-promote when the server has no preferred dataset. - Updates developer settings and WebView/Matter flows to handle the new export result and show a more precise outcome.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| common/src/main/res/values/strings.xml | Adds a new developer-settings string for “exported but not preferred” outcome |
| common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt | Documents addThreadDataset behavior and adds setThreadPreferredDataset API |
| common/src/main/kotlin/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt | Implements thread/set_preferred_dataset WebSocket call |
| app/src/minimal/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt | Updates minimal flavor stub to new ExportResult return type |
| app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt | Adapts logging and state transitions to new ExportResult type |
| app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt | Adds ExportResult sealed class and updates KDoc/return type |
| app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt | Updates developer Thread sync UI messaging based on ExportResult |
| app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt | Re-queries server after export and optionally promotes exported dataset |
| val datasets = webSocket.getThreadDatasets() | ||
| val preferred = datasets?.firstOrNull { it.preferred } | ||
| when { | ||
| preferred == null -> { | ||
| val ours = datasets?.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) } | ||
| if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) { | ||
| Timber.d("Thread: promoted exported dataset to preferred on the server") | ||
| true | ||
| } else { | ||
| false |
| val out = "${context.getString( | ||
| commonR.string.thread_debug_result_mismatch, | ||
| )} ${context.getString( | ||
| commonR.string.thread_debug_result_mismatch_detail, | ||
| sent.networkName, | ||
| )}" | ||
| view.onThreadDebugResult(out, null) |
| override suspend fun setThreadPreferredDataset(datasetId: String): Boolean { | ||
| val response = webSocketCore.sendMessage( | ||
| mapOf( | ||
| "type" to "thread/set_preferred_dataset", | ||
| "dataset_id" to datasetId, |
| "Thread ${if (sent is ThreadManager.ExportResult.Sent) { | ||
| "sent credential for ${sent.networkName} (server prefers exported: ${sent.serverPrefersExported})" | ||
| } else { | ||
| "did not send credential" |
There was a problem hiding this comment.
| "did not send credential" | |
| "did not send credential reason $sent" |
| when { | ||
| preferred == null -> { | ||
| val ours = datasets?.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) } | ||
| if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) { |
There was a problem hiding this comment.
We probably need to check if core has this endpoint no? Or the min version we use for thread already contains this endpoint?
| if (result.resultCode != Activity.RESULT_OK || !coreSupportsThread(serverId)) { | ||
| return ThreadManager.ExportResult.NotSent | ||
| } | ||
| val credentials = ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!) |
There was a problem hiding this comment.
We can avoid crashing here by checking if data is null and returning NotSent.
| val webSocket = serverManager.webSocketRepository(serverId) | ||
| val added = try { | ||
| webSocket.addThreadDataset(credentials.activeOperationalDataset) | ||
| } catch (e: Exception) { |
There was a problem hiding this comment.
It's a coroutine, we have a bad design in the app by catching all Exception but we can't change it now. But because of that we have to reThrow CancellationException otherwise it let the scope into an invalid state.
| preferred.extendedPanId.equals(deviceExtPan, ignoreCase = true) -> true | ||
| else -> false | ||
| } | ||
| } catch (e: Exception) { |
| if (added) return threadNetworkCredentials.networkName | ||
| } catch (e: Exception) { | ||
| Timber.e(e, "Error while executing server new Thread credentials request") | ||
| @OptIn(ExperimentalStdlibApi::class) |
There was a problem hiding this comment.
| @OptIn(ExperimentalStdlibApi::class) |
Not needed anymore (you can probably also remove it in the rest of the file)
| // surface an honest result we re-query and compare extended PAN IDs. If the server has | ||
| // no preferred dataset at all (e.g. no border router announcing one), promote the | ||
| // just-exported dataset; we never override an existing server preference. | ||
| val deviceExtPan = credentials.extendedPanId.toHexString(HexFormat.UpperCase) |
There was a problem hiding this comment.
| val deviceExtPan = credentials.extendedPanId.toHexString(HexFormat.UpperCase) | |
| val deviceExtPan = credentials.extendedPanId.toHexString() |
Since you ignore the case bellow we don't care
| // no preferred dataset at all (e.g. no border router announcing one), promote the | ||
| // just-exported dataset; we never override an existing server preference. | ||
| val deviceExtPan = credentials.extendedPanId.toHexString(HexFormat.UpperCase) | ||
| val serverPrefersExported = try { |
There was a problem hiding this comment.
The whole logic could be extracted into a private function.
| preferred == null -> { | ||
| val ours = datasets?.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) } | ||
| if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) { | ||
| Timber.d("Thread: promoted exported dataset to preferred on the server") |
There was a problem hiding this comment.
| Timber.d("Thread: promoted exported dataset to preferred on the server") | |
| Timber.d("Promote exported dataset to preferred network on the server") |
| } catch (e: Exception) { | ||
| Timber.e(e, "Error while executing server new Thread credentials request") | ||
| @OptIn(ExperimentalStdlibApi::class) | ||
| override suspend fun sendThreadDatasetExportResult( |
There was a problem hiding this comment.
If you are up for the task we could start unit testing this function. Claude is pretty good at this exercise on Android.
| else -> false | ||
| } | ||
| } catch (e: Exception) { | ||
| Timber.w(e, "Unable to verify Thread preferred dataset after export") |
There was a problem hiding this comment.
Could also be setting the prefered dataset since you potentially makes 2 call.
| commonR.string.thread_debug_result_mismatch, | ||
| )} ${context.getString(commonR.string.thread_debug_result_mismatch_detail, submitted)}" | ||
| view.onThreadDebugResult(out, null) | ||
| when (val sent = threadManager.sendThreadDatasetExportResult(result, serverId)) { |
There was a problem hiding this comment.
I feel like it can be simplified maybe something like this
when (val sent = threadManager.sendThreadDatasetExportResult(result, serverId)) {
is ThreadManager.ExportResult.Sent -> when {
isDeviceOnly -> view.onThreadDebugResult(
context.getString(commonR.string.thread_debug_result_exported),
true,
)
sent.serverPrefersExported == true -> view.onThreadDebugResult(
context.getString(commonR.string.thread_debug_result_match),
true,
)
else -> {
val headlineRes = if (sent.serverPrefersExported == false) {
commonR.string.thread_debug_result_exported_not_preferred
} else {
commonR.string.thread_debug_result_mismatch
}
val detail = context.getString(
commonR.string.thread_debug_result_mismatch_detail,
sent.networkName,
)
view.onThreadDebugResult("${context.getString(headlineRes)} $detail", null)
}
}| val serverPrefersExported = try { | ||
| val datasets = webSocket.getThreadDatasets() | ||
| val preferred = datasets?.firstOrNull { it.preferred } | ||
| when { | ||
| preferred == null -> { | ||
| val ours = datasets?.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) } | ||
| if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) { | ||
| Timber.d("Thread: promoted exported dataset to preferred on the server") | ||
| true | ||
| } else { | ||
| false | ||
| } | ||
| } | ||
| preferred.extendedPanId.equals(deviceExtPan, ignoreCase = true) -> true | ||
| else -> false |
There was a problem hiding this comment.
| val serverPrefersExported = try { | |
| val datasets = webSocket.getThreadDatasets() | |
| val preferred = datasets?.firstOrNull { it.preferred } | |
| when { | |
| preferred == null -> { | |
| val ours = datasets?.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) } | |
| if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) { | |
| Timber.d("Thread: promoted exported dataset to preferred on the server") | |
| true | |
| } else { | |
| false | |
| } | |
| } | |
| preferred.extendedPanId.equals(deviceExtPan, ignoreCase = true) -> true | |
| else -> false | |
| val serverPrefersExported = try { | |
| val datasets = webSocket.getThreadDatasets().orEmpty() | |
| val preferred = datasets.firstOrNull { it.preferred } | |
| if (preferred != null) { | |
| preferred.extendedPanId.equals(deviceExtPan, ignoreCase = true) | |
| } else { | |
| val ours = datasets.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) } | |
| if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) { | |
| Timber.d("Thread: promoted exported dataset to preferred on the server") | |
| true | |
| } else { | |
| false | |
| } | |
| } |
The when using instead of a if is sometimes less readable IMO.
Summary
When the developer-settings "Sync Thread credentials" flow exported the device's preferred dataset to Home Assistant, the app treated a successful
thread/add_dataset_tlvreply as "credentials in sync". In practice the WebSocket command never makes the just-added dataset the server's preferred one — that auto-promote behaviour was removed in Core #108278, which moved the preferred-dataset selection behind the explicitthread/set_preferred_datasetcall. As a result, after Core #108278 the app's export flow would loop: every sync re-detected the same mismatch, prompted the user, exported again, and reported success while the server's preferred dataset had not changed at all.This PR fixes the export flow to behave correctly with the new Core contract:
sendThreadDatasetExportResultnow returns a sealedExportResultso callers can distinguish "sent and now preferred" from "sent but server still prefers another network".thread/set_preferred_datasetWebSocket call;setThreadPreferredDataset(datasetId)wrapsthread/set_preferred_dataset.thread_debug_result_exported_not_preferredso the user can tell the difference between a successful sync and an export-that-did-not-promote.The
WebViewPresenterImplandMatterCommissioningViewModelcallers are adjusted to the new return type without behaviour changes.Related: home-assistant/core#108278 —
thread/add_dataset_tlvno longer promotes the added dataset to preferred, making this client-side change necessary.Checklist
Screenshots
Link to pull request in documentation repositories
Any other notes
The
ThreadManager.sendThreadDatasetExportResultsignature changes fromString?toExportResult. All in-tree callers are updated; this is a developer-facing breaking change inside the app module only.