diff --git a/package.json b/package.json
index 48258f74c1a2..d67d582af2a9 100644
--- a/package.json
+++ b/package.json
@@ -168,7 +168,7 @@
"@rollup/plugin-node-resolve": "15.2.3",
"@rollup/plugin-replace": "5.0.7",
"@types/babel__plugin-transform-runtime": "7.9.5",
- "@types/chromecast-caf-receiver": "6.0.15",
+ "@types/chromecast-caf-receiver": "6.0.16",
"@types/chromecast-caf-sender": "1.0.10",
"@types/color-name": "1.1.4",
"@types/glob": "8.1.0",
diff --git a/pyproject.toml b/pyproject.toml
index 28a3be951139..48b7f8fcea7c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
-version = "20240626.2"
+version = "20240627.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"
diff --git a/src/components/ha-qr-scanner.ts b/src/components/ha-qr-scanner.ts
index b65f9ba07aa1..b6fdef9e8984 100644
--- a/src/components/ha-qr-scanner.ts
+++ b/src/components/ha-qr-scanner.ts
@@ -1,72 +1,92 @@
import "@material/mwc-button/mwc-button";
-import "@material/mwc-list/mwc-list-item";
import { mdiCamera } from "@mdi/js";
-import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
+import { UnsubscribeFunc } from "home-assistant-js-websocket";
+import { css, html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type QrScanner from "qr-scanner";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { LocalizeFunc } from "../common/translations/localize";
+import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint";
+import { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-button-menu";
+import "./ha-list-item";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@customElement("ha-qr-scanner")
class HaQrScanner extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
@property({ attribute: false }) public localize!: LocalizeFunc;
+ @property() public description?: string;
+
+ @property({ attribute: "alternative_option_label" })
+ public alternativeOptionLabel?: string;
+
+ @property() public error?: string;
+
@state() private _cameras?: QrScanner.Camera[];
- @state() private _error?: string;
+ @state() private _manual = false;
private _qrScanner?: QrScanner;
private _qrNotFoundCount = 0;
- @query("video", true) private _video!: HTMLVideoElement;
+ private _removeListener?: UnsubscribeFunc;
- @query("#canvas-container", true) private _canvasContainer!: HTMLDivElement;
+ @query("video", true) private _video?: HTMLVideoElement;
+
+ @query("#canvas-container", true) private _canvasContainer?: HTMLDivElement;
@query("ha-textfield") private _manualInput?: HaTextField;
public disconnectedCallback(): void {
super.disconnectedCallback();
this._qrNotFoundCount = 0;
+ if (this._nativeBarcodeScanner) {
+ this._closeExternalScanner();
+ }
if (this._qrScanner) {
this._qrScanner.stop();
this._qrScanner.destroy();
this._qrScanner = undefined;
}
- while (this._canvasContainer.lastChild) {
+ while (this._canvasContainer?.lastChild) {
this._canvasContainer.removeChild(this._canvasContainer.lastChild);
}
}
public connectedCallback(): void {
super.connectedCallback();
- if (this.hasUpdated && navigator.mediaDevices) {
+ if (this.hasUpdated) {
this._loadQrScanner();
}
}
protected firstUpdated() {
- if (navigator.mediaDevices) {
- this._loadQrScanner();
- }
+ this._loadQrScanner();
}
protected updated(changedProps: PropertyValues) {
- if (changedProps.has("_error") && this._error) {
- fireEvent(this, "qr-code-error", { message: this._error });
+ if (changedProps.has("error") && this.error) {
+ alert(`error: ${this.error}`);
+ this._notifyExternalScanner(this.error);
}
}
- protected render(): TemplateResult {
- return html`${this._error
- ? html`${this._error}`
+ protected render() {
+ if (this._nativeBarcodeScanner && !this._manual) {
+ return nothing;
+ }
+
+ return html`${this.error
+ ? html`${this.error}`
: ""}
- ${navigator.mediaDevices
+ ${navigator.mediaDevices && !this._manual
? html`
${this._cameras && this._cameras.length > 1
@@ -80,21 +100,26 @@ class HaQrScanner extends LitElement {
>
${this._cameras!.map(
(camera) => html`
- ${camera.label}
+ ${camera.label}
+
`
)}
`
- : ""}
+ : nothing}
`
- : html`
- ${!window.isSecureContext
- ? this.localize("ui.components.qr-scanner.only_https_supported")
- : this.localize("ui.components.qr-scanner.not_supported")}
-
+ : html`${this._manual
+ ? nothing
+ : html`
+ ${!window.isSecureContext
+ ? this.localize(
+ "ui.components.qr-scanner.only_https_supported"
+ )
+ : this.localize("ui.components.qr-scanner.not_supported")}
+ `}
${this.localize("ui.components.qr-scanner.manual_input")}
- ${this.localize("ui.common.submit")}
+
+ ${this.localize("ui.common.submit")}
+
`}`;
}
+ private get _nativeBarcodeScanner(): boolean {
+ return Boolean(this.hass.auth.external?.config.hasBarCodeScanner);
+ }
+
private async _loadQrScanner() {
+ if (this._nativeBarcodeScanner) {
+ this._openExternalScanner();
+ return;
+ }
+ if (!navigator.mediaDevices) {
+ return;
+ }
const QrScanner = (await import("qr-scanner")).default;
if (!(await QrScanner.hasCamera())) {
- this._error = "No camera found";
+ this._reportError("No camera found");
return;
}
QrScanner.WORKER_PATH = "/static/js/qr-scanner-worker.min.js";
this._listCameras(QrScanner);
this._qrScanner = new QrScanner(
- this._video,
+ this._video!,
this._qrCodeScanned,
this._qrCodeError
);
// @ts-ignore
const canvas = this._qrScanner.$canvas;
- this._canvasContainer.appendChild(canvas);
+ this._canvasContainer!.appendChild(canvas);
canvas.style.display = "block";
try {
await this._qrScanner.start();
} catch (err: any) {
- this._error = err;
+ this._reportError(err);
}
}
@@ -140,16 +176,16 @@ class HaQrScanner extends LitElement {
if (err === "No QR code found") {
this._qrNotFoundCount++;
if (this._qrNotFoundCount === 250) {
- this._error = err;
+ this._reportError(err);
}
return;
}
- this._error = err.message || err;
+ this._reportError(err.message || err);
// eslint-disable-next-line no-console
console.log(err);
};
- private _qrCodeScanned = async (qrCodeString: string): Promise => {
+ private _qrCodeScanned = (qrCodeString: string): void => {
this._qrNotFoundCount = 0;
fireEvent(this, "qr-code-scanned", { value: qrCodeString });
};
@@ -175,6 +211,62 @@ class HaQrScanner extends LitElement {
this._qrScanner?.setCamera((ev.target as any).value);
}
+ private _openExternalScanner() {
+ this._removeListener = addExternalBarCodeListener((msg) => {
+ if (msg.command === "bar_code/scan_result") {
+ if (msg.payload.format !== "qr_code") {
+ this._notifyExternalScanner(
+ `Wrong barcode scanned! ${msg.payload.format}: ${msg.payload.rawValue}, we need a QR code.`
+ );
+ } else {
+ this._qrCodeScanned(msg.payload.rawValue);
+ }
+ } else if (msg.command === "bar_code/aborted") {
+ this._closeExternalScanner();
+ if (msg.payload.reason === "canceled") {
+ fireEvent(this, "qr-code-closed");
+ } else {
+ this._manual = true;
+ }
+ }
+ return true;
+ });
+ this.hass.auth.external!.fireMessage({
+ type: "bar_code/scan",
+ payload: {
+ title: this.title || "Scan QR code",
+ description: this.description || "Scan a barcode.",
+ alternative_option_label:
+ this.alternativeOptionLabel || "Click to manually enter the barcode",
+ },
+ });
+ }
+
+ private _closeExternalScanner() {
+ this._removeListener?.();
+ this._removeListener = undefined;
+ this.hass.auth.external!.fireMessage({
+ type: "bar_code/close",
+ });
+ }
+
+ private _notifyExternalScanner(message: string) {
+ if (!this.hass.auth.external) {
+ return;
+ }
+ this.hass.auth.external.fireMessage({
+ type: "bar_code/notify",
+ payload: {
+ message,
+ },
+ });
+ this.error = undefined;
+ }
+
+ private _reportError(message: string) {
+ fireEvent(this, "qr-code-error", { message });
+ }
+
static styles = css`
canvas {
width: 100%;
@@ -210,6 +302,7 @@ declare global {
interface HASSDomEvents {
"qr-code-scanned": { value: string };
"qr-code-error": { message: string };
+ "qr-code-closed": undefined;
}
interface HTMLElementTagNameMap {
diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts
index 7690c387adfd..47230ff88d11 100644
--- a/src/components/ha-selector/ha-selector-area.ts
+++ b/src/components/ha-selector/ha-selector-area.ts
@@ -11,6 +11,7 @@ import {
fetchEntitySourcesWithCache,
} from "../../data/entity_sources";
import type { AreaSelector } from "../../data/selector";
+import { ConfigEntry, getConfigEntries } from "../../data/config_entries";
import {
filterSelectorDevices,
filterSelectorEntities,
@@ -37,6 +38,8 @@ export class HaAreaSelector extends LitElement {
@state() private _entitySources?: EntitySources;
+ @state() private _configEntries?: ConfigEntry[];
+
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
private _hasIntegration(selector: AreaSelector) {
@@ -72,6 +75,12 @@ export class HaAreaSelector extends LitElement {
this._entitySources = sources;
});
}
+ if (!this._configEntries && this._hasIntegration(this.selector)) {
+ this._configEntries = [];
+ getConfigEntries(this.hass).then((entries) => {
+ this._configEntries = entries;
+ });
+ }
}
protected render() {
@@ -136,7 +145,9 @@ export class HaAreaSelector extends LitElement {
const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup(
this._entitySources,
- Object.values(this.hass.entities)
+ Object.values(this.hass.entities),
+ Object.values(this.hass.devices),
+ this._configEntries
)
: undefined;
diff --git a/src/components/ha-selector/ha-selector-device.ts b/src/components/ha-selector/ha-selector-device.ts
index b3ffebd4b667..0131b9b69854 100644
--- a/src/components/ha-selector/ha-selector-device.ts
+++ b/src/components/ha-selector/ha-selector-device.ts
@@ -11,6 +11,7 @@ import {
fetchEntitySourcesWithCache,
} from "../../data/entity_sources";
import type { DeviceSelector } from "../../data/selector";
+import { ConfigEntry, getConfigEntries } from "../../data/config_entries";
import {
filterSelectorDevices,
filterSelectorEntities,
@@ -27,6 +28,8 @@ export class HaDeviceSelector extends LitElement {
@state() private _entitySources?: EntitySources;
+ @state() private _configEntries?: ConfigEntry[];
+
@property() public value?: any;
@property() public label?: string;
@@ -75,6 +78,12 @@ export class HaDeviceSelector extends LitElement {
this._entitySources = sources;
});
}
+ if (!this._configEntries && this._hasIntegration(this.selector)) {
+ this._configEntries = [];
+ getConfigEntries(this.hass).then((entries) => {
+ this._configEntries = entries;
+ });
+ }
}
protected render() {
@@ -123,7 +132,9 @@ export class HaDeviceSelector extends LitElement {
const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup(
this._entitySources,
- Object.values(this.hass.entities)
+ Object.values(this.hass.entities),
+ Object.values(this.hass.devices),
+ this._configEntries
)
: undefined;
diff --git a/src/components/ha-selector/ha-selector-floor.ts b/src/components/ha-selector/ha-selector-floor.ts
index eac63f414edb..6d4d0d29c834 100644
--- a/src/components/ha-selector/ha-selector-floor.ts
+++ b/src/components/ha-selector/ha-selector-floor.ts
@@ -11,6 +11,7 @@ import {
fetchEntitySourcesWithCache,
} from "../../data/entity_sources";
import type { FloorSelector } from "../../data/selector";
+import { ConfigEntry, getConfigEntries } from "../../data/config_entries";
import {
filterSelectorDevices,
filterSelectorEntities,
@@ -37,6 +38,8 @@ export class HaFloorSelector extends LitElement {
@state() private _entitySources?: EntitySources;
+ @state() private _configEntries?: ConfigEntry[];
+
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
private _hasIntegration(selector: FloorSelector) {
@@ -72,6 +75,12 @@ export class HaFloorSelector extends LitElement {
this._entitySources = sources;
});
}
+ if (!this._configEntries && this._hasIntegration(this.selector)) {
+ this._configEntries = [];
+ getConfigEntries(this.hass).then((entries) => {
+ this._configEntries = entries;
+ });
+ }
}
protected render() {
@@ -136,7 +145,9 @@ export class HaFloorSelector extends LitElement {
const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup(
this._entitySources,
- Object.values(this.hass.entities)
+ Object.values(this.hass.entities),
+ Object.values(this.hass.devices),
+ this._configEntries
)
: undefined;
diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts
index 80e9a5b05373..90bd894e8a62 100644
--- a/src/data/device_registry.ts
+++ b/src/data/device_registry.ts
@@ -5,6 +5,7 @@ import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "./entity_registry";
+import { ConfigEntry } from "./config_entries";
import type { EntitySources } from "./entity_sources";
export {
@@ -142,9 +143,11 @@ export const getDeviceEntityDisplayLookup = (
export const getDeviceIntegrationLookup = (
entitySources: EntitySources,
- entities: EntityRegistryDisplayEntry[] | EntityRegistryEntry[]
-): Record => {
- const deviceIntegrations: Record = {};
+ entities: EntityRegistryDisplayEntry[] | EntityRegistryEntry[],
+ devices?: DeviceRegistryEntry[],
+ configEntries?: ConfigEntry[]
+): Record> => {
+ const deviceIntegrations: Record> = {};
for (const entity of entities) {
const source = entitySources[entity.entity_id];
@@ -152,10 +155,22 @@ export const getDeviceIntegrationLookup = (
continue;
}
- if (!deviceIntegrations[entity.device_id!]) {
- deviceIntegrations[entity.device_id!] = [];
+ deviceIntegrations[entity.device_id!] =
+ deviceIntegrations[entity.device_id!] || new Set();
+ deviceIntegrations[entity.device_id!].add(source.domain);
+ }
+ // Lookup devices that have no entities
+ if (devices && configEntries) {
+ for (const device of devices) {
+ for (const config_entry_id of device.config_entries) {
+ const entry = configEntries.find((e) => e.entry_id === config_entry_id);
+ if (entry?.domain) {
+ deviceIntegrations[device.id] =
+ deviceIntegrations[device.id] || new Set();
+ deviceIntegrations[device.id].add(entry.domain);
+ }
+ }
}
- deviceIntegrations[entity.device_id!].push(source.domain);
}
return deviceIntegrations;
};
diff --git a/src/data/selector.ts b/src/data/selector.ts
index bb90c792f48b..60b9e4973b1c 100644
--- a/src/data/selector.ts
+++ b/src/data/selector.ts
@@ -696,7 +696,7 @@ export const entityMeetsTargetSelector = (
export const filterSelectorDevices = (
filterDevice: DeviceSelectorFilter,
device: DeviceRegistryEntry,
- deviceIntegrationLookup?: Record | undefined
+ deviceIntegrationLookup?: Record> | undefined
): boolean => {
const {
manufacturer: filterManufacturer,
@@ -713,7 +713,7 @@ export const filterSelectorDevices = (
}
if (filterIntegration && deviceIntegrationLookup) {
- if (!deviceIntegrationLookup?.[device.id]?.includes(filterIntegration)) {
+ if (!deviceIntegrationLookup?.[device.id]?.has(filterIntegration)) {
return false;
}
}
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts
index 29c982bbfe7f..530073bb44b2 100644
--- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts
+++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-add-node.ts
@@ -205,14 +205,13 @@ class DialogZWaveJSAddNode extends LitElement {
Search device
`
: this._status === "qr_scan"
- ? html`${this._error
- ? html`${this._error}`
- : ""}
-
${this._supportsSmartStart
- ? html`