mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-26 23:54:13 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94b69033e5 | ||
|
|
8125bf54ee | ||
|
|
293351bceb | ||
|
|
0558fa58d4 | ||
|
|
040630ed8e |
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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]': {}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
+315
-100
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+366
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user