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..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 @@ -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"; @@ -22,6 +23,8 @@ import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagno import type { OTBRInfo, OTBRInfoDict } from "../../../../../data/otbr"; import { OTBRCreateNetwork, + OTBRDeletePendingDataset, + OTBRGetPendingDataset, OTBRSetChannel, OTBRSetNetwork, getOTBRInfo, @@ -49,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 "../../../../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; @@ -71,6 +80,11 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { @state() private _otbrInfo?: OTBRInfoDict; + @state() private _pendingChanges: Record = {}; + + private _completionTimeouts: Record> = + {}; + protected render(): TemplateResult { const networks = this._groupRoutersByNetwork(this._routers, this._datasets); @@ -210,6 +224,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { ` : ""} + ${this._renderPendingChannelAlert(otbrForNetwork)} ${network.routers?.length ? html`

@@ -413,6 +428,11 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { ev.target.style.display = ""; } + public override disconnectedCallback() { + super.disconnectedCallback(); + this._clearCompletionTimeouts(); + } + hassSubscribe() { return [ subscribeDiscoverThreadRouters(this.hass, (routers: ThreadRouter[]) => { @@ -488,6 +508,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { } catch (_err) { this._otbrInfo = undefined; } + await this._refreshPendingDatasets(); } private async _signUrl(ev) { @@ -657,6 +678,106 @@ 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; + + const pending = this._pendingChanges[extAddr]; + if (!pending) { + return nothing; + } + + return html` + ${this.hass.localize( + "ui.panel.config.thread.pending_channel_change_label" + )}: + ${otbr.channel}${pending.pending_channel} + — + + + ${this.hass.localize( + "ui.panel.config.thread.pending_channel_change_cancel" + )} + + `; + } + + private async _refreshPendingDatasets() { + if (!this._otbrInfo) { + this._pendingChanges = {}; + return; + } + this._clearCompletionTimeouts(); + 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] = { + 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._pendingChanges = newPending; + + for (const [extAddr, pending] of Object.entries(this._pendingChanges)) { + const remaining = pending.end_time.getTime() - Date.now(); + this._completionTimeouts[extAddr] = setTimeout( + () => { + if (this.isConnected) { + this._refresh(); + } + }, + Math.max(0, remaining) + ); + } + } + + private _clearCompletionTimeouts() { + for (const timeout of Object.values(this._completionTimeouts)) { + clearTimeout(timeout); + } + this._completionTimeouts = {}; + } + + 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: this.hass.localize("ui.panel.config.thread.otbr_config_failed"), + text: err.message || err, + }); + return; + } + if (this._completionTimeouts[extAddr]) { + clearTimeout(this._completionTimeouts[extAddr]); + delete this._completionTimeouts[extAddr]; + } + const { [extAddr]: _, ...rest } = this._pendingChanges; + this._pendingChanges = rest; + } + private async _changeChannel(otbr: OTBRInfo) { const currentChannel = otbr.channel; const channelStr = await showPromptDialog(this, { @@ -685,20 +806,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, { @@ -774,6 +882,10 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) { justify-content: space-between; } + .pending-alert { + margin: var(--ha-space-2) var(--ha-space-4); + } + .send-to-phone-description { color: var(--secondary-text-color); font-size: var(--ha-font-size-s); diff --git a/src/translations/en.json b/src/translations/en.json index 90fe89714e1e..0d8324e0bf8d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7026,6 +7026,8 @@ "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_label": "Pending channel change", + "pending_channel_change_cancel": "Cancel", "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",