From 4edf1139e3378abeada5062c043560013a081ffb Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:19:19 -0400 Subject: [PATCH 1/6] Show pending channel migration status on Thread config page Add an alert banner inside the network card that displays when a Thread channel change is in progress, with a live countdown timer and cancel button. Uses hybrid approach: client-side countdown for responsiveness with periodic server sync every 30s for accuracy. Requires new backend WebSocket commands (otbr/get_pending_dataset and otbr/delete_pending_dataset) to be implemented in home-assistant core. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/data/otbr.ts | 23 +++ .../thread/thread-config-panel.ts | 186 ++++++++++++++++-- src/translations/en.json | 5 + 3 files changed, 199 insertions(+), 15 deletions(-) diff --git a/src/data/otbr.ts b/src/data/otbr.ts index 3d46bb6a384e..fb0dca3c4172 100644 --- a/src/data/otbr.ts +++ b/src/data/otbr.ts @@ -46,3 +46,26 @@ export const OTBRSetChannel = ( extended_address, channel, }); + +export interface OTBRPendingDataset { + pending_channel: number; + pending_dataset_delay: number; +} + +export const OTBRGetPendingDataset = ( + hass: HomeAssistant, + extended_address: string +): Promise => + hass.callWS({ + type: "otbr/get_pending_dataset", + extended_address, + }); + +export const OTBRDeletePendingDataset = ( + hass: HomeAssistant, + extended_address: string +): Promise => + hass.callWS({ + type: "otbr/delete_pending_dataset", + extended_address, + }); diff --git a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts index 394d73c2aade..6fffa756a235 100644 --- a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts @@ -12,6 +12,7 @@ import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../../../common/config/is_component_loaded"; import { stringCompare } from "../../../../../common/string/compare"; import { extractSearchParam } from "../../../../../common/url/search-params"; +import "../../../../../components/ha-alert"; import "../../../../../components/ha-button"; import "../../../../../components/ha-card"; import "../../../../../components/ha-dropdown"; @@ -19,9 +20,15 @@ import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdow import "../../../../../components/ha-dropdown-item"; import { getSignedPath } from "../../../../../data/auth"; import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagnostics"; -import type { OTBRInfo, OTBRInfoDict } from "../../../../../data/otbr"; +import type { + OTBRInfo, + OTBRInfoDict, + OTBRPendingDataset, +} from "../../../../../data/otbr"; import { OTBRCreateNetwork, + OTBRDeletePendingDataset, + OTBRGetPendingDataset, OTBRSetChannel, OTBRSetNetwork, getOTBRInfo, @@ -71,6 +78,14 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { @state() private _otbrInfo?: OTBRInfoDict; + @state() private _pendingDatasets: Record = {}; + + @state() private _completedMigrations: Record = {}; + + private _countdownInterval?: ReturnType; + + private _syncInterval?: ReturnType; + protected render(): TemplateResult { const networks = this._groupRoutersByNetwork(this._routers, this._datasets); @@ -210,6 +225,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { ` : ""} + ${this._renderPendingChannelAlert(otbrForNetwork)} ${network.routers?.length ? html`

@@ -413,6 +429,11 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { ev.target.style.display = ""; } + public override disconnectedCallback() { + super.disconnectedCallback(); + this._clearTimers(); + } + hassSubscribe() { return [ subscribeDiscoverThreadRouters(this.hass, (routers: ThreadRouter[]) => { @@ -488,6 +509,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { } catch (_err) { this._otbrInfo = undefined; } + await this._refreshPendingDatasets(); } private async _signUrl(ev) { @@ -657,6 +679,153 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { this._refresh(); } + private _renderPendingChannelAlert( + otbr: OTBRInfo | false | undefined + ): TemplateResult | typeof nothing { + if (!otbr) { + return nothing; + } + const extAddr = otbr.extended_address; + + if (this._completedMigrations[extAddr]) { + return html` + ${this.hass.localize( + "ui.panel.config.thread.pending_channel_change_complete" + )} + `; + } + + const pending = this._pendingDatasets[extAddr]; + if (!pending) { + return nothing; + } + + const totalSeconds = Math.max(0, Math.floor(pending.pending_dataset_delay)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const formattedTime = + minutes > 0 + ? this.hass.localize( + "ui.panel.config.thread.pending_channel_change_minutes", + { minutes, seconds } + ) + : this.hass.localize( + "ui.panel.config.thread.pending_channel_change_seconds", + { seconds } + ); + + return html` + ${this.hass.localize( + "ui.panel.config.thread.pending_channel_change", + { channel: pending.pending_channel, time: formattedTime } + )} + + ${this.hass.localize( + "ui.panel.config.thread.pending_channel_change_cancel" + )} + + `; + } + + private async _refreshPendingDatasets() { + if (!this._otbrInfo) { + return; + } + const newPending: Record = {}; + const promises = Object.keys(this._otbrInfo).map(async (extAddr) => { + try { + const result = await OTBRGetPendingDataset(this.hass, extAddr); + if (result) { + newPending[extAddr] = result; + } + } catch (_err) { + // Ignore errors fetching pending dataset + } + }); + await Promise.all(promises); + this._pendingDatasets = newPending; + this._updateTimers(); + } + + private _updateTimers() { + const hasPending = Object.keys(this._pendingDatasets).length > 0; + + if (hasPending && !this._countdownInterval) { + this._countdownInterval = setInterval(() => { + this._tickCountdown(); + }, 1000); + this._syncInterval = setInterval(() => { + this._refreshPendingDatasets(); + }, 30_000); + } else if (!hasPending && this._countdownInterval) { + this._clearTimers(); + } + } + + private _tickCountdown() { + const updated: Record = {}; + let changed = false; + + for (const [extAddr, pending] of Object.entries(this._pendingDatasets)) { + const newDelay = pending.pending_dataset_delay - 1; + if (newDelay <= 0) { + changed = true; + this._completedMigrations = { + ...this._completedMigrations, + [extAddr]: true, + }; + setTimeout(() => { + const { [extAddr]: _, ...rest } = this._completedMigrations; + this._completedMigrations = rest; + }, 5000); + this._refresh(); + } else { + updated[extAddr] = { ...pending, pending_dataset_delay: newDelay }; + if (newDelay !== pending.pending_dataset_delay) { + changed = true; + } + } + } + + if (changed) { + this._pendingDatasets = updated; + this._updateTimers(); + } else { + this._pendingDatasets = updated; + } + } + + private _clearTimers() { + if (this._countdownInterval) { + clearInterval(this._countdownInterval); + this._countdownInterval = undefined; + } + if (this._syncInterval) { + clearInterval(this._syncInterval); + this._syncInterval = undefined; + } + } + + private async _cancelChannelChange(ev: Event) { + const extAddr = (ev.currentTarget as any).extendedAddress as string; + try { + await OTBRDeletePendingDataset(this.hass, extAddr); + } catch (err: any) { + showAlertDialog(this, { + title: "Error", + text: err.message || err, + }); + return; + } + const { [extAddr]: _, ...rest } = this._pendingDatasets; + this._pendingDatasets = rest; + this._updateTimers(); + } + private async _changeChannel(otbr: OTBRInfo) { const currentChannel = otbr.channel; const channelStr = await showPromptDialog(this, { @@ -685,20 +854,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { return; } try { - const result = await OTBRSetChannel( - this.hass, - otbr.extended_address, - channel - ); - showAlertDialog(this, { - title: this.hass.localize( - "ui.panel.config.thread.change_channel_initiated_title" - ), - text: this.hass.localize( - "ui.panel.config.thread.change_channel_initiated_text", - { delay: Math.floor(result.delay / 60) } - ), - }); + await OTBRSetChannel(this.hass, otbr.extended_address, channel); } catch (err: any) { if (err.code === "multiprotocol_enabled") { showAlertDialog(this, { diff --git a/src/translations/en.json b/src/translations/en.json index 90fe89714e1e..1e9f313fa49d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7026,6 +7026,11 @@ "change_channel_label": "Channel", "change_channel_multiprotocol_enabled_title": "The Thread adapter has multiprotocol enabled", "change_channel_multiprotocol_enabled_text": "To change channel when the Thread adapter has multiprotocol enabled, please use the hardware settings menu.", + "pending_channel_change": "Channel change to {channel} in {time}", + "pending_channel_change_cancel": "Cancel", + "pending_channel_change_complete": "Channel change complete", + "pending_channel_change_minutes": "{minutes} min, {seconds} sec", + "pending_channel_change_seconds": "{seconds} sec", "change_channel_range": "Channel must be in the range 11 to 26", "change_channel_text": "Initiating a channel change for your Home Assistant Thread network should be performed with caution. Some Thread devices may not migrate to the new channel automatically and, if the new channel is congested, your Thread devices may become intermittently unavailable. Some devices may need to be manually re-joined to your Thread network before they show in Home Assistant again. This action cannot be reversed (without performing another channel change).", "thread_network_info": "Thread network information", From 499c5234422893c46c187cbd52115aaf99abf9d7 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:53:32 -0400 Subject: [PATCH 2/6] Refine pending channel change alert formatting Show current and target channels with bold formatting and arrow separator. Add "migration in" prefix before the countdown timer. Fix alert overlap with border router count by adding margin. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../thread/thread-config-panel.ts | 17 ++++++++++++----- src/translations/en.json | 5 +++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts index 6fffa756a235..04aced66d5bf 100644 --- a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts @@ -688,7 +688,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { const extAddr = otbr.extended_address; if (this._completedMigrations[extAddr]) { - return html` + return html` ${this.hass.localize( "ui.panel.config.thread.pending_channel_change_complete" )} @@ -714,11 +714,14 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { { seconds } ); - return html` + return html` ${this.hass.localize( - "ui.panel.config.thread.pending_channel_change", - { channel: pending.pending_channel, time: formattedTime } - )} + "ui.panel.config.thread.pending_channel_change_label" + )}: + ${otbr.channel}${pending.pending_channel} + — ${this.hass.localize( + "ui.panel.config.thread.pending_channel_change_migration_in" + )} ${formattedTime} Date: Fri, 17 Apr 2026 14:01:02 -0400 Subject: [PATCH 3/6] Address review feedback and fix formatting - Wrap countdown timer in aria-hidden span for screen reader compat - Guard setTimeout callback with isConnected check - Defer refresh to after tick loop to avoid multiple refreshes - Clear pending state and timers when otbrInfo unavailable - Use localized error title for cancel failure dialog - Change "migration" to "cutover" in countdown text - Fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../thread/thread-config-panel.ts | 24 +++++++++++++++---- src/translations/en.json | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts index 04aced66d5bf..ff1857b5f713 100644 --- a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts @@ -719,9 +719,13 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { "ui.panel.config.thread.pending_channel_change_label" )}: ${otbr.channel}${pending.pending_channel} - — ${this.hass.localize( - "ui.panel.config.thread.pending_channel_change_migration_in" - )} ${formattedTime} + = {}; @@ -772,20 +778,24 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { private _tickCountdown() { const updated: Record = {}; let changed = false; + let needsRefresh = false; for (const [extAddr, pending] of Object.entries(this._pendingDatasets)) { const newDelay = pending.pending_dataset_delay - 1; if (newDelay <= 0) { changed = true; + needsRefresh = true; this._completedMigrations = { ...this._completedMigrations, [extAddr]: true, }; setTimeout(() => { + if (!this.isConnected) { + return; + } const { [extAddr]: _, ...rest } = this._completedMigrations; this._completedMigrations = rest; }, 5000); - this._refresh(); } else { updated[extAddr] = { ...pending, pending_dataset_delay: newDelay }; if (newDelay !== pending.pending_dataset_delay) { @@ -800,6 +810,10 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { } else { this._pendingDatasets = updated; } + + if (needsRefresh) { + this._refresh(); + } } private _clearTimers() { @@ -819,7 +833,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { await OTBRDeletePendingDataset(this.hass, extAddr); } catch (err: any) { showAlertDialog(this, { - title: "Error", + title: this.hass.localize("ui.panel.config.thread.otbr_config_failed"), text: err.message || err, }); return; diff --git a/src/translations/en.json b/src/translations/en.json index dd63559c904b..545de91008ce 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7027,7 +7027,7 @@ "change_channel_multiprotocol_enabled_title": "The Thread adapter has multiprotocol enabled", "change_channel_multiprotocol_enabled_text": "To change channel when the Thread adapter has multiprotocol enabled, please use the hardware settings menu.", "pending_channel_change_label": "Pending channel change", - "pending_channel_change_migration_in": "migration in", + "pending_channel_change_cutover_in": "cutover in", "pending_channel_change_cancel": "Cancel", "pending_channel_change_complete": "Channel change complete", "pending_channel_change_minutes": "{minutes} min {seconds} sec", From 498bfec9d6810f89ee96b994f3a7cad2e630e602 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:19:12 -0400 Subject: [PATCH 4/6] Use hui-timestamp-display for countdown rendering Replace custom tick-based countdown with hui-timestamp-display component that manages its own per-second updates internally. Store end_time (Date) instead of decrementing delay. Use setTimeout for completion detection instead of per-second tick checking. This removes ~60 lines of timer management code (countdown interval, tick handler, timer update logic) in favor of the existing HA component. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../thread/thread-config-panel.ts | 175 +++++++++--------- src/translations/en.json | 3 - 2 files changed, 85 insertions(+), 93 deletions(-) diff --git a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts index ff1857b5f713..a9ee1fb7c99b 100644 --- a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts @@ -20,11 +20,7 @@ import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdow import "../../../../../components/ha-dropdown-item"; import { getSignedPath } from "../../../../../data/auth"; import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagnostics"; -import type { - OTBRInfo, - OTBRInfoDict, - OTBRPendingDataset, -} from "../../../../../data/otbr"; +import type { OTBRInfo, OTBRInfoDict } from "../../../../../data/otbr"; import { OTBRCreateNetwork, OTBRDeletePendingDataset, @@ -56,8 +52,14 @@ import type { HomeAssistant } from "../../../../../types"; import { brandsUrl } from "../../../../../util/brands-url"; import { documentationUrl } from "../../../../../util/documentation-url"; import { fileDownload } from "../../../../../util/file_download"; +import "../../../../../panels/lovelace/components/hui-timestamp-display"; import { showThreadDatasetDialog } from "./show-dialog-thread-dataset"; +interface PendingChannelChange { + pending_channel: number; + end_time: Date; +} + export interface ThreadNetwork { name: string; dataset?: ThreadDataSet; @@ -78,14 +80,15 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { @state() private _otbrInfo?: OTBRInfoDict; - @state() private _pendingDatasets: Record = {}; + @state() private _pendingChanges: Record = {}; @state() private _completedMigrations: Record = {}; - private _countdownInterval?: ReturnType; - private _syncInterval?: ReturnType; + private _completionTimeouts: Record> = + {}; + protected render(): TemplateResult { const networks = this._groupRoutersByNetwork(this._routers, this._datasets); @@ -431,7 +434,11 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { public override disconnectedCallback() { super.disconnectedCallback(); - this._clearTimers(); + this._clearSync(); + for (const timeout of Object.values(this._completionTimeouts)) { + clearTimeout(timeout); + } + this._completionTimeouts = {}; } hassSubscribe() { @@ -695,37 +702,23 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { `; } - const pending = this._pendingDatasets[extAddr]; + const pending = this._pendingChanges[extAddr]; if (!pending) { return nothing; } - const totalSeconds = Math.max(0, Math.floor(pending.pending_dataset_delay)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - const formattedTime = - minutes > 0 - ? this.hass.localize( - "ui.panel.config.thread.pending_channel_change_minutes", - { minutes, seconds } - ) - : this.hass.localize( - "ui.panel.config.thread.pending_channel_change_seconds", - { seconds } - ); - return html` ${this.hass.localize( "ui.panel.config.thread.pending_channel_change_label" )}: ${otbr.channel}${pending.pending_channel} - + — + = {}; + const newPending: Record = {}; + const now = Date.now(); const promises = Object.keys(this._otbrInfo).map(async (extAddr) => { try { const result = await OTBRGetPendingDataset(this.hass, extAddr); if (result) { - newPending[extAddr] = result; + newPending[extAddr] = { + pending_channel: result.pending_channel, + end_time: new Date(now + result.pending_dataset_delay * 1000), + }; } } catch (_err) { // Ignore errors fetching pending dataset } }); await Promise.all(promises); - this._pendingDatasets = newPending; - this._updateTimers(); + this._pendingChanges = newPending; + this._scheduleCompletionChecks(); + this._startSync(); } - private _updateTimers() { - const hasPending = Object.keys(this._pendingDatasets).length > 0; - - if (hasPending && !this._countdownInterval) { - this._countdownInterval = setInterval(() => { - this._tickCountdown(); - }, 1000); - this._syncInterval = setInterval(() => { - this._refreshPendingDatasets(); - }, 30_000); - } else if (!hasPending && this._countdownInterval) { - this._clearTimers(); + private _scheduleCompletionChecks() { + // Clear existing completion timeouts + for (const timeout of Object.values(this._completionTimeouts)) { + clearTimeout(timeout); } - } + this._completionTimeouts = {}; - private _tickCountdown() { - const updated: Record = {}; - let changed = false; - let needsRefresh = false; - - for (const [extAddr, pending] of Object.entries(this._pendingDatasets)) { - const newDelay = pending.pending_dataset_delay - 1; - if (newDelay <= 0) { - changed = true; - needsRefresh = true; - this._completedMigrations = { - ...this._completedMigrations, - [extAddr]: true, - }; - setTimeout(() => { - if (!this.isConnected) { - return; - } - const { [extAddr]: _, ...rest } = this._completedMigrations; - this._completedMigrations = rest; - }, 5000); + const now = Date.now(); + for (const [extAddr, pending] of Object.entries(this._pendingChanges)) { + const remaining = pending.end_time.getTime() - now; + if (remaining <= 0) { + this._handleCompletion(extAddr); } else { - updated[extAddr] = { ...pending, pending_dataset_delay: newDelay }; - if (newDelay !== pending.pending_dataset_delay) { - changed = true; - } + this._completionTimeouts[extAddr] = setTimeout(() => { + this._handleCompletion(extAddr); + }, remaining); } } + } - if (changed) { - this._pendingDatasets = updated; - this._updateTimers(); - } else { - this._pendingDatasets = updated; + private _handleCompletion(extAddr: string) { + if (!this.isConnected) { + return; } + const { [extAddr]: _, ...rest } = this._pendingChanges; + this._pendingChanges = rest; + this._completedMigrations = { + ...this._completedMigrations, + [extAddr]: true, + }; + setTimeout(() => { + if (!this.isConnected) { + return; + } + const { [extAddr]: __, ...remaining } = this._completedMigrations; + this._completedMigrations = remaining; + }, 5000); + this._refresh(); + } - if (needsRefresh) { - this._refresh(); + private _startSync() { + if (Object.keys(this._pendingChanges).length > 0 && !this._syncInterval) { + this._syncInterval = setInterval(() => { + this._refreshPendingDatasets(); + }, 30_000); + } else if ( + Object.keys(this._pendingChanges).length === 0 && + this._syncInterval + ) { + this._clearSync(); } } - private _clearTimers() { - if (this._countdownInterval) { - clearInterval(this._countdownInterval); - this._countdownInterval = undefined; - } + private _clearSync() { if (this._syncInterval) { clearInterval(this._syncInterval); this._syncInterval = undefined; @@ -838,9 +829,13 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { }); return; } - const { [extAddr]: _, ...rest } = this._pendingDatasets; - this._pendingDatasets = rest; - this._updateTimers(); + if (this._completionTimeouts[extAddr]) { + clearTimeout(this._completionTimeouts[extAddr]); + delete this._completionTimeouts[extAddr]; + } + const { [extAddr]: _, ...rest } = this._pendingChanges; + this._pendingChanges = rest; + this._startSync(); } private async _changeChannel(otbr: OTBRInfo) { diff --git a/src/translations/en.json b/src/translations/en.json index 545de91008ce..0aceb885ee94 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7027,11 +7027,8 @@ "change_channel_multiprotocol_enabled_title": "The Thread adapter has multiprotocol enabled", "change_channel_multiprotocol_enabled_text": "To change channel when the Thread adapter has multiprotocol enabled, please use the hardware settings menu.", "pending_channel_change_label": "Pending channel change", - "pending_channel_change_cutover_in": "cutover in", "pending_channel_change_cancel": "Cancel", "pending_channel_change_complete": "Channel change complete", - "pending_channel_change_minutes": "{minutes} min {seconds} sec", - "pending_channel_change_seconds": "{seconds} sec", "change_channel_range": "Channel must be in the range 11 to 26", "change_channel_text": "Initiating a channel change for your Home Assistant Thread network should be performed with caution. Some Thread devices may not migrate to the new channel automatically and, if the new channel is congested, your Thread devices may become intermittently unavailable. Some devices may need to be manually re-joined to your Thread network before they show in Home Assistant again. This action cannot be reversed (without performing another channel change).", "thread_network_info": "Thread network information", From 7131a4d6e4b9b6196cfe3bc54ea29efbc50fc0f8 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:23:03 -0400 Subject: [PATCH 5/6] Simplify pending channel change implementation Remove success state, completion migrations tracking, and sync interval. The alert simply disappears when the timer expires and _refresh() is called. Inline the completion scheduling into _refreshPendingDatasets. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../thread/thread-config-panel.ts | 90 ++++--------------- src/translations/en.json | 1 - 2 files changed, 15 insertions(+), 76 deletions(-) diff --git a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts index a9ee1fb7c99b..9e0266f53d0b 100644 --- a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts @@ -82,10 +82,6 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { @state() private _pendingChanges: Record = {}; - @state() private _completedMigrations: Record = {}; - - private _syncInterval?: ReturnType; - private _completionTimeouts: Record> = {}; @@ -434,11 +430,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { public override disconnectedCallback() { super.disconnectedCallback(); - this._clearSync(); - for (const timeout of Object.values(this._completionTimeouts)) { - clearTimeout(timeout); - } - this._completionTimeouts = {}; + this._clearCompletionTimeouts(); } hassSubscribe() { @@ -694,14 +686,6 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { } const extAddr = otbr.extended_address; - if (this._completedMigrations[extAddr]) { - return html` - ${this.hass.localize( - "ui.panel.config.thread.pending_channel_change_complete" - )} - `; - } - const pending = this._pendingChanges[extAddr]; if (!pending) { return nothing; @@ -734,9 +718,9 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { private async _refreshPendingDatasets() { if (!this._otbrInfo) { this._pendingChanges = {}; - this._clearSync(); return; } + this._clearCompletionTimeouts(); const newPending: Record = {}; const now = Date.now(); const promises = Object.keys(this._otbrInfo).map(async (extAddr) => { @@ -754,68 +738,25 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { }); await Promise.all(promises); this._pendingChanges = newPending; - this._scheduleCompletionChecks(); - this._startSync(); - } - - private _scheduleCompletionChecks() { - // Clear existing completion timeouts - for (const timeout of Object.values(this._completionTimeouts)) { - clearTimeout(timeout); - } - this._completionTimeouts = {}; - const now = Date.now(); for (const [extAddr, pending] of Object.entries(this._pendingChanges)) { - const remaining = pending.end_time.getTime() - now; - if (remaining <= 0) { - this._handleCompletion(extAddr); - } else { - this._completionTimeouts[extAddr] = setTimeout(() => { - this._handleCompletion(extAddr); - }, remaining); - } + const remaining = pending.end_time.getTime() - Date.now(); + this._completionTimeouts[extAddr] = setTimeout( + () => { + if (this.isConnected) { + this._refresh(); + } + }, + Math.max(0, remaining) + ); } } - private _handleCompletion(extAddr: string) { - if (!this.isConnected) { - return; - } - const { [extAddr]: _, ...rest } = this._pendingChanges; - this._pendingChanges = rest; - this._completedMigrations = { - ...this._completedMigrations, - [extAddr]: true, - }; - setTimeout(() => { - if (!this.isConnected) { - return; - } - const { [extAddr]: __, ...remaining } = this._completedMigrations; - this._completedMigrations = remaining; - }, 5000); - this._refresh(); - } - - private _startSync() { - if (Object.keys(this._pendingChanges).length > 0 && !this._syncInterval) { - this._syncInterval = setInterval(() => { - this._refreshPendingDatasets(); - }, 30_000); - } else if ( - Object.keys(this._pendingChanges).length === 0 && - this._syncInterval - ) { - this._clearSync(); - } - } - - private _clearSync() { - if (this._syncInterval) { - clearInterval(this._syncInterval); - this._syncInterval = undefined; + private _clearCompletionTimeouts() { + for (const timeout of Object.values(this._completionTimeouts)) { + clearTimeout(timeout); } + this._completionTimeouts = {}; } private async _cancelChannelChange(ev: Event) { @@ -835,7 +776,6 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { } const { [extAddr]: _, ...rest } = this._pendingChanges; this._pendingChanges = rest; - this._startSync(); } private async _changeChannel(otbr: OTBRInfo) { diff --git a/src/translations/en.json b/src/translations/en.json index 0aceb885ee94..0d8324e0bf8d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7028,7 +7028,6 @@ "change_channel_multiprotocol_enabled_text": "To change channel when the Thread adapter has multiprotocol enabled, please use the hardware settings menu.", "pending_channel_change_label": "Pending channel change", "pending_channel_change_cancel": "Cancel", - "pending_channel_change_complete": "Channel change complete", "change_channel_range": "Channel must be in the range 11 to 26", "change_channel_text": "Initiating a channel change for your Home Assistant Thread network should be performed with caution. Some Thread devices may not migrate to the new channel automatically and, if the new channel is congested, your Thread devices may become intermittently unavailable. Some devices may need to be manually re-joined to your Thread network before they show in Home Assistant again. This action cannot be reversed (without performing another channel change).", "thread_network_info": "Thread network information", From 3ef1f1e7e73da2cb1c6324f3726ebf96dc9bc8dd Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:30:41 -0400 Subject: [PATCH 6/6] Fix hui-timestamp-display import path Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integration-panels/thread/thread-config-panel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts index 9e0266f53d0b..f26fb1e052f9 100644 --- a/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts @@ -52,7 +52,7 @@ import type { HomeAssistant } from "../../../../../types"; import { brandsUrl } from "../../../../../util/brands-url"; import { documentationUrl } from "../../../../../util/documentation-url"; import { fileDownload } from "../../../../../util/file_download"; -import "../../../../../panels/lovelace/components/hui-timestamp-display"; +import "../../../../lovelace/components/hui-timestamp-display"; import { showThreadDatasetDialog } from "./show-dialog-thread-dataset"; interface PendingChannelChange {