Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/data/otbr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OTBRPendingDataset | null> =>
hass.callWS({
type: "otbr/get_pending_dataset",
extended_address,
});

export const OTBRDeletePendingDataset = (
hass: HomeAssistant,
extended_address: string
): Promise<void> =>
hass.callWS({
type: "otbr/delete_pending_dataset",
extended_address,
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,23 @@ 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";
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
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,
Expand Down Expand Up @@ -71,6 +78,14 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {

@state() private _otbrInfo?: OTBRInfoDict;

@state() private _pendingDatasets: Record<string, OTBRPendingDataset> = {};

@state() private _completedMigrations: Record<string, boolean> = {};

private _countdownInterval?: ReturnType<typeof setInterval>;

private _syncInterval?: ReturnType<typeof setInterval>;

protected render(): TemplateResult {
const networks = this._groupRoutersByNetwork(this._routers, this._datasets);

Expand Down Expand Up @@ -210,6 +225,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
</div>`
: ""}
</div>
${this._renderPendingChannelAlert(otbrForNetwork)}
${network.routers?.length
? html`<div class="card-content routers">
<h4>
Expand Down Expand Up @@ -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[]) => {
Expand Down Expand Up @@ -488,6 +509,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
} catch (_err) {
this._otbrInfo = undefined;
}
await this._refreshPendingDatasets();
}

private async _signUrl(ev) {
Expand Down Expand Up @@ -657,6 +679,156 @@ 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`<ha-alert class="pending-alert" alert-type="success">
${this.hass.localize(
"ui.panel.config.thread.pending_channel_change_complete"
)}
</ha-alert>`;
}

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`<ha-alert class="pending-alert" alert-type="info">
${this.hass.localize(
"ui.panel.config.thread.pending_channel_change_label"
)}:
<b>${otbr.channel}</b> → <b>${pending.pending_channel}</b>
— ${this.hass.localize(
"ui.panel.config.thread.pending_channel_change_migration_in"
)} ${formattedTime}
Comment thread
raman325 marked this conversation as resolved.
Outdated
<ha-button
slot="action"
.extendedAddress=${extAddr}
@click=${this._cancelChannelChange}
>
${this.hass.localize(
"ui.panel.config.thread.pending_channel_change_cancel"
)}
</ha-button>
</ha-alert>`;
}

private async _refreshPendingDatasets() {
if (!this._otbrInfo) {
Comment thread
raman325 marked this conversation as resolved.
return;
}
const newPending: Record<string, OTBRPendingDataset> = {};
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<string, OTBRPendingDataset> = {};
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);
Comment thread
raman325 marked this conversation as resolved.
Outdated
this._refresh();
} else {
Comment thread
raman325 marked this conversation as resolved.
Outdated
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",
Comment thread
raman325 marked this conversation as resolved.
Outdated
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, {
Expand Down Expand Up @@ -685,20 +857,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, {
Expand Down Expand Up @@ -774,6 +933,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);
Expand Down
6 changes: 6 additions & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7026,6 +7026,12 @@
"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_migration_in": "migration 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",
Expand Down
Loading