Compare commits

...
Author SHA1 Message Date
Raphael Hehl 94b69033e5 Share a Matter device through the companion app from the share dialog (#54364) 2026-10-26 21:47:32 +01:00
Maarten Lakerveld 8125bf54ee Keep the calendar add button above the safe area (#55298)
The add event button is absolutely positioned, but ha-full-calendar had no
position of its own, so the button resolved against the 100vh app bar layout
and always sat 16px above the physical screen edge. The calendar box already
subtracts the bottom safe-area inset, so anchoring the button to it keeps it
clear of the home indicator and the iOS app's native tab bar.
2026-10-26 20:14:36 +02:00
renovate[bot] 293351bceb Update dependency @lokalise/node-api to v17 (#55321)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-26 20:12:08 +02:00
Felix Schneider 0558fa58d4 Add "open in new tab" to automation trace and editor menus (#55264)
Make automation trace and editor toolbar buttons links

Add an href to the "Traces" button in the automation editor and the
"Edit automation" button on the trace page, so they can be opened in a
new tab via middle-click, Ctrl/Cmd+click or the context menu. A plain
click in the editor still runs the unsaved changes check.
2026-10-26 19:09:17 +01:00
Petar Petrov 040630ed8e Drop hass from the categories filter (#55317)
Category registry create, update and delete helpers take callWS.
2026-10-26 18:52:43 +01:00
18 changed files with 1048 additions and 153 deletions

No files matched your search

+1 -1
View File
@@ -148,7 +148,7 @@
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
"@lokalise/node-api": "16.6.0",
"@lokalise/node-api": "17.0.1",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
+5 -5
View File
@@ -500,8 +500,8 @@ importers:
specifier: 0.67.0
version: 0.67.0([email protected]([email protected]))
'@lokalise/node-api':
specifier: 16.6.0
version: 16.6.0
specifier: 17.0.1
version: 17.0.1
'@octokit/auth-oauth-device':
specifier: 8.0.5
version: 8.0.5
@@ -2221,8 +2221,8 @@ packages:
'@lit/[email protected]':
resolution: {integrity: sha512-1gJGJl8WON+2j0y9xfcD+XsS1rvcy3XDgsIhcdUW++yTR8ESjZW6o7dn8M8a4SZM8NnJe6ynS2cKWwsbfLOurg==}
'@lokalise/node-api@16.6.0':
resolution: {integrity: sha512-2Urk4wow8HLtyvNHUUobzjlqCuynWpC/xy6UY8LtxUdC5NI+h8I/FC1c91GoLljE+9EBwIlQ7VGvTw5tYhSrwA==}
'@lokalise/node-api@17.0.1':
resolution: {integrity: sha512-CI2jlIpR5G7z1/pU4wxAKM1uQJllwhN3mXJvMNCsHj6e369W+w55HTQ/n+oiKYx3tC6hosTOvDuLnzWn8yJFSg==}
engines: {node: '>=22.19.0'}
'@mapbox/[email protected]':
@@ -9764,7 +9764,7 @@ snapshots:
dependencies:
'@lit/reactive-element': 2.1.2
'@lokalise/node-api@16.6.0': {}
'@lokalise/node-api@17.0.1': {}
'@mapbox/[email protected]': {}
+91 -29
View File
@@ -6,8 +6,9 @@ import {
mdiPlus,
mdiTag,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import type { ContextType } from "@lit/context";
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
@@ -16,8 +17,12 @@ import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { LocalizeFunc } from "../common/translations/localize";
import type { CategoryRegistryEntry } from "../data/category_registry";
import {
createCategoryRegistryEntry,
@@ -25,11 +30,11 @@ import {
subscribeCategoryRegistry,
updateCategoryRegistryEntry,
} from "../data/category_registry";
import { apiContext, connectionContext } from "../data/context";
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import type { HomeAssistantConnection } from "../types";
import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
@@ -40,8 +45,19 @@ import "./item/ha-list-item-option";
import "./list/ha-list-selectable";
@customElement("ha-filter-categories")
export class HaFilterCategories extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
export class HaFilterCategories extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: connectionContext, subscribe: true })
@transform<HomeAssistantConnection, Connection>({
transformer: (value) => value?.connection,
})
private _connection?: Connection;
@property({ attribute: false }) public value?: string[];
@@ -57,18 +73,66 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
private _panel = new FilterPanelController(this, this._content);
protected hassSubscribeRequiredHostProps = ["scope"];
private _unsubCategories?: UnsubscribeFunc;
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeCategoryRegistry(
this.hass.connection,
this.scope!,
(categories) => {
private _categoriesConnection?: Connection;
private _categoriesScope?: string;
public connectedCallback() {
super.connectedCallback();
this._subscribeCategories();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeCategories();
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.get("scope") !== undefined) {
this._categories = [];
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_connection") || changedProps.has("scope")) {
this._subscribeCategories();
}
}
private _subscribeCategories() {
if (
this._connection === this._categoriesConnection &&
this.scope === this._categoriesScope
) {
return;
}
this._unsubscribeCategories();
if (!this._connection || !this.scope || !this.isConnected) {
return;
}
const scope = this.scope;
this._categoriesConnection = this._connection;
this._categoriesScope = scope;
this._unsubCategories = subscribeCategoryRegistry(
this._connection,
scope,
(categories) => {
if (this._categoriesScope === scope) {
this._categories = categories;
}
),
];
}
);
}
private _unsubscribeCategories() {
this._unsubCategories?.();
this._unsubCategories = undefined;
this._categoriesConnection = undefined;
this._categoriesScope = undefined;
}
protected render() {
@@ -79,7 +143,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.category.caption")}
${this._localize("ui.panel.config.category.caption")}
${
this.value?.length
? html`<div class="badge">${this.value?.length}</div>
@@ -98,7 +162,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
<ha-list-selectable
class="ha-scrollbar"
multi
aria-label=${this.hass.localize(
aria-label=${this._localize(
"ui.panel.config.category.caption"
)}
@ha-list-item-selected=${this._categoryAdded}
@@ -138,14 +202,14 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
<ha-icon-button
.path=${mdiDotsVertical}
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.label=${this._localize("ui.common.menu")}
></ha-icon-button>
<ha-dropdown-item value="edit">
<ha-svg-icon
slot="icon"
.path=${mdiPencil}
></ha-svg-icon>
${this.hass.localize(
${this._localize(
"ui.panel.config.category.editor.edit"
)}
</ha-dropdown-item>
@@ -154,7 +218,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
slot="icon"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
${this._localize(
"ui.panel.config.category.editor.delete"
)}
</ha-dropdown-item>
@@ -164,7 +228,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
</ha-list-selectable>
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
${this._localize("ui.panel.config.category.editor.add")}
</ha-list-item>
</div>
`
@@ -191,26 +255,24 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
scope: this.scope!,
entry: this._categories.find((cat) => cat.category_id === id),
updateEntry: (updates) =>
updateCategoryRegistryEntry(this.hass, this.scope!, id, updates),
updateCategoryRegistryEntry(this._api.callWS, this.scope!, id, updates),
});
}
private async _deleteCategory(id: string) {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.category.editor.confirm_delete"
),
text: this.hass.localize(
title: this._localize("ui.panel.config.category.editor.confirm_delete"),
text: this._localize(
"ui.panel.config.category.editor.confirm_delete_text"
),
confirmText: this.hass.localize("ui.common.delete"),
confirmText: this._localize("ui.common.delete"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await deleteCategoryRegistryEntry(this.hass, this.scope!, id);
await deleteCategoryRegistryEntry(this._api.callWS, this.scope!, id);
if (this.value?.includes(id)) {
this._setValue(this.value.filter((categoryId) => categoryId !== id));
@@ -227,7 +289,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
showCategoryRegistryDetailDialog(this, {
scope: this.scope,
createEntry: (values) =>
createCategoryRegistryEntry(this.hass, this.scope!, values),
createCategoryRegistryEntry(this._api.callWS, this.scope!, values),
});
}
+6 -6
View File
@@ -53,23 +53,23 @@ export const subscribeCategoryRegistry = (
);
export const createCategoryRegistryEntry = (
hass: HomeAssistant,
callWS: HomeAssistant["callWS"],
scope: string,
values: CategoryRegistryEntryMutableParams
) =>
hass.callWS<CategoryRegistryEntry>({
callWS<CategoryRegistryEntry>({
type: "config/category_registry/create",
scope,
...values,
});
export const updateCategoryRegistryEntry = (
hass: HomeAssistant,
callWS: HomeAssistant["callWS"],
scope: string,
category_id: string,
updates: Partial<CategoryRegistryEntryMutableParams>
) =>
hass.callWS<CategoryRegistryEntry>({
callWS<CategoryRegistryEntry>({
type: "config/category_registry/update",
scope,
category_id,
@@ -77,11 +77,11 @@ export const updateCategoryRegistryEntry = (
});
export const deleteCategoryRegistryEntry = (
hass: HomeAssistant,
callWS: HomeAssistant["callWS"],
scope: string,
category_id: string
) =>
hass.callWS({
callWS({
type: "config/category_registry/delete",
scope,
category_id,
+60
View File
@@ -113,11 +113,71 @@ export interface MatterCommissioningParameters {
setup_pin_code: number;
setup_manual_code: string;
setup_qr_code: string;
// Null when the Matter server does not report them, absent with an older Core.
discriminator?: number | null;
vendor_id?: number | null;
product_id?: number | null;
commissioning_timeout?: number | null;
}
export const canCommissionMatterExternal = (hass: HomeAssistant) =>
hass.auth.external?.config.canCommissionMatter;
export type MatterShareTarget = "apple_home" | "app_chooser";
export interface MatterShareDeviceParams {
// Always sent: the iOS app falls back to it, the Android app ignores it.
setup_qr_code: string;
setup_pin_code: number;
discriminator?: number;
vendor_id?: number;
product_id?: number;
device_name?: string;
remaining_seconds?: number;
}
/** The app chooser opens a window of its own, so unlike Apple Home it needs the discriminator and timeout. */
export const matterShareTargetExternal = (
hass: HomeAssistant,
params: MatterCommissioningParameters | undefined
): MatterShareTarget | undefined => {
const config = hass.auth.external?.config;
if (!config || !params) {
return undefined;
}
if (config.canShareMatterDeviceToAppleHome) {
return "apple_home";
}
if (
config.canShareMatterDeviceToOtherApps &&
params.discriminator != null &&
params.commissioning_timeout != null
) {
return "app_chooser";
}
return undefined;
};
/** Capped at `timeout` in case the clock went back. */
export const matterShareRemainingSeconds = (
timeout: number | null | undefined,
requestedAt: number | undefined,
now: number
): number | undefined =>
timeout != null && requestedAt !== undefined
? Math.min(timeout, Math.floor(timeout - (now - requestedAt) / 1000))
: undefined;
/** Rejects with `{code, message}`; `code` is `canceled` when the user backed out. */
export const shareMatterDeviceExternal = (
hass: HomeAssistant,
params: MatterShareDeviceParams
) =>
hass.auth.external!.sendMessage<"matter/share_device">({
type: "matter/share_device",
payload: params,
});
export const startExternalCommissioning = async (hass: HomeAssistant) => {
if (isComponentLoaded(hass.config, "thread")) {
const datasets = await listThreadDataSets(hass);
+13
View File
@@ -1,5 +1,6 @@
import type { NavigateOptions } from "../common/navigate";
import type { AutomationConfig } from "../data/automation";
import type { MatterShareDeviceParams } from "../data/matter";
const CALLBACK_EXTERNAL_BUS = "externalBus";
@@ -43,6 +44,11 @@ interface EMOutgoingMessageEntityAddToGetActions extends EMMessage {
};
}
interface EMOutgoingMessageMatterShareDevice extends EMMessage {
type: "matter/share_device";
payload: MatterShareDeviceParams;
}
interface EMOutgoingMessageBarCodeScan extends EMMessage {
type: "bar_code/scan";
payload: {
@@ -118,6 +124,11 @@ interface EMOutgoingMessageWithAnswer {
request: EMOutgoingMessageEntityAddToGetActions;
response: ExternalEntityAddToActions;
};
"matter/share_device": {
request: EMOutgoingMessageMatterShareDevice;
// The app answers with an empty object; the outcome is the resolution itself.
response: Record<string, never>;
};
// Replies once the stream is connected
"webrtc/stream/start": {
request: EMOutgoingMessageWebRtcStreamStart;
@@ -447,6 +458,8 @@ export interface ExternalConfig {
hasExoPlayer?: boolean;
canCommissionMatter?: boolean;
hasMatterStatusReport?: boolean;
canShareMatterDeviceToAppleHome?: boolean;
canShareMatterDeviceToOtherApps?: boolean;
canImportThreadCredentials?: boolean;
canTransferThreadCredentialsToKeychain?: boolean;
hasAssist?: boolean;
+1
View File
@@ -694,6 +694,7 @@ export class HAFullCalendar extends LitElement {
:host {
display: flex;
flex-direction: column;
position: relative;
--fc-classic-today: transparent;
}
@@ -26,6 +26,7 @@ import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import { fireEvent } from "../../../common/dom/fire_event";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../../common/navigate";
import { promiseTimeout } from "../../../common/util/promise-timeout";
import "../../../components/ha-button";
@@ -290,7 +291,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
<ha-button
appearance="plain"
size="s"
@click=${this._showTrace}
href="/config/automation/trace/${encodeURIComponent(
this.config.id
)}"
@click=${this._traceClicked}
slot="toolbar-icon"
>
${this.hass.localize(
@@ -851,6 +855,12 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
});
}
private async _traceClicked(ev: MouseEvent) {
if (isNavigationClick(ev)) {
await this._showTrace();
}
}
private async _showTrace() {
if (this.config?.id) {
const result = await this.confirmUnsavedChanged();
@@ -600,7 +600,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-categories
.hass=${this.hass}
scope="automation"
.value=${this._filters["ha-filter-categories"]?.value}
@data-table-filter-changed=${this._filterChanged}
@@ -1403,7 +1402,7 @@ ${rejected
scope: "automation",
createEntry: async (values) => {
const category = await createCategoryRegistryEntry(
this.hass,
this.hass.callWS,
"automation",
values
);
@@ -168,7 +168,9 @@ export class HaAutomationTrace extends LitElement {
appearance="plain"
size="s"
class="trace-link"
@click=${this._navigateToAutomation}
href="/config/automation/edit/${encodeURIComponent(
stateObj.attributes.id
)}"
slot="toolbar-icon"
>
${this.hass.localize(
@@ -232,7 +232,7 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
suggestedName: suggestedName,
createEntry: async (values) => {
const category = await createCategoryRegistryEntry(
this.hass,
this.hass.callWS,
this.scope!,
values
);
@@ -764,7 +764,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-categories
.hass=${this.hass}
scope="helpers"
.value=${this._filters["ha-filter-categories"]}
@data-table-filter-changed=${this._filterChanged}
@@ -1550,7 +1549,7 @@ ${rejected
scope: "helpers",
createEntry: async (values) => {
const category = await createCategoryRegistryEntry(
this.hass,
this.hass.callWS,
"helpers",
values
);
@@ -1,22 +1,36 @@
import { mdiCloseCircle } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-qr-code";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import { domainToName } from "../../../../../data/integration";
import type { MatterCommissioningParameters } from "../../../../../data/matter";
import { openMatterCommissioningWindow } from "../../../../../data/matter";
import type {
MatterCommissioningParameters,
MatterShareTarget,
} from "../../../../../data/matter";
import {
matterShareRemainingSeconds,
matterShareTargetExternal,
openMatterCommissioningWindow,
shareMatterDeviceExternal,
} from "../../../../../data/matter";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
import type { MatterOpenCommissioningWindowDialogParams } from "./show-dialog-matter-open-commissioning-window";
// No countdown is shown, so leave the app time to start pairing.
const EXPIRY_MARGIN_SECONDS = 30;
@customElement("dialog-matter-open-commissioning-window")
class DialogMatterOpenCommissioningWindow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -29,10 +43,32 @@ class DialogMatterOpenCommissioningWindow extends LitElement {
@state() private _open = false;
@state() private _sharing = false;
@state() private _shareFailed = false;
// Kept apart from a failure because trying again cannot bring the window back.
@state() private _shareExpired = false;
@query(".share-alert") private _shareAlert?: HTMLElement | null;
private _windowOpenedAt?: number;
private _expiryTimer?: number;
// Not reset on close: the request outlives it.
private _opening = new Set<string>();
public async showDialog(
params: MatterOpenCommissioningWindowDialogParams
): Promise<void> {
// The element is reused and `closed` is not guaranteed, so the last device's window could show.
this._resetWindow();
this.device_id = params.device_id;
// The Matter server revokes an open window before opening another, so a second Start would kill it.
if (this._opening.has(params.device_id)) {
this._status = "started";
}
this._open = true;
}
@@ -40,6 +76,14 @@ class DialogMatterOpenCommissioningWindow extends LitElement {
if (!this.device_id) {
return nothing;
}
const shareTarget = matterShareTargetExternal(
this.hass,
this._commissionParams
);
// Apple Home only helps Apple Home users, so there the code, which works with any app, stays primary.
const copyIsPrimary = shareTarget === "apple_home";
// While the app pairs, its window is still live, and a new code would revoke it.
const expired = this._shareExpired && !this._sharing;
return html`
<ha-dialog
@@ -50,113 +94,152 @@ class DialogMatterOpenCommissioningWindow extends LitElement {
@closed=${this._dialogClosed}
>
${
this._commissionParams
? html`
<p>
this._commissionParams && expired
? html`<ha-alert class="share-alert" alert-type="error">
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.share_expired"
)}
<ha-button slot="action" size="s" @click=${this._start}>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.success"
"ui.panel.config.matter.open_commissioning_window.new_code"
)}
<br />
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.scan_code"
)}
</p>
<div class="sharing-code-container">
<div class="sharing-code">
<img
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${domainToName(this.hass.localize, "matter")}
src=${brandsUrl(
{
domain: "matter",
type: "logo",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
<ha-qr-code
.data=${this._commissionParams.setup_qr_code}
errorCorrectionLevel="quartile"
scale="6"
margin="1"
></ha-qr-code>
<span class="code"
>${this._commissionParams.setup_manual_code.substring(
0,
4
)}-${this._commissionParams.setup_manual_code.substring(
4,
7
)}-${this._commissionParams.setup_manual_code.substring(
7
)}</span
>
</div>
</div>
`
: this._status === "started"
</ha-button>
</ha-alert>`
: this._commissionParams
? html`
<div class="flex-container">
<ha-spinner></ha-spinner>
<div class="status">
<p>
<b>
<p>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.success"
)}
<br />
${this._scanPrompt(shareTarget)}
</p>
${
this._shareFailed
? html`<ha-alert class="share-alert" alert-type="error">
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.in_progress"
"ui.panel.config.matter.open_commissioning_window.share_failed"
)}
</b>
</p>
</ha-alert>`
: nothing
}
<div class="sharing-code-container">
<div class="sharing-code">
<img
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${domainToName(this.hass.localize, "matter")}
src=${brandsUrl(
{
domain: "matter",
type: "logo",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
<ha-qr-code
.data=${this._commissionParams.setup_qr_code}
errorCorrectionLevel="quartile"
scale="6"
margin="1"
></ha-qr-code>
<span class="code"
>${this._commissionParams.setup_manual_code.substring(
0,
4
)}-${this._commissionParams.setup_manual_code.substring(
4,
7
)}-${this._commissionParams.setup_manual_code.substring(
7
)}</span
>
</div>
</div>
`
: this._status === "failed"
: this._status === "started"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
<ha-spinner></ha-spinner>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.failed"
)}
<b>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.in_progress"
)}
</b>
</p>
</div>
</div>
`
: html`
<p>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.description",
{
startCommissioning: html`<b
>${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.start_commissioning"
)}</b
>`,
}
)}
</p>
<p class="note">
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.prevent_misuse_description"
)}
</p>
`
: this._status === "failed"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.failed"
)}
</p>
</div>
</div>
`
: html`
<p>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.description",
{
startCommissioning: html`<b
>${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.start_commissioning"
)}</b
>`,
}
)}
</p>
<p class="note">
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.prevent_misuse_description"
)}
</p>
`
}
<ha-dialog-footer slot="footer">
${
this._commissionParams
? html`
<ha-button slot="primaryAction" @click=${this._copyCode}>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.copy_code"
)}
</ha-button>
`
// An expired code leaves `_status` at "started", so it falls through to Close.
this._commissionParams && !expired
? shareTarget
? html`
<ha-button
slot=${copyIsPrimary ? "primaryAction" : "secondaryAction"}
appearance=${copyIsPrimary ? "accent" : "plain"}
@click=${this._copyCode}
>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.copy_code"
)}
</ha-button>
<ha-button
slot=${copyIsPrimary ? "secondaryAction" : "primaryAction"}
appearance=${copyIsPrimary ? "plain" : "accent"}
.loading=${this._sharing}
@click=${this._shareDevice}
>
${this._shareLabel(shareTarget)}
</ha-button>
`
: html`
<ha-button slot="primaryAction" @click=${this._copyCode}>
${this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.copy_code"
)}
</ha-button>
`
: this._status === "started" || this._status === "failed"
? html`
<ha-button slot="primaryAction" @click=${this.closeDialog}>
@@ -176,19 +259,132 @@ class DialogMatterOpenCommissioningWindow extends LitElement {
`;
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
// The footer button is pinned while the body scrolls, so on a phone the alert can land off-screen.
if (
(changedProperties.has("_shareFailed") ||
changedProperties.has("_shareExpired") ||
changedProperties.has("_sharing")) &&
(this._shareFailed || this._shareExpired) &&
!this._sharing
) {
this._shareAlert?.scrollIntoView({ block: "nearest" });
}
}
private async _start(): Promise<void> {
if (!this.hass) {
return;
}
this._status = "started";
this._commissionParams = undefined;
clearTimeout(this._expiryTimer);
this._shareFailed = false;
this._shareExpired = false;
const deviceId = this.device_id!;
this._opening.add(deviceId);
try {
this._commissionParams = await openMatterCommissioningWindow(
this.hass,
this.device_id!
);
// Before the request, so the remaining time is never overstated. Not `performance.now()`: it
// stops while the device sleeps, and a locked screen is how a window usually runs out.
const requestedAt = Date.now();
const params = await openMatterCommissioningWindow(this.hass, deviceId);
// Closed or switched device meanwhile. Same device keeps it, or the open window is orphaned.
if (this.device_id !== deviceId) {
return;
}
this._commissionParams = params;
this._windowOpenedAt = requestedAt;
if (params.commissioning_timeout != null) {
const delay =
requestedAt +
(params.commissioning_timeout - EXPIRY_MARGIN_SECONDS) * 1000 -
Date.now();
// Otherwise a dead code only shows as dead on a Share tap.
this._expiryTimer = window.setTimeout(() => {
this._shareExpired = true;
}, delay);
}
} catch (_e) {
this._status = "failed";
if (this.device_id === deviceId) {
this._status = "failed";
}
} finally {
this._opening.delete(deviceId);
}
}
// The QR code dominates the dialog, so say the button skips the scan.
private _scanPrompt(target: MatterShareTarget | undefined) {
if (!target) {
return this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.scan_code"
);
}
const button = this._shareLabel(target);
return target === "apple_home"
? this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.scan_code_add_to_apple_home",
{ button }
)
: this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.scan_code_share_with_app",
{ button }
);
}
private _shareLabel(target: MatterShareTarget) {
return target === "apple_home"
? this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.add_to_apple_home"
)
: this.hass.localize(
"ui.panel.config.matter.open_commissioning_window.share_with_app"
);
}
private async _shareDevice() {
const params = this._commissionParams;
if (!params || this._sharing) {
return;
}
this._shareFailed = false;
this._shareExpired = false;
const remaining = matterShareRemainingSeconds(
params.commissioning_timeout,
this._windowOpenedAt,
Date.now()
);
// `<=`: the timer fires with exactly the margin left.
if (remaining !== undefined && remaining <= EXPIRY_MARGIN_SECONDS) {
this._shareExpired = true;
return;
}
this._sharing = true;
const device = this.hass.devices[this.device_id!];
try {
await shareMatterDeviceExternal(this.hass, {
setup_qr_code: params.setup_qr_code,
setup_pin_code: params.setup_pin_code,
discriminator: params.discriminator ?? undefined,
vendor_id: params.vendor_id ?? undefined,
product_id: params.product_id ?? undefined,
device_name: device ? computeDeviceName(device) : undefined,
remaining_seconds: remaining,
});
// Closed or reopened meanwhile.
if (this._commissionParams === params) {
this.closeDialog();
}
} catch (err: unknown) {
if (this._commissionParams === params) {
// Backing out of the platform sheet is not an error.
this._shareFailed = (err as { code?: string })?.code !== "canceled";
}
} finally {
if (this._commissionParams === params) {
this._sharing = false;
}
}
}
@@ -197,7 +393,10 @@ class DialogMatterOpenCommissioningWindow extends LitElement {
return;
}
await copyToClipboard(this._commissionParams.setup_manual_code);
this.closeDialog();
// Closing under the open share sheet would discard its result.
if (!this._sharing) {
this.closeDialog();
}
}
public closeDialog(): void {
@@ -205,10 +404,21 @@ class DialogMatterOpenCommissioningWindow extends LitElement {
}
private _dialogClosed(): void {
this._resetWindow();
// Every dismissal that is not `closeDialog` leaves this true, and `ha-dialog` reads it.
this._open = false;
this.device_id = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _resetWindow(): void {
this._status = undefined;
this._commissionParams = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
this._sharing = false;
this._shareFailed = false;
this._shareExpired = false;
this._windowOpenedAt = undefined;
clearTimeout(this._expiryTimer);
}
static get styles(): CSSResultGroup {
@@ -240,7 +450,7 @@ class DialogMatterOpenCommissioningWindow extends LitElement {
padding: 8px;
}
ha-svg-icon {
.flex-container ha-svg-icon {
width: 68px;
height: 48px;
}
@@ -282,6 +492,11 @@ class DialogMatterOpenCommissioningWindow extends LitElement {
color: var(--secondary-text-color);
font-size: 0.9em;
}
ha-alert ha-button[slot="action"] {
width: max-content;
white-space: nowrap;
}
`,
];
}
@@ -599,7 +599,6 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-categories
.hass=${this.hass}
scope="scene"
.value=${this._filters["ha-filter-categories"]?.value}
@data-table-filter-changed=${this._filterChanged}
@@ -1145,7 +1144,7 @@ ${rejected
scope: "scene",
createEntry: async (values) => {
const category = await createCategoryRegistryEntry(
this.hass,
this.hass.callWS,
"scene",
values
);
+1 -2
View File
@@ -532,7 +532,6 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-categories
.hass=${this.hass}
scope="script"
.value=${this._filters["ha-filter-categories"]?.value}
@data-table-filter-changed=${this._filterChanged}
@@ -1123,7 +1122,7 @@ ${rejected
scope: "script",
createEntry: async (values) => {
const category = await createCategoryRegistryEntry(
this.hass,
this.hass.callWS,
"script",
values
);
+8 -1
View File
@@ -9260,7 +9260,14 @@
"failed": "The command failed. Additional information may be available in the logs.",
"success": "Your device is ready to be added to another Matter platform.",
"scan_code": "With their app, scan the QR code or enter the sharing code below to finish setup.",
"copy_code": "Copy code"
"scan_code_add_to_apple_home": "If you use Apple Home on this device, select \"{button}\". Otherwise, scan the QR code or enter the sharing code below in the other app to finish setup.",
"scan_code_share_with_app": "If the other app, such as Google Home, is on this device, select \"{button}\". Otherwise, scan the QR code or enter the sharing code below in that app to finish setup.",
"copy_code": "Copy code",
"add_to_apple_home": "Add to Apple Home",
"share_with_app": "Share with app",
"share_failed": "Couldn't share the device. Try again, or close this dialog to get a new code.",
"share_expired": "This code has expired.",
"new_code": "Get new code"
},
"lock": {
"manage": "Manage access",
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import type { MatterCommissioningParameters } from "../../src/data/matter";
import {
matterShareRemainingSeconds,
matterShareTargetExternal,
} from "../../src/data/matter";
import type { ExternalConfig } from "../../src/external_app/external_messaging";
import type { HomeAssistant } from "../../src/types";
const codesOnly: MatterCommissioningParameters = {
setup_pin_code: 20202021,
setup_manual_code: "34970112332",
setup_qr_code: "MT:-24J0AFN00KA0648G00",
};
const withFields: MatterCommissioningParameters = {
...codesOnly,
discriminator: 3840,
vendor_id: 0xfff1,
product_id: 0x8000,
commissioning_timeout: 300,
};
const hassWith = (config?: Partial<ExternalConfig>): HomeAssistant =>
({
auth: { external: config ? { config } : undefined },
}) as unknown as HomeAssistant;
describe("matterShareTargetExternal", () => {
it("shares to Apple Home with the setup code alone", () => {
const hass = hassWith({ canShareMatterDeviceToAppleHome: true });
expect(matterShareTargetExternal(hass, codesOnly)).toBe("apple_home");
expect(matterShareTargetExternal(hass, withFields)).toBe("apple_home");
});
it("needs the window's fields for the app chooser", () => {
const hass = hassWith({ canShareMatterDeviceToOtherApps: true });
expect(matterShareTargetExternal(hass, codesOnly)).toBe(undefined);
expect(
matterShareTargetExternal(hass, { ...codesOnly, discriminator: null })
).toBe(undefined);
expect(matterShareTargetExternal(hass, withFields)).toBe("app_chooser");
});
it("needs the window's discriminator for the app chooser", () => {
const hass = hassWith({ canShareMatterDeviceToOtherApps: true });
expect(
matterShareTargetExternal(hass, { ...withFields, discriminator: null })
).toBe(undefined);
expect(
matterShareTargetExternal(hass, {
...withFields,
discriminator: undefined,
})
).toBe(undefined);
});
it("needs the window's timeout for the app chooser", () => {
const hass = hassWith({ canShareMatterDeviceToOtherApps: true });
expect(
matterShareTargetExternal(hass, {
...withFields,
commissioning_timeout: null,
})
).toBe(undefined);
expect(
matterShareTargetExternal(hass, {
...withFields,
commissioning_timeout: undefined,
})
).toBe(undefined);
});
it("still offers Apple Home without the window's timeout", () => {
expect(
matterShareTargetExternal(
hassWith({ canShareMatterDeviceToAppleHome: true }),
{ ...withFields, commissioning_timeout: null }
)
).toBe("apple_home");
});
// 0 is a valid discriminator.
it("offers the app chooser for a discriminator of zero", () => {
expect(
matterShareTargetExternal(
hassWith({ canShareMatterDeviceToOtherApps: true }),
{ ...withFields, discriminator: 0 }
)
).toBe("app_chooser");
});
// Apps are meant to send at most one.
it("prefers Apple Home when an app reports both capabilities", () => {
expect(
matterShareTargetExternal(
hassWith({
canShareMatterDeviceToAppleHome: true,
canShareMatterDeviceToOtherApps: true,
}),
withFields
)
).toBe("apple_home");
});
it("offers nothing without an app, a capability or an open window", () => {
expect(matterShareTargetExternal(hassWith(), withFields)).toBe(undefined);
expect(matterShareTargetExternal(hassWith({}), withFields)).toBe(undefined);
expect(
matterShareTargetExternal(
hassWith({ canShareMatterDeviceToAppleHome: true }),
undefined
)
).toBe(undefined);
});
});
describe("matterShareRemainingSeconds", () => {
const requestedAt = 1_000_000;
it("is unknown without a timeout or a start", () => {
expect(
matterShareRemainingSeconds(undefined, requestedAt, requestedAt)
).toBe(undefined);
expect(matterShareRemainingSeconds(null, requestedAt, requestedAt)).toBe(
undefined
);
expect(matterShareRemainingSeconds(300, undefined, requestedAt)).toBe(
undefined
);
});
// 0 is a closed window, not an unknown one.
it("reports a timeout of zero as no time left", () => {
expect(matterShareRemainingSeconds(0, requestedAt, requestedAt)).toBe(0);
});
it("counts down in whole seconds", () => {
expect(matterShareRemainingSeconds(300, requestedAt, requestedAt)).toBe(
300
);
expect(
matterShareRemainingSeconds(300, requestedAt, requestedAt + 60_500)
).toBe(239);
});
// 0 is a valid timestamp.
it("counts from a requestedAt of zero", () => {
expect(matterShareRemainingSeconds(300, 0, 10_000)).toBe(290);
});
it("never exceeds the window when the clock went back", () => {
expect(
matterShareRemainingSeconds(300, requestedAt, requestedAt - 10_000)
).toBe(300);
});
it("drops below one second once the window closed", () => {
expect(
matterShareRemainingSeconds(300, requestedAt, requestedAt + 300_000)
).toBe(0);
});
});
@@ -0,0 +1,366 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type * as MatterData from "../../../../../../src/data/matter";
import type { MatterCommissioningParameters } from "../../../../../../src/data/matter";
import type { MatterOpenCommissioningWindowDialogParams } from "../../../../../../src/panels/config/integrations/integration-panels/matter/show-dialog-matter-open-commissioning-window";
import type { HomeAssistant } from "../../../../../../src/types";
const openMatterCommissioningWindow = vi.fn();
const shareMatterDeviceExternal = vi.fn();
vi.mock("../../../../../../src/common/util/copy-clipboard", () => ({
copyToClipboard: vi.fn(),
}));
vi.mock("../../../../../../src/data/matter", async (importOriginal) => ({
...(await importOriginal<typeof MatterData>()),
openMatterCommissioningWindow: (...args: unknown[]) =>
openMatterCommissioningWindow(...args),
shareMatterDeviceExternal: (...args: unknown[]) =>
shareMatterDeviceExternal(...args),
}));
await import("../../../../../../src/panels/config/integrations/integration-panels/matter/dialog-matter-open-commissioning-window");
const window_: MatterCommissioningParameters = {
setup_pin_code: 20202021,
setup_manual_code: "34970112332",
setup_qr_code: "MT:-24J0AFN00KA0648G00",
discriminator: 3840,
vendor_id: 0xfff1,
product_id: 0x8001,
commissioning_timeout: 300,
};
/** The element is reused, so a late window has to land in the open it belongs to. */
describe("dialog-matter-open-commissioning-window", () => {
let dialog: HTMLElement & {
hass: HomeAssistant;
showDialog: (
params: MatterOpenCommissioningWindowDialogParams
) => Promise<void>;
};
// As in test/dialogs/form/dialog-form.test.ts: the guards only show in private state.
const internals = () => dialog as unknown as Record<string, unknown>;
const share = () => (internals()["_shareDevice"] as () => Promise<void>)();
const start = () => (internals()["_start"] as () => Promise<void>)();
const close = () => (internals()["_dialogClosed"] as () => void)();
const commissionParams = () =>
internals()["_commissionParams"] as
MatterCommissioningParameters | undefined;
beforeEach(() => {
openMatterCommissioningWindow.mockReset();
shareMatterDeviceExternal.mockReset();
dialog = document.createElement(
"dialog-matter-open-commissioning-window"
) as typeof dialog;
// Never appended, so `render()` does not run and the stub needs no `auth`.
dialog.hass = {
devices: { dev_a: { name: "Kitchen light" } },
localize: (key: string) => key,
} as unknown as HomeAssistant;
});
const pendingWindow = () => {
let settle!: {
resolve: (params: MatterCommissioningParameters) => void;
reject: (error: Error) => void;
};
openMatterCommissioningWindow.mockReturnValue(
new Promise<MatterCommissioningParameters>((resolve, reject) => {
settle = { resolve, reject };
})
);
return settle;
};
it("drops a window that arrives for a device the dialog has left", async () => {
await dialog.showDialog({ device_id: "dev_a" });
const settle = pendingWindow();
const started = start();
close();
await dialog.showDialog({ device_id: "dev_b" });
settle.resolve(window_);
await started;
expect(commissionParams()).toBeUndefined();
expect(internals()["_windowOpenedAt"]).toBeUndefined();
});
it("marks itself closed when the dialog reports it was dismissed", async () => {
openMatterCommissioningWindow.mockResolvedValue(window_);
await dialog.showDialog({ device_id: "dev_a" });
await start();
close();
expect(internals()["_open"]).toBe(false);
});
it("drops a failure that arrives for a device the dialog has left", async () => {
await dialog.showDialog({ device_id: "dev_a" });
const settle = pendingWindow();
const started = start();
close();
await dialog.showDialog({ device_id: "dev_b" });
settle.reject(new Error("busy"));
await started;
expect(internals()["_status"]).toBeUndefined();
});
it("keeps a window that arrives after a reopen for the same device", async () => {
await dialog.showDialog({ device_id: "dev_a" });
const settle = pendingWindow();
const started = start();
close();
await dialog.showDialog({ device_id: "dev_a" });
settle.resolve(window_);
await started;
expect(commissionParams()).toBe(window_);
expect(internals()["_windowOpenedAt"]).toBeTypeOf("number");
});
it("waits for the pending window instead of offering Start after a reopen for the same device", async () => {
await dialog.showDialog({ device_id: "dev_a" });
const settle = pendingWindow();
const started = start();
close();
await dialog.showDialog({ device_id: "dev_a" });
expect(internals()["_status"]).toBe("started");
settle.resolve(window_);
await started;
close();
await dialog.showDialog({ device_id: "dev_a" });
expect(internals()["_status"]).toBeUndefined();
});
it("forgets the previous window when reopened without a close", async () => {
openMatterCommissioningWindow.mockResolvedValue(window_);
await dialog.showDialog({ device_id: "dev_a" });
await start();
expect(commissionParams()).toBe(window_);
await dialog.showDialog({ device_id: "dev_b" });
expect(commissionParams()).toBeUndefined();
expect(internals()["_status"]).toBeUndefined();
});
describe("sharing", () => {
// The remaining time is in whole seconds, so a real clock can tick it down mid-test.
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const openedWindow = async () => {
openMatterCommissioningWindow.mockResolvedValue(window_);
await dialog.showDialog({ device_id: "dev_a" });
await start();
};
/** The keys are the contract with both apps, and nothing else checks them. */
it("puts the whole window on the bus under the names the apps read", async () => {
shareMatterDeviceExternal.mockResolvedValue({});
await openedWindow();
await share();
expect(shareMatterDeviceExternal).toHaveBeenCalledTimes(1);
expect(shareMatterDeviceExternal.mock.calls[0][1]).toStrictEqual({
setup_qr_code: window_.setup_qr_code,
setup_pin_code: window_.setup_pin_code,
discriminator: window_.discriminator,
vendor_id: window_.vendor_id,
product_id: window_.product_id,
device_name: "Kitchen light",
remaining_seconds: 300,
});
});
it("closes the dialog when the app reports success", async () => {
shareMatterDeviceExternal.mockResolvedValue({});
await openedWindow();
await share();
expect(internals()["_open"]).toBe(false);
expect(internals()["_shareFailed"]).toBe(false);
});
it("shows nothing when the app reports a cancel", async () => {
shareMatterDeviceExternal.mockRejectedValue({ code: "canceled" });
await openedWindow();
await share();
expect(internals()["_shareFailed"]).toBe(false);
expect(internals()["_open"]).toBe(true);
});
it("shows a failure for every other code", async () => {
shareMatterDeviceExternal.mockRejectedValue({ code: "failed" });
await openedWindow();
await share();
expect(internals()["_shareFailed"]).toBe(true);
expect(internals()["_open"]).toBe(true);
});
it("refuses a window that has run out, without asking the app", async () => {
await openedWindow();
internals()["_windowOpenedAt"] = Date.now() - 300_000;
await share();
expect(shareMatterDeviceExternal).not.toHaveBeenCalled();
expect(internals()["_shareExpired"]).toBe(true);
});
it("refuses a window with 30 seconds left", async () => {
await openedWindow();
internals()["_windowOpenedAt"] = Date.now() - 270_000;
await share();
expect(shareMatterDeviceExternal).not.toHaveBeenCalled();
expect(internals()["_shareExpired"]).toBe(true);
});
it("still shares a window with 31 seconds left", async () => {
shareMatterDeviceExternal.mockResolvedValue({});
await openedWindow();
internals()["_windowOpenedAt"] = Date.now() - 269_000;
await share();
expect(shareMatterDeviceExternal.mock.calls[0][1].remaining_seconds).toBe(
31
);
});
it("opens a new window in place once the old one has run out", async () => {
shareMatterDeviceExternal.mockRejectedValue({ code: "failed" });
await openedWindow();
await share();
vi.advanceTimersByTime(300_000);
const fresh = { ...window_, setup_manual_code: "11111111111" };
openMatterCommissioningWindow.mockResolvedValue(fresh);
await start();
expect(openMatterCommissioningWindow).toHaveBeenCalledTimes(2);
expect(internals()["_shareFailed"]).toBe(false);
expect(internals()["_shareExpired"]).toBe(false);
expect(commissionParams()).toBe(fresh);
});
describe("expiry", () => {
it("marks the code expired 30 seconds before the window ends, without a tap", async () => {
await openedWindow();
vi.advanceTimersByTime(269_000);
expect(internals()["_shareExpired"]).toBe(false);
vi.advanceTimersByTime(1_000);
expect(internals()["_shareExpired"]).toBe(true);
expect(shareMatterDeviceExternal).not.toHaveBeenCalled();
});
it("times a new window from its own start", async () => {
await openedWindow();
vi.advanceTimersByTime(200_000);
await start();
vi.advanceTimersByTime(269_000);
expect(internals()["_shareExpired"]).toBe(false);
vi.advanceTimersByTime(1_000);
expect(internals()["_shareExpired"]).toBe(true);
});
it("sets no clock when the server gives no timeout", async () => {
openMatterCommissioningWindow.mockResolvedValue({
...window_,
commissioning_timeout: null,
});
await dialog.showDialog({ device_id: "dev_a" });
await start();
expect(vi.getTimerCount()).toBe(0);
});
it("keeps the code while the app is still pairing on it", async () => {
// Built on call, so the 20 s run from the share, not from setup.
shareMatterDeviceExternal.mockImplementation(
() =>
new Promise((_resolve, reject) => {
setTimeout(() => reject({ code: "canceled" }), 20_000);
})
);
await openedWindow();
vi.advanceTimersByTime(260_000);
const pending = share();
vi.advanceTimersByTime(10_000);
expect(internals()["_shareExpired"]).toBe(true);
expect(internals()["_sharing"]).toBe(true);
await vi.advanceTimersByTimeAsync(10_000);
await pending;
expect(internals()["_sharing"]).toBe(false);
});
it("stops the clock when the dialog closes", async () => {
await openedWindow();
close();
vi.advanceTimersByTime(300_000);
expect(internals()["_shareExpired"]).toBe(false);
});
});
it("keeps the dialog open when the code is copied during a share", async () => {
shareMatterDeviceExternal.mockReturnValue(
new Promise(() => {
// never settles
})
);
await openedWindow();
void share();
await (internals()["_copyCode"] as () => Promise<void>)();
expect(internals()["_open"]).toBe(true);
expect(commissionParams()).toBe(window_);
});
it("sends once while a share is already in flight", async () => {
shareMatterDeviceExternal.mockReturnValue(
new Promise(() => {
// never settles
})
);
await openedWindow();
const first = share();
await share();
expect(shareMatterDeviceExternal).toHaveBeenCalledTimes(1);
void first;
});
});
});