diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c7d3d8fa9c..a556ef94a7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -74,33 +74,11 @@ jobs: version=$(echo "${{ github.ref }}" | awk -F"/" '{print $NF}' ) echo "home-assistant-frontend==$version" > ./requirements.txt - - name: Upload requirements.txt - uses: actions/upload-artifact@v2 - with: - name: requirements - path: ./requirements.txt - - build-wheels: - name: Build wheels for ${{ matrix.arch }} - needs: wheels-init - runs-on: ubuntu-latest - strategy: - matrix: - arch: ["aarch64", "armhf", "armv7", "amd64", "i386"] - tag: - - "3.9-alpine3.14" - steps: - - name: Download requirements.txt - uses: actions/download-artifact@v2 - with: - name: requirements - - name: Build wheels - uses: home-assistant/wheels@master + uses: home-assistant/wheels@2022.06.7 with: - tag: ${{ matrix.tag }} - arch: ${{ matrix.arch }} - wheels-host: ${{ secrets.WHEELS_HOST }} + abi: cp310 + tag: musllinux_1_2 + arch: amd64 wheels-key: ${{ secrets.WHEELS_KEY }} - wheels-user: wheels requirements: "requirements.txt" diff --git a/build-scripts/gulp/gen-icons-json.js b/build-scripts/gulp/gen-icons-json.js index 268ccb0ce5..8a69ba97af 100644 --- a/build-scripts/gulp/gen-icons-json.js +++ b/build-scripts/gulp/gen-icons-json.js @@ -156,3 +156,12 @@ gulp.task("gen-icons-json", (done) => { done(); }); + +gulp.task("gen-dummy-icons-json", (done) => { + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + fs.writeFileSync(path.resolve(OUTPUT_DIR, "iconList.json"), "[]"); + done(); +}); diff --git a/build-scripts/gulp/hassio.js b/build-scripts/gulp/hassio.js index 08370ad7c6..5b5e31650d 100644 --- a/build-scripts/gulp/hassio.js +++ b/build-scripts/gulp/hassio.js @@ -9,6 +9,7 @@ require("./compress.js"); require("./rollup.js"); require("./gather-static.js"); require("./translations.js"); +require("./gen-icons-json.js"); gulp.task( "develop-hassio", @@ -17,6 +18,7 @@ gulp.task( process.env.NODE_ENV = "development"; }, "clean-hassio", + "gen-dummy-icons-json", "gen-index-hassio-dev", "build-supervisor-translations", "copy-translations-supervisor", @@ -33,6 +35,7 @@ gulp.task( process.env.NODE_ENV = "production"; }, "clean-hassio", + "gen-dummy-icons-json", "build-supervisor-translations", "copy-translations-supervisor", "build-locale-data", diff --git a/demo/src/configs/kernehed/entities.ts b/demo/src/configs/kernehed/entities.ts index 53d6b74524..f7020ee719 100644 --- a/demo/src/configs/kernehed/entities.ts +++ b/demo/src/configs/kernehed/entities.ts @@ -59,7 +59,7 @@ export const demoEntitiesKernehed: DemoConfig["entities"] = () => attributes: { hidden: true, radius: 50, - friendly_name: "Skolan", + friendly_name: "School", icon: "mdi:school", }, }, @@ -137,7 +137,7 @@ export const demoEntitiesKernehed: DemoConfig["entities"] = () => state: "73", attributes: { unit_of_measurement: "%", - friendly_name: "oskar batteri", + friendly_name: "Oskar battery", device_class: "battery", }, }, @@ -146,7 +146,7 @@ export const demoEntitiesKernehed: DemoConfig["entities"] = () => state: "88", attributes: { unit_of_measurement: "%", - friendly_name: "bella batteri", + friendly_name: "Bella battery", device_class: "battery", }, }, @@ -154,7 +154,7 @@ export const demoEntitiesKernehed: DemoConfig["entities"] = () => entity_id: "binary_sensor.unifi_camera", state: "off", attributes: { - friendly_name: "R\u00f6relsesensor kamera", + friendly_name: "Motion sensor camera", icon: "mdi:walk", }, }, @@ -707,7 +707,7 @@ export const demoEntitiesKernehed: DemoConfig["entities"] = () => }, ], cloudiness: 25, - friendly_name: "V\u00e4der", + friendly_name: "Weather", }, }, "binary_sensor.ubiquiti_switch": { @@ -731,7 +731,7 @@ export const demoEntitiesKernehed: DemoConfig["entities"] = () => round_trip_time_max: "0.626", round_trip_time_mdev: "", round_trip_time_min: "0.358", - friendly_name: "Entr\u00e9 kamera", + friendly_name: "Entrance camera", device_class: "connectivity", icon: "mdi:cctv", }, @@ -807,7 +807,7 @@ export const demoEntitiesKernehed: DemoConfig["entities"] = () => attributes: { battery_level: 88, on: true, - friendly_name: "Altand\u00f6rren sensor", + friendly_name: "Back door sensor", device_class: "opening", icon: "mdi:door", }, @@ -841,7 +841,7 @@ export const demoEntitiesKernehed: DemoConfig["entities"] = () => battery_level: 60, on: true, dark: true, - friendly_name: "R\u00f6relsesensor tv\u00e4ttstugan", + friendly_name: "Laundy room motion sensor", device_class: "motion", icon: "mdi:walk", }, diff --git a/demo/src/stubs/config.ts b/demo/src/stubs/config.ts index c51700fb1d..f77c8d3b09 100644 --- a/demo/src/stubs/config.ts +++ b/demo/src/stubs/config.ts @@ -1,7 +1,7 @@ import { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; export const mockConfig = (hass: MockHomeAssistant) => { - hass.mockAPI("config/config_entries/entry", () => [ + hass.mockAPI("config/config_entries/entry?domain=co2signal", () => [ { entry_id: "co2signal", domain: "co2signal", diff --git a/demo/src/stubs/history.ts b/demo/src/stubs/history.ts index d843c942ac..2e2c507ccc 100644 --- a/demo/src/stubs/history.ts +++ b/demo/src/stubs/history.ts @@ -466,6 +466,7 @@ export const mockHistory = (mockHass: MockHomeAssistant) => { return results; } ); + mockHass.mockWS("recorder/get_statistics_metadata", () => []); mockHass.mockWS("history/list_statistic_ids", () => []); mockHass.mockWS( "history/statistics_during_period", diff --git a/gallery/public/images/clearspace.png b/gallery/public/images/clearspace.png index ffd1aa5f62..fe7c1e7824 100644 Binary files a/gallery/public/images/clearspace.png and b/gallery/public/images/clearspace.png differ diff --git a/gallery/public/images/logo-variants.png b/gallery/public/images/logo-variants.png index 2a35152ce5..d8c17f42a4 100644 Binary files a/gallery/public/images/logo-variants.png and b/gallery/public/images/logo-variants.png differ diff --git a/gallery/public/images/logo-with-text.png b/gallery/public/images/logo-with-text.png index c47eaa7112..82f6836d10 100644 Binary files a/gallery/public/images/logo-with-text.png and b/gallery/public/images/logo-with-text.png differ diff --git a/gallery/public/images/logo.png b/gallery/public/images/logo.png index e3faf04c5a..b62952edde 100644 Binary files a/gallery/public/images/logo.png and b/gallery/public/images/logo.png differ diff --git a/gallery/public/images/sunflowers.jpg b/gallery/public/images/sunflowers.jpg index 961d2a7bf3..f2353eb71f 100644 Binary files a/gallery/public/images/sunflowers.jpg and b/gallery/public/images/sunflowers.jpg differ diff --git a/gallery/public/images/using-our-logo.png b/gallery/public/images/using-our-logo.png index 0199a37b64..6f264b3bfb 100644 Binary files a/gallery/public/images/using-our-logo.png and b/gallery/public/images/using-our-logo.png differ diff --git a/hassio/src/addon-store/hassio-addon-repository.ts b/hassio/src/addon-store/hassio-addon-repository.ts index 8d55dbcb8c..56c9ecaadf 100644 --- a/hassio/src/addon-store/hassio-addon-repository.ts +++ b/hassio/src/addon-store/hassio-addon-repository.ts @@ -6,10 +6,8 @@ import { atLeastVersion } from "../../../src/common/config/version"; import { navigate } from "../../../src/common/navigate"; import { caseInsensitiveStringCompare } from "../../../src/common/string/compare"; import "../../../src/components/ha-card"; -import { - HassioAddonInfo, - HassioAddonRepository, -} from "../../../src/data/hassio/addon"; +import { HassioAddonRepository } from "../../../src/data/hassio/addon"; +import { StoreAddon } from "../../../src/data/supervisor/store"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; import { HomeAssistant } from "../../../src/types"; import "../components/hassio-card-content"; @@ -23,20 +21,16 @@ class HassioAddonRepositoryEl extends LitElement { @property({ attribute: false }) public repo!: HassioAddonRepository; - @property({ attribute: false }) public addons!: HassioAddonInfo[]; + @property({ attribute: false }) public addons!: StoreAddon[]; @property() public filter!: string; - private _getAddons = memoizeOne( - (addons: HassioAddonInfo[], filter?: string) => { - if (filter) { - return filterAndSort(addons, filter); - } - return addons.sort((a, b) => - caseInsensitiveStringCompare(a.name, b.name) - ); + private _getAddons = memoizeOne((addons: StoreAddon[], filter?: string) => { + if (filter) { + return filterAndSort(addons, filter); } - ); + return addons.sort((a, b) => caseInsensitiveStringCompare(a.name, b.name)); + }); protected render(): TemplateResult { const repo = this.repo; diff --git a/hassio/src/addon-store/hassio-addon-store.ts b/hassio/src/addon-store/hassio-addon-store.ts index 4d1f5ba1bf..2c373a4e9f 100644 --- a/hassio/src/addon-store/hassio-addon-store.ts +++ b/hassio/src/addon-store/hassio-addon-store.ts @@ -14,15 +14,15 @@ import memoizeOne from "memoize-one"; import { atLeastVersion } from "../../../src/common/config/version"; import { fireEvent } from "../../../src/common/dom/fire_event"; import { navigate } from "../../../src/common/navigate"; -import "../../../src/components/search-input"; import { extractSearchParam } from "../../../src/common/url/search-params"; import "../../../src/components/ha-button-menu"; import "../../../src/components/ha-icon-button"; +import "../../../src/components/search-input"; import { - HassioAddonInfo, HassioAddonRepository, reloadHassioAddons, } from "../../../src/data/hassio/addon"; +import { StoreAddon } from "../../../src/data/supervisor/store"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; import "../../../src/layouts/hass-loading-screen"; import "../../../src/layouts/hass-subpage"; @@ -66,10 +66,10 @@ class HassioAddonStore extends LitElement { protected render(): TemplateResult { let repos: TemplateResult[] = []; - if (this.supervisor.addon.repositories) { + if (this.supervisor.store.repositories) { repos = this.addonRepositories( - this.supervisor.addon.repositories, - this.supervisor.addon.addons, + this.supervisor.store.repositories, + this.supervisor.store.addons, this._filter ); } @@ -145,7 +145,7 @@ class HassioAddonStore extends LitElement { private addonRepositories = memoizeOne( ( repositories: HassioAddonRepository[], - addons: HassioAddonInfo[], + addons: StoreAddon[], filter?: string ) => repositories.sort(sortRepos).map((repo) => { diff --git a/hassio/src/addon-view/hassio-addon-dashboard.ts b/hassio/src/addon-view/hassio-addon-dashboard.ts index 6f4580870f..98a6c8a843 100644 --- a/hassio/src/addon-view/hassio-addon-dashboard.ts +++ b/hassio/src/addon-view/hassio-addon-dashboard.ts @@ -12,15 +12,17 @@ import { navigate } from "../../../src/common/navigate"; import { extractSearchParam } from "../../../src/common/url/search-params"; import "../../../src/components/ha-circular-progress"; import { + fetchAddonInfo, fetchHassioAddonInfo, fetchHassioAddonsInfo, HassioAddonDetails, } from "../../../src/data/hassio/addon"; import { extractApiErrorMessage } from "../../../src/data/hassio/common"; import { - fetchHassioSupervisorInfo, - setSupervisorOption, -} from "../../../src/data/hassio/supervisor"; + addStoreRepository, + fetchSupervisorStore, + StoreAddonDetails, +} from "../../../src/data/supervisor/store"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; import { showConfirmationDialog } from "../../../src/dialogs/generic/show-dialog-box"; import "../../../src/layouts/hass-error-screen"; @@ -45,7 +47,9 @@ class HassioAddonDashboard extends LitElement { @property({ attribute: false }) public route!: Route; - @property({ attribute: false }) public addon?: HassioAddonDetails; + @property({ attribute: false }) public addon?: + | HassioAddonDetails + | StoreAddonDetails; @property({ type: Boolean }) public narrow!: boolean; @@ -173,10 +177,10 @@ class HassioAddonDashboard extends LitElement { const requestedAddon = extractSearchParam("addon"); const requestedAddonRepository = extractSearchParam("repository_url"); if (requestedAddonRepository) { - const supervisorInfo = await fetchHassioSupervisorInfo(this.hass); + const storeInfo = await fetchSupervisorStore(this.hass); if ( - !supervisorInfo.addons_repositories.find( - (repo) => repo === requestedAddonRepository + !storeInfo.repositories.find( + (repo) => repo.source === requestedAddonRepository ) ) { if ( @@ -197,12 +201,7 @@ class HassioAddonDashboard extends LitElement { } try { - await setSupervisorOption(this.hass, { - addons_repositories: [ - ...supervisorInfo.addons_repositories, - requestedAddonRepository, - ], - }); + await addStoreRepository(this.hass, requestedAddonRepository); } catch (err: any) { this._error = extractApiErrorMessage(err); } @@ -245,6 +244,8 @@ class HassioAddonDashboard extends LitElement { if (path === "uninstall") { window.history.back(); + } else if (path === "install") { + this.addon = await fetchHassioAddonInfo(this.hass, this.addon!.slug); } else { await this._routeDataChanged(); } @@ -262,8 +263,7 @@ class HassioAddonDashboard extends LitElement { return; } try { - const addoninfo = await fetchHassioAddonInfo(this.hass, addon); - this.addon = addoninfo; + this.addon = await fetchAddonInfo(this.hass, this.supervisor, addon); } catch (err: any) { this._error = `Error fetching addon info: ${extractApiErrorMessage(err)}`; this.addon = undefined; diff --git a/hassio/src/addon-view/hassio-addon-router.ts b/hassio/src/addon-view/hassio-addon-router.ts index fe0bad9c00..66cf1fb8af 100644 --- a/hassio/src/addon-view/hassio-addon-router.ts +++ b/hassio/src/addon-view/hassio-addon-router.ts @@ -1,5 +1,6 @@ import { customElement, property } from "lit/decorators"; import { HassioAddonDetails } from "../../../src/data/hassio/addon"; +import { StoreAddonDetails } from "../../../src/data/supervisor/store"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; import { HassRouterPage, @@ -20,7 +21,9 @@ class HassioAddonRouter extends HassRouterPage { @property({ attribute: false }) public supervisor!: Supervisor; - @property({ attribute: false }) public addon!: HassioAddonDetails; + @property({ attribute: false }) public addon!: + | HassioAddonDetails + | StoreAddonDetails; protected routerOptions: RouterOptions = { defaultPage: "info", diff --git a/hassio/src/addon-view/info/hassio-addon-info.ts b/hassio/src/addon-view/info/hassio-addon-info.ts index 71a0a6f225..6675d7fd55 100644 --- a/hassio/src/addon-view/info/hassio-addon-info.ts +++ b/hassio/src/addon-view/info/hassio-addon-info.ts @@ -59,7 +59,10 @@ import { fetchHassioStats, HassioStats, } from "../../../../src/data/hassio/common"; -import { StoreAddon } from "../../../../src/data/supervisor/store"; +import { + StoreAddon, + StoreAddonDetails, +} from "../../../../src/data/supervisor/store"; import { Supervisor } from "../../../../src/data/supervisor/supervisor"; import { showAlertDialog, @@ -100,7 +103,9 @@ class HassioAddonInfo extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public addon!: HassioAddonDetails; + @property({ attribute: false }) public addon!: + | HassioAddonDetails + | StoreAddonDetails; @property({ attribute: false }) public supervisor!: Supervisor; @@ -143,7 +148,7 @@ class HassioAddonInfo extends LitElement { > ` : ""} - ${!this.addon.protected + ${"protected" in this.addon && !this.addon.protected ? html`
- ${this.addon.state === "started" + ${this.addon.version && this.addon.state === "started" ? html` ${this.supervisor.localize("addon.dashboard.hostname")} @@ -669,7 +674,7 @@ class HassioAddonInfo extends LitElement { } private async _loadData(): Promise { - if (this.addon.state === "started") { + if ("state" in this.addon && this.addon.state === "started") { this._metrics = await fetchHassioStats( this.hass, `addons/${this.addon.slug}` @@ -717,18 +722,22 @@ class HassioAddonInfo extends LitElement { } private get _computeIsRunning(): boolean { - return this.addon?.state === "started"; + return (this.addon as HassioAddonDetails)?.state === "started"; } private get _pathWebui(): string | null { - return ( - this.addon.webui && - this.addon.webui.replace("[HOST]", document.location.hostname) + return (this.addon as HassioAddonDetails).webui!.replace( + "[HOST]", + document.location.hostname ); } private get _computeShowWebUI(): boolean | "" | null { - return !this.addon.ingress && this.addon.webui && this._computeIsRunning; + return ( + !this.addon.ingress && + (this.addon as HassioAddonDetails).webui && + this._computeIsRunning + ); } private _openIngress(): void { @@ -754,7 +763,8 @@ class HassioAddonInfo extends LitElement { private async _startOnBootToggled(): Promise { this._error = undefined; const data: HassioAddonSetOptionParams = { - boot: this.addon.boot === "auto" ? "manual" : "auto", + boot: + (this.addon as HassioAddonDetails).boot === "auto" ? "manual" : "auto", }; try { await setHassioAddonOption(this.hass, this.addon.slug, data); @@ -776,7 +786,7 @@ class HassioAddonInfo extends LitElement { private async _watchdogToggled(): Promise { this._error = undefined; const data: HassioAddonSetOptionParams = { - watchdog: !this.addon.watchdog, + watchdog: !(this.addon as HassioAddonDetails).watchdog, }; try { await setHassioAddonOption(this.hass, this.addon.slug, data); @@ -798,7 +808,7 @@ class HassioAddonInfo extends LitElement { private async _autoUpdateToggled(): Promise { this._error = undefined; const data: HassioAddonSetOptionParams = { - auto_update: !this.addon.auto_update, + auto_update: !(this.addon as HassioAddonDetails).auto_update, }; try { await setHassioAddonOption(this.hass, this.addon.slug, data); @@ -820,7 +830,7 @@ class HassioAddonInfo extends LitElement { private async _protectionToggled(): Promise { this._error = undefined; const data: HassioAddonSetSecurityParams = { - protected: !this.addon.protected, + protected: !(this.addon as HassioAddonDetails).protected, }; try { await setHassioAddonSecurity(this.hass, this.addon.slug, data); @@ -842,7 +852,7 @@ class HassioAddonInfo extends LitElement { private async _panelToggled(): Promise { this._error = undefined; const data: HassioAddonSetOptionParams = { - ingress_panel: !this.addon.ingress_panel, + ingress_panel: !(this.addon as HassioAddonDetails).ingress_panel, }; try { await setHassioAddonOption(this.hass, this.addon.slug, data); @@ -870,7 +880,7 @@ class HassioAddonInfo extends LitElement { showHassioMarkdownDialog(this, { title: this.supervisor.localize("addon.dashboard.changelog"), - content: extractChangelog(this.addon, content), + content: extractChangelog(this.addon as HassioAddonDetails, content), }); } catch (err: any) { showAlertDialog(this, { diff --git a/hassio/src/backups/hassio-backups.ts b/hassio/src/backups/hassio-backups.ts index c8e8e5e30c..99d2869f8b 100644 --- a/hassio/src/backups/hassio-backups.ts +++ b/hassio/src/backups/hassio-backups.ts @@ -98,9 +98,8 @@ export class HassioBackups extends LitElement { if (backup.content.addons.length !== 0) { for (const addon of backup.content.addons) { content.push( - this.supervisor.supervisor.addons.find( - (entry) => entry.slug === addon - )?.name || addon + this.supervisor.addon.addons.find((entry) => entry.slug === addon) + ?.name || addon ); } } diff --git a/hassio/src/components/hassio-filter-addons.ts b/hassio/src/components/hassio-filter-addons.ts index ddb56188aa..6daa1ddbad 100644 --- a/hassio/src/components/hassio-filter-addons.ts +++ b/hassio/src/components/hassio-filter-addons.ts @@ -1,8 +1,8 @@ import Fuse from "fuse.js"; -import { HassioAddonInfo } from "../../../src/data/hassio/addon"; +import { StoreAddon } from "../../../src/data/supervisor/store"; -export function filterAndSort(addons: HassioAddonInfo[], filter: string) { - const options: Fuse.IFuseOptions = { +export function filterAndSort(addons: StoreAddon[], filter: string) { + const options: Fuse.IFuseOptions = { keys: ["name", "description", "slug"], isCaseSensitive: false, minMatchCharLength: 2, diff --git a/hassio/src/components/supervisor-backup-content.ts b/hassio/src/components/supervisor-backup-content.ts index 45faee620f..dd5c38a178 100644 --- a/hassio/src/components/supervisor-backup-content.ts +++ b/hassio/src/components/supervisor-backup-content.ts @@ -96,7 +96,7 @@ export class SupervisorBackupContent extends LitElement { : ["ssl", "share", "media", "addons/local"] ); this.addons = _computeAddons( - this.backup ? this.backup.addons : this.supervisor?.supervisor.addons + this.backup ? this.backup.addons : this.supervisor?.addon.addons ); this.backupType = this.backup?.type || "full"; this.backupName = this.backup?.name || ""; diff --git a/hassio/src/dashboard/hassio-addons.ts b/hassio/src/dashboard/hassio-addons.ts index 4e685ef361..cc855ccc69 100644 --- a/hassio/src/dashboard/hassio-addons.ts +++ b/hassio/src/dashboard/hassio-addons.ts @@ -24,7 +24,7 @@ class HassioAddons extends LitElement { ? html`

${this.supervisor.localize("dashboard.addons")}

` : ""}
- ${!this.supervisor.supervisor.addons?.length + ${!this.supervisor.addon.addons.length ? html`
@@ -34,7 +34,7 @@ class HassioAddons extends LitElement {
` - : this.supervisor.supervisor.addons + : this.supervisor.addon.addons .sort((a, b) => caseInsensitiveStringCompare(a.name, b.name)) .map( (addon) => html` diff --git a/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts b/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts index d42cb05a4b..82325db788 100644 --- a/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts +++ b/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts @@ -15,15 +15,18 @@ import "../../../../src/components/ha-circular-progress"; import { createCloseHeading } from "../../../../src/components/ha-dialog"; import "../../../../src/components/ha-icon-button"; import { - fetchHassioAddonsInfo, HassioAddonInfo, HassioAddonRepository, } from "../../../../src/data/hassio/addon"; import { extractApiErrorMessage } from "../../../../src/data/hassio/common"; -import { setSupervisorOption } from "../../../../src/data/hassio/supervisor"; import { haStyle, haStyleDialog } from "../../../../src/resources/styles"; import type { HomeAssistant } from "../../../../src/types"; import { HassioRepositoryDialogParams } from "./show-dialog-repositories"; +import { + addStoreRepository, + fetchStoreRepositories, + removeStoreRepository, +} from "../../../../src/data/supervisor/store"; @customElement("dialog-hassio-repositories") class HassioRepositoriesDialog extends LitElement { @@ -58,7 +61,13 @@ class HassioRepositoriesDialog extends LitElement { private _filteredRepositories = memoizeOne((repos: HassioAddonRepository[]) => repos - .filter((repo) => repo.slug !== "core" && repo.slug !== "local") + .filter( + (repo) => + repo.slug !== "core" && // The core add-ons repository + repo.slug !== "local" && // Locally managed add-ons + repo.slug !== "a0d7b954" && // Home Assistant Community Add-ons + repo.slug !== "5c53de3b" // The ESPHome repository + ) .sort((a, b) => caseInsensitiveStringCompare(a.name, b.name)) ); @@ -78,7 +87,7 @@ class HassioRepositoriesDialog extends LitElement { const repositories = this._filteredRepositories(this._repositories); const usedRepositories = this._filteredUsedRepositories( repositories, - this._dialogParams.supervisor.supervisor.addons + this._dialogParams.supervisor.addon.addons ); return html` { try { - const addonsinfo = await fetchHassioAddonsInfo(this.hass); - - this._repositories = addonsinfo.repositories; + this._repositories = await fetchStoreRepositories(this.hass); fireEvent(this, "supervisor-collection-refresh", { collection: "addon" }); } catch (err: any) { @@ -231,14 +238,9 @@ class HassioRepositoriesDialog extends LitElement { return; } this._processing = true; - const repositories = this._filteredRepositories(this._repositories!); - const newRepositories = repositories.map((repo) => repo.source); - newRepositories.push(input.value); try { - await setSupervisorOption(this.hass, { - addons_repositories: newRepositories, - }); + await addStoreRepository(this.hass, input.value); await this._loadData(); input.value = ""; @@ -250,19 +252,8 @@ class HassioRepositoriesDialog extends LitElement { private async _removeRepository(ev: Event) { const slug = (ev.currentTarget as any).slug; - const repositories = this._filteredRepositories(this._repositories!); - const repository = repositories.find((repo) => repo.slug === slug); - if (!repository) { - return; - } - const newRepositories = repositories - .map((repo) => repo.source) - .filter((repo) => repo !== repository.source); - try { - await setSupervisorOption(this.hass, { - addons_repositories: newRepositories, - }); + await removeStoreRepository(this.hass, slug); await this._loadData(); } catch (err: any) { this._error = extractApiErrorMessage(err); diff --git a/pyproject.toml b/pyproject.toml index 7ece90691f..62a5a7f846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20220601.0" +version = "20220629.0" license = {text = "Apache-2.0"} description = "The Home Assistant frontend" readme = "README.md" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000000..e14c0e7b08 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +# Setuptools v62.3 doesn't support editable installs with just 'pyproject.toml' (PEP 660). +# Keep this file until it does! diff --git a/src/common/entity/domain_icon.ts b/src/common/entity/domain_icon.ts index 498b882a4e..82c10c6b48 100644 --- a/src/common/entity/domain_icon.ts +++ b/src/common/entity/domain_icon.ts @@ -8,6 +8,7 @@ import { mdiCalendar, mdiCast, mdiCastConnected, + mdiChartSankey, mdiCheckCircleOutline, mdiClock, mdiCloseCircleOutline, @@ -24,6 +25,7 @@ import { mdiPowerPlug, mdiPowerPlugOff, mdiRestart, + mdiSwapHorizontal, mdiToggleSwitchVariant, mdiToggleSwitchVariantOff, mdiWeatherNight, @@ -153,6 +155,12 @@ export const domainIconWithoutDefault = ( ? FIXED_DOMAIN_ICONS[domain] : mdiWeatherNight; + case "switch_as_x": + return mdiSwapHorizontal; + + case "threshold": + return mdiChartSankey; + case "update": return compareState === "on" ? updateIsInstalling(stateObj as UpdateEntity) diff --git a/src/common/number/clamp.ts b/src/common/number/clamp.ts index 3b2488afe8..5591885f2e 100644 --- a/src/common/number/clamp.ts +++ b/src/common/number/clamp.ts @@ -5,6 +5,6 @@ export const clamp = (value: number, min: number, max: number) => export const conditionalClamp = (value: number, min?: number, max?: number) => { let result: number; result = min ? Math.max(value, min) : value; - result = max ? Math.min(value, max) : value; + result = max ? Math.min(result, max) : result; return result; }; diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index 170d933463..84c4132849 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -11,6 +11,8 @@ import { classMap } from "lit/directives/class-map"; import { styleMap } from "lit/directives/style-map"; import { clamp } from "../../common/number/clamp"; +export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; + interface Tooltip extends TooltipModel { top: string; left: string; @@ -324,6 +326,9 @@ export default class HaChartBase extends LitElement { width: 16px; flex-shrink: 0; box-sizing: border-box; + margin-inline-end: 6px; + margin-inline-start: initial; + direction: var(--direction); } .chartTooltip .bullet { align-self: baseline; @@ -332,6 +337,9 @@ export default class HaChartBase extends LitElement { :host([rtl]) .chartTooltip .bullet { margin-right: inherit; margin-left: 6px; + margin-inline-end: inherit; + margin-inline-start: 6px; + direction: var(--direction); } .chartTooltip { padding: 8px; diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index f1ca3f4a19..85bb575985 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -8,7 +8,7 @@ import { } from "../../common/number/format_number"; import { LineChartEntity, LineChartState } from "../../data/history"; import { HomeAssistant } from "../../types"; -import "./ha-chart-base"; +import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; const safeParseFloat = (value) => { const parsed = parseFloat(value); @@ -34,6 +34,8 @@ class StateHistoryChartLine extends LitElement { @state() private _chartOptions?: ChartOptions; + private _chartTime: Date = new Date(); + protected render() { return html` { diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index 519ae60b5b..1f90bc36cd 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -9,7 +9,7 @@ import { numberFormatToLocale } from "../../common/number/format_number"; import { computeRTL } from "../../common/util/compute_rtl"; import { TimelineEntity } from "../../data/history"; import { HomeAssistant } from "../../types"; -import "./ha-chart-base"; +import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base"; import type { TimeLineData } from "./timeline-chart/const"; /** Binary sensor device classes for which the static colors for on/off are NOT inverted. @@ -103,6 +103,8 @@ export class StateHistoryChartTimeline extends LitElement { @state() private _chartOptions?: ChartOptions<"timeline">; + private _chartTime: Date = new Date(); + protected render() { return html` - stringCompare(a.name, b.name) - ); + const addonsInfo = await fetchHassioAddonsInfo(this.hass); + this._addons = addonsInfo.addons + .filter((addon) => addon.version) + .sort((a, b) => stringCompare(a.name, b.name)); } else { showAlertDialog(this, { title: this.hass.localize( diff --git a/src/components/ha-chip.ts b/src/components/ha-chip.ts index cb58ebb75d..88bfc8be3c 100644 --- a/src/components/ha-chip.ts +++ b/src/components/ha-chip.ts @@ -67,8 +67,7 @@ export class HaChip extends LitElement { color: var(--ha-chip-icon-color, var(--ha-chip-text-color)); } .mdc-chip.mdc-chip--selected .mdc-chip__checkmark, - .mdc-chip.no-text - .mdc-chip__icon--leading:not(.mdc-chip__icon--leading-hidden) { + .mdc-chip .mdc-chip__icon--leading:not(.mdc-chip__icon--leading-hidden) { margin-right: -4px; margin-inline-start: -4px; margin-inline-end: 4px; diff --git a/src/components/ha-clickable-list-item.ts b/src/components/ha-clickable-list-item.ts index 71f207a129..43175efd08 100644 --- a/src/components/ha-clickable-list-item.ts +++ b/src/components/ha-clickable-list-item.ts @@ -1,17 +1,13 @@ -import { ListItemBase } from "@material/mwc-list/mwc-list-item-base"; -import { styles } from "@material/mwc-list/mwc-list-item.css"; -import { css, CSSResult, html } from "lit"; +import { css, CSSResultGroup, html } from "lit"; import { customElement, property, query } from "lit/decorators"; +import { HaListItem } from "./ha-list-item"; @customElement("ha-clickable-list-item") -export class HaClickableListItem extends ListItemBase { +export class HaClickableListItem extends HaListItem { @property() public href?: string; @property({ type: Boolean }) public disableHref = false; - // property used only in css - @property({ type: Boolean, reflect: true }) public rtl = false; - @property({ type: Boolean, reflect: true }) public openNewTab = false; @query("a") private _anchor!: HTMLAnchorElement; @@ -39,18 +35,10 @@ export class HaClickableListItem extends ListItemBase { }); } - static get styles(): CSSResult[] { + static get styles(): CSSResultGroup { return [ - styles, + super.styles, css` - :host { - padding-left: 0px; - padding-right: 0px; - } - :host([graphic="avatar"]:not([twoLine])), - :host([graphic="icon"]:not([twoLine])) { - height: 48px; - } a { width: 100%; height: 100%; @@ -60,19 +48,6 @@ export class HaClickableListItem extends ListItemBase { padding-right: var(--mdc-list-side-padding, 20px); overflow: hidden; } - span.material-icons:first-of-type { - margin-inline-start: 0px !important; - margin-inline-end: var( - --mdc-list-item-graphic-margin, - 16px - ) !important; - direction: var(--direction); - } - span.material-icons:last-of-type { - margin-inline-start: auto !important; - margin-inline-end: 0px !important; - direction: var(--direction); - } `, ]; } diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index 2ec8a2e627..ff1364a934 100644 --- a/src/components/ha-code-editor.ts +++ b/src/components/ha-code-editor.ts @@ -11,6 +11,7 @@ import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { loadCodeMirror } from "../resources/codemirror.ondemand"; import { HomeAssistant } from "../types"; +import "./ha-icon"; declare global { interface HASSDomEvents { @@ -26,6 +27,12 @@ const saveKeyBinding: KeyBinding = { }, }; +const renderIcon = (completion: Completion) => { + const icon = document.createElement("ha-icon"); + icon.icon = completion.label; + return icon; +}; + @customElement("ha-code-editor") export class HaCodeEditor extends ReactiveElement { public codemirror?: EditorView; @@ -47,6 +54,8 @@ export class HaCodeEditor extends ReactiveElement { private _loadedCodeMirror?: typeof import("../resources/codemirror"); + private _iconList?: Completion[]; + public set value(value: string) { this._value = value; } @@ -154,7 +163,10 @@ export class HaCodeEditor extends ReactiveElement { if (!this.readOnly && this.autocompleteEntities && this.hass) { extensions.push( this._loadedCodeMirror.autocompletion({ - override: [this._entityCompletions.bind(this)], + override: [ + this._entityCompletions.bind(this), + this._mdiCompletions.bind(this), + ], maxRenderedOptions: 10, }) ); @@ -209,6 +221,47 @@ export class HaCodeEditor extends ReactiveElement { }; } + private _getIconItems = async (): Promise => { + if (!this._iconList) { + let iconList: { + name: string; + keywords: string[]; + }[]; + if (__SUPERVISOR__) { + iconList = []; + } else { + iconList = (await import("../../build/mdi/iconList.json")).default; + } + + this._iconList = iconList.map((icon) => ({ + type: "variable", + label: `mdi:${icon.name}`, + detail: icon.keywords.join(", "), + info: renderIcon, + })); + } + + return this._iconList; + }; + + private async _mdiCompletions( + context: CompletionContext + ): Promise { + const match = context.matchBefore(/mdi:/); + + if (!match || (match.from === match.to && !context.explicit)) { + return null; + } + + const iconItems = await this._getIconItems(); + + return { + from: Number(match.from), + options: iconItems, + span: /^\w*.\w*$/, + }; + } + private _blockKeyboardShortcuts() { this.addEventListener("keydown", (ev) => ev.stopPropagation()); } diff --git a/src/components/ha-dialog.ts b/src/components/ha-dialog.ts index 64fcbc4846..346410aa44 100644 --- a/src/components/ha-dialog.ts +++ b/src/components/ha-dialog.ts @@ -91,6 +91,7 @@ export class HaDialog extends DialogBase { .header_title { margin-right: 40px; margin-inline-end: 40px; + margin-inline-start: initial; direction: var(--direction); } .header_button { diff --git a/src/components/ha-fab.ts b/src/components/ha-fab.ts index b36871e381..2432b9a20d 100644 --- a/src/components/ha-fab.ts +++ b/src/components/ha-fab.ts @@ -19,6 +19,14 @@ export class HaFab extends FabBase { direction: var(--direction); } `, + // safari workaround - must be explicit + document.dir === "rtl" + ? css` + :host .mdc-fab--extended .mdc-fab__icon { + direction: rtl; + } + ` + : css``, ]; } diff --git a/src/components/ha-form/ha-form-multi_select.ts b/src/components/ha-form/ha-form-multi_select.ts index a591f710bc..a25de38672 100644 --- a/src/components/ha-form/ha-form-multi_select.ts +++ b/src/components/ha-form/ha-form-multi_select.ts @@ -205,6 +205,9 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement { ha-formfield { display: block; padding-right: 16px; + padding-inline-end: 16px; + padding-inline-start: initial; + direction: var(--direction); } ha-textfield { display: block; @@ -216,6 +219,9 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement { right: 1em; top: 1em; cursor: pointer; + inset-inline-end: 1em; + inset-inline-start: initial; + direction: var(--direction); } :host([opened]) ha-svg-icon { color: var(--primary-color); diff --git a/src/components/ha-gauge.ts b/src/components/ha-gauge.ts index c3374d6b97..1d60da1b3c 100644 --- a/src/components/ha-gauge.ts +++ b/src/components/ha-gauge.ts @@ -14,6 +14,7 @@ const getAngle = (value: number, min: number, max: number) => { export interface LevelDefinition { level: number; stroke: string; + label?: string; } @customElement("ha-gauge") @@ -38,22 +39,31 @@ export class Gauge extends LitElement { @state() private _updated = false; + @state() private _segment_label? = ""; + protected firstUpdated(changedProperties: PropertyValues) { super.firstUpdated(changedProperties); // Wait for the first render for the initial animation to work afterNextRender(() => { this._updated = true; this._angle = getAngle(this.value, this.min, this.max); + this._segment_label = this.getSegmentLabel(); this._rescale_svg(); }); } protected updated(changedProperties: PropertyValues) { super.updated(changedProperties); - if (!this._updated || !changedProperties.has("value")) { + if ( + !this._updated || + (!changedProperties.has("value") && + !changedProperties.has("label") && + !changedProperties.has("_segment_label")) + ) { return; } this._angle = getAngle(this.value, this.min, this.max); + this._segment_label = this.getSegmentLabel(); this._rescale_svg(); } @@ -118,9 +128,11 @@ export class Gauge extends LitElement { - ${this.valueText || formatNumber(this.value, this.locale)} ${ - this.label - } + ${ + this._segment_label + ? this._segment_label + : this.valueText || formatNumber(this.value, this.locale) + } ${this._segment_label ? "" : this.label} `; } @@ -137,6 +149,18 @@ export class Gauge extends LitElement { ); } + private getSegmentLabel() { + if (this.levels) { + this.levels.sort((a, b) => a.level - b.level); + for (let i = this.levels.length - 1; i >= 0; i--) { + if (this.value >= this.levels[i].level) { + return this.levels[i].label; + } + } + } + return ""; + } + static get styles() { return css` :host { diff --git a/src/components/ha-list-item.ts b/src/components/ha-list-item.ts new file mode 100644 index 0000000000..df3a2e80ee --- /dev/null +++ b/src/components/ha-list-item.ts @@ -0,0 +1,42 @@ +import { ListItemBase } from "@material/mwc-list/mwc-list-item-base"; +import { styles } from "@material/mwc-list/mwc-list-item.css"; +import { css, CSSResultGroup } from "lit"; +import { customElement } from "lit/decorators"; + +@customElement("ha-list-item") +export class HaListItem extends ListItemBase { + static get styles(): CSSResultGroup { + return [ + styles, + css` + :host { + padding-left: var(--mdc-list-side-padding, 20px); + padding-right: var(--mdc-list-side-padding, 20px); + } + :host([graphic="avatar"]:not([twoLine])), + :host([graphic="icon"]:not([twoLine])) { + height: 48px; + } + span.material-icons:first-of-type { + margin-inline-start: 0px !important; + margin-inline-end: var( + --mdc-list-item-graphic-margin, + 16px + ) !important; + direction: var(--direction); + } + span.material-icons:last-of-type { + margin-inline-start: auto !important; + margin-inline-end: 0px !important; + direction: var(--direction); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-list-item": HaListItem; + } +} diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 1453afa07b..7545a19a36 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -79,6 +79,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { @property({ type: Boolean, reflect: true }) public disabled = false; + @property({ type: Boolean }) public horizontal = false; + @state() private _areas?: { [areaId: string]: AreaRegistryEntry }; @state() private _devices?: { @@ -117,45 +119,55 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { if (!this._areas || !this._devices || !this._entities) { return html``; } - return html`
- ${this.value?.area_id - ? ensureArray(this.value.area_id).map((area_id) => { - const area = this._areas![area_id]; - return this._renderChip( - "area_id", - area_id, - area?.name || area_id, - undefined, - mdiSofa - ); - }) - : ""} - ${this.value?.device_id - ? ensureArray(this.value.device_id).map((device_id) => { - const device = this._devices![device_id]; - return this._renderChip( - "device_id", - device_id, - device ? computeDeviceName(device, this.hass) : device_id, - undefined, - mdiDevices - ); - }) - : ""} - ${this.value?.entity_id - ? ensureArray(this.value.entity_id).map((entity_id) => { - const entity = this.hass.states[entity_id]; - return this._renderChip( - "entity_id", - entity_id, - entity ? computeStateName(entity) : entity_id, - entity - ); - }) - : ""} -
+ return html`
+ ${this.horizontal ? this._renderChips() : this._renderItems()} ${this._renderPicker()} -
+ ${this.horizontal ? this._renderItems() : this._renderChips()} +
`; + } + + private _renderItems() { + return html`
+ ${this.value?.area_id + ? ensureArray(this.value.area_id).map((area_id) => { + const area = this._areas![area_id]; + return this._renderChip( + "area_id", + area_id, + area?.name || area_id, + undefined, + mdiSofa + ); + }) + : ""} + ${this.value?.device_id + ? ensureArray(this.value.device_id).map((device_id) => { + const device = this._devices![device_id]; + return this._renderChip( + "device_id", + device_id, + device ? computeDeviceName(device, this.hass) : device_id, + undefined, + mdiDevices + ); + }) + : ""} + ${this.value?.entity_id + ? ensureArray(this.value.entity_id).map((entity_id) => { + const entity = this.hass.states[entity_id]; + return this._renderChip( + "entity_id", + entity_id, + entity ? computeStateName(entity) : entity_id, + entity + ); + }) + : ""} +
`; + } + + private _renderChips() { + return html`
- ${this.helper ? html`${this.helper}` : ""} `; @@ -321,6 +332,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .entityFilter=${this.entityRegFilter} .includeDeviceClasses=${this.includeDeviceClasses} .includeDomains=${this.includeDomains} + class=${this.horizontal ? "hidden-picker" : ""} @value-changed=${this._targetPicked} >`; case "device_id": @@ -335,6 +347,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .entityFilter=${this.entityRegFilter} .includeDeviceClasses=${this.includeDeviceClasses} .includeDomains=${this.includeDomains} + class=${this.horizontal ? "hidden-picker" : ""} @value-changed=${this._targetPicked} >`; case "entity_id": @@ -348,6 +361,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .entityFilter=${this.entityFilter} .includeDeviceClasses=${this.includeDeviceClasses} .includeDomains=${this.includeDomains} + class=${this.horizontal ? "hidden-picker" : ""} @value-changed=${this._targetPicked} allow-custom-entity >`; @@ -539,6 +553,16 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { static get styles(): CSSResultGroup { return css` ${unsafeCSS(chipStyles)} + .hidden-picker { + height: 0px; + display: inline-block; + overflow: hidden; + position: absolute; + } + .horizontal-container { + display: flex; + flex-wrap: wrap; + } .mdc-chip { color: var(--primary-text-color); } diff --git a/src/components/ha-textfield.ts b/src/components/ha-textfield.ts index 0e0f3bd804..32c9bbde6f 100644 --- a/src/components/ha-textfield.ts +++ b/src/components/ha-textfield.ts @@ -61,6 +61,11 @@ export class HaTextField extends TextFieldBase { padding-inline-end: var(--text-field-suffix-padding-right, 0px); direction: var(--direction); } + .mdc-text-field--with-leading-icon { + padding-inline-start: var(--text-field-suffix-padding-left, 0px); + padding-inline-end: var(--text-field-suffix-padding-right, 16px); + direction: var(--direction); + } .mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__affix--suffix { @@ -71,6 +76,12 @@ export class HaTextField extends TextFieldBase { color: var(--secondary-text-color); } + .mdc-text-field__icon--leading { + margin-inline-start: 16px; + margin-inline-end: 8px; + direction: var(--direction); + } + input { text-align: var(--text-field-text-align); } @@ -110,7 +121,25 @@ export class HaTextField extends TextFieldBase { inset-inline-end: initial !important; direction: var(--direction); } + + .mdc-text-field__input[type="number"] { + direction: var(--direction); + } `, + // safari workaround - must be explicit + document.dir === "rtl" + ? css` + .mdc-text-field__affix--suffix, + .mdc-text-field--with-leading-icon, + .mdc-text-field__icon--leading, + .mdc-floating-label, + .mdc-text-field--with-leading-icon.mdc-text-field--filled + .mdc-floating-label, + .mdc-text-field__input[type="number"] { + direction: rtl; + } + ` + : css``, ]; } diff --git a/src/components/media-player/ha-browse-media-tts.ts b/src/components/media-player/ha-browse-media-tts.ts index 3cd39d3a73..152da17ac6 100644 --- a/src/components/media-player/ha-browse-media-tts.ts +++ b/src/components/media-player/ha-browse-media-tts.ts @@ -36,7 +36,7 @@ declare global { class BrowseMediaTTS extends LitElement { @property() public hass!: HomeAssistant; - @property() public item; + @property() public item!: MediaPlayerItem; @property() public action!: MediaPlayerBrowseAction; diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index 4ee029ee13..da98917f43 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -116,9 +116,6 @@ export class HaMediaPlayerBrowse extends LitElement { private _resizeObserver?: ResizeObserver; - // @ts-ignore - private _intersectionObserver?: IntersectionObserver; - public connectedCallback(): void { super.connectedCallback(); this.updateComplete.then(() => this._attachResizeObserver()); @@ -128,9 +125,6 @@ export class HaMediaPlayerBrowse extends LitElement { if (this._resizeObserver) { this._resizeObserver.disconnect(); } - if (this._intersectionObserver) { - this._intersectionObserver.disconnect(); - } } public async refresh() { @@ -485,7 +479,10 @@ export class HaMediaPlayerBrowse extends LitElement { .layout=${grid({ itemSize: { width: "175px", - height: "225px", + height: + childrenMediaClass.thumbnail_ratio === "portrait" + ? "312px" + : "225px", }, gap: "16px", flex: { preserve: "aspect-ratio" }, diff --git a/src/components/user/ha-user-picker.ts b/src/components/user/ha-user-picker.ts index 0e7ae97466..6bbc3c1af3 100644 --- a/src/components/user/ha-user-picker.ts +++ b/src/components/user/ha-user-picker.ts @@ -8,6 +8,7 @@ import { fetchUsers, User } from "../../data/user"; import { HomeAssistant } from "../../types"; import "../ha-select"; import "./ha-user-badge"; +import "../ha-list-item"; class HaUserPicker extends LitElement { public hass?: HomeAssistant; @@ -48,14 +49,14 @@ class HaUserPicker extends LitElement { : ""} ${this._sortedUsers(this.users).map( (user) => html` - + ${user.name} - + ` )} diff --git a/src/data/application_credential.ts b/src/data/application_credential.ts index 075ea5f721..5dee1bd355 100644 --- a/src/data/application_credential.ts +++ b/src/data/application_credential.ts @@ -1,7 +1,11 @@ import { HomeAssistant } from "../types"; +export interface ApplicationCredentialsDomainConfig { + description_placeholders: string; +} + export interface ApplicationCredentialsConfig { - domains: string[]; + integrations: Record; } export interface ApplicationCredential { diff --git a/src/data/cached-history.ts b/src/data/cached-history.ts index 563d3d898b..30fa4956f9 100644 --- a/src/data/cached-history.ts +++ b/src/data/cached-history.ts @@ -158,8 +158,14 @@ export const getRecentWithCache = ( } const stateHistory = computeHistory(hass, fetchedHistory, localize); if (appendingToCache) { - mergeLine(stateHistory.line, cache.data.line); - mergeTimeline(stateHistory.timeline, cache.data.timeline); + if (stateHistory.line.length) { + mergeLine(stateHistory.line, cache.data.line); + } + if (stateHistory.timeline.length) { + mergeTimeline(stateHistory.timeline, cache.data.timeline); + // Replace the timeline array to force an update + cache.data.timeline = [...cache.data.timeline]; + } pruneStartTime(startTime, cache.data); } else { cache.data = stateHistory; @@ -191,6 +197,8 @@ const mergeLine = ( oldLine.data.push(entity); } }); + // Replace the cached line data to force an update + oldLine.data = [...oldLine.data]; } else { cacheLines.push(line); } diff --git a/src/data/camera.ts b/src/data/camera.ts index 48bbe4bc73..cca3671377 100644 --- a/src/data/camera.ts +++ b/src/data/camera.ts @@ -41,6 +41,12 @@ export interface WebRtcAnswer { answer: string; } +export const cameraUrlWithWidthHeight = ( + base_url: string, + width: number, + height: number +) => `${base_url}&width=${width}&height=${height}`; + export const computeMJPEGStreamUrl = (entity: CameraEntity) => `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`; @@ -57,7 +63,7 @@ export const fetchThumbnailUrlWithCache = async ( hass, entityId ); - return `${base_url}&width=${width}&height=${height}`; + return cameraUrlWithWidthHeight(base_url, width, height); }; export const fetchThumbnailUrl = async ( diff --git a/src/data/config_entries.ts b/src/data/config_entries.ts index b9e555998c..88667cf2ab 100644 --- a/src/data/config_entries.ts +++ b/src/data/config_entries.ts @@ -38,19 +38,19 @@ export const getConfigEntries = ( hass: HomeAssistant, filters?: { type?: "helper" | "integration"; domain?: string } ): Promise => { - const params = new URLSearchParams(); + const params: any = {}; if (filters) { if (filters.type) { - params.append("type", filters.type); + params.type_filter = filters.type; } if (filters.domain) { - params.append("domain", filters.domain); + params.domain = filters.domain; } } - return hass.callApi( - "GET", - `config/config_entries/entry?${params.toString()}` - ); + return hass.callWS({ + type: "config_entries/get", + ...params, + }); }; export const updateConfigEntry = ( diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index e77d75c2b5..4c7a931d0f 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -33,6 +33,18 @@ export interface UpdateEntityRegistryEntryResult { require_restart?: boolean; } +export interface SensorEntityOptions { + unit_of_measurement?: string | null; +} + +export interface WeatherEntityOptions { + precipitation_unit?: string | null; + pressure_unit?: string | null; + temperature_unit?: string | null; + visibility_unit?: string | null; + wind_speed_unit?: string | null; +} + export interface EntityRegistryEntryUpdateParams { name?: string | null; icon?: string | null; @@ -42,9 +54,7 @@ export interface EntityRegistryEntryUpdateParams { hidden_by: string | null; new_entity_id?: string; options_domain?: string; - options?: { - unit_of_measurement?: string | null; - }; + options?: SensorEntityOptions | WeatherEntityOptions; } export const findBatteryEntity = ( diff --git a/src/data/hassio/addon.ts b/src/data/hassio/addon.ts index c27d735b0e..5c1e7f49c2 100644 --- a/src/data/hassio/addon.ts +++ b/src/data/hassio/addon.ts @@ -1,7 +1,9 @@ import { atLeastVersion } from "../../common/config/version"; import type { HaFormSchema } from "../../components/ha-form/types"; import { HomeAssistant } from "../../types"; -import { SupervisorArch } from "../supervisor/supervisor"; +import { supervisorApiCall } from "../supervisor/common"; +import { StoreAddonDetails } from "../supervisor/store"; +import { Supervisor, SupervisorArch } from "../supervisor/supervisor"; import { extractApiErrorMessage, hassioApiResultExtractor, @@ -363,3 +365,15 @@ export const uninstallHassioAddon = async ( `hassio/addons/${slug}/uninstall` ); }; + +export const fetchAddonInfo = ( + hass: HomeAssistant, + supervisor: Supervisor, + addonSlug: string +): Promise => + supervisorApiCall( + hass, + !supervisor.addon?.addons.find((addon) => addon.slug === addonSlug) + ? `/store/addons/${addonSlug}` // Use /store/addons when add-on is not installed + : `/addons/${addonSlug}/info` // Use /addons when add-on is installed + ); diff --git a/src/data/hassio/supervisor.ts b/src/data/hassio/supervisor.ts index e08600098b..9f5bcd0f28 100644 --- a/src/data/hassio/supervisor.ts +++ b/src/data/hassio/supervisor.ts @@ -1,7 +1,6 @@ import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant, PanelInfo } from "../../types"; import { SupervisorArch } from "../supervisor/supervisor"; -import { HassioAddonInfo } from "./addon"; import { hassioApiResultExtractor, HassioResponse } from "./common"; export type HassioHomeAssistantInfo = { @@ -22,7 +21,7 @@ export type HassioHomeAssistantInfo = { }; export type HassioSupervisorInfo = { - addons: HassioAddonInfo[]; + addons: string[]; addons_repositories: string[]; arch: SupervisorArch; channel: string; diff --git a/src/data/history.ts b/src/data/history.ts index 7de7651a75..0fb7a8857c 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -223,16 +223,12 @@ export const fetchDate = ( hass: HomeAssistant, startTime: Date, endTime: Date, - entityId?: string + entityIds: string[] ): Promise => hass.callApi( "GET", `history/period/${startTime.toISOString()}?end_time=${endTime.toISOString()}&minimal_response${ - entityId ? `&filter_entity_id=${entityId}` : `` - }${ - entityId && !entityIdHistoryNeedsAttributes(hass, entityId) - ? `&no_attributes` - : `` + entityIds ? `&filter_entity_id=${entityIds.join(",")}` : `` }` ); @@ -240,19 +236,19 @@ export const fetchDateWS = ( hass: HomeAssistant, startTime: Date, endTime: Date, - entityId?: string + entityIds: string[] ) => { const params = { type: "history/history_during_period", start_time: startTime.toISOString(), end_time: endTime.toISOString(), minimal_response: true, - no_attributes: !!( - entityId && !entityIdHistoryNeedsAttributes(hass, entityId) - ), + no_attributes: !entityIds + .map((entityId) => entityIdHistoryNeedsAttributes(hass, entityId)) + .reduce((cur, next) => cur || next, false), }; - if (entityId) { - return hass.callWS({ ...params, entity_ids: [entityId] }); + if (entityIds.length !== 0) { + return hass.callWS({ ...params, entity_ids: entityIds }); } return hass.callWS(params); }; diff --git a/src/data/logbook.ts b/src/data/logbook.ts index eda3aa4ce6..1b1f9ce980 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -1,4 +1,4 @@ -import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { HassEntity } from "home-assistant-js-websocket"; import { BINARY_STATE_OFF, BINARY_STATE_ON, @@ -6,12 +6,14 @@ import { } from "../common/const"; import { computeDomain } from "../common/entity/compute_domain"; import { computeStateDisplay } from "../common/entity/compute_state_display"; +import { computeStateDomain } from "../common/entity/compute_state_domain"; import { LocalizeFunc } from "../common/translations/localize"; +import { HaEntityPickerEntityFilterFunc } from "../components/entity/ha-entity-picker"; import { HomeAssistant } from "../types"; import { UNAVAILABLE_STATES } from "./entity"; const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; -export const CONTINUOUS_DOMAINS = ["proximity", "sensor"]; +export const CONTINUOUS_DOMAINS = ["counter", "proximity", "sensor"]; export interface LogbookStreamMessage { events: LogbookEntry[]; @@ -175,7 +177,7 @@ export const subscribeLogbook = ( endDate: string, entityIds?: string[], deviceIds?: string[] -): Promise => { +): Promise<() => Promise> => { // If all specified filters are empty lists, we can return an empty list. if ( (entityIds || deviceIds) && @@ -425,3 +427,10 @@ export const localizeStateMessage = ( : state ); }; + +export const filterLogbookCompatibleEntities: HaEntityPickerEntityFilterFunc = ( + entity +) => + computeStateDomain(entity) !== "sensor" || + (entity.attributes.unit_of_measurement === undefined && + entity.attributes.state_class === undefined); diff --git a/src/data/media-player.ts b/src/data/media-player.ts index 199cbbb386..a5c780f582 100644 --- a/src/data/media-player.ts +++ b/src/data/media-player.ts @@ -36,6 +36,7 @@ import { supportsFeature } from "../common/entity/supports-feature"; import { MediaPlayerItemId } from "../components/media-player/ha-media-player-browse"; import type { HomeAssistant } from "../types"; import { UNAVAILABLE_STATES } from "./entity"; +import { isTTSMediaSource } from "./tts"; interface MediaPlayerEntityAttributes extends HassEntityAttributeBase { media_content_id?: string; @@ -441,3 +442,29 @@ export const handleMediaControlClick = ( entity_id: stateObj!.entity_id, } ); + +export const mediaPlayerPlayMedia = ( + hass: HomeAssistant, + entity_id: string, + media_content_id: string, + media_content_type: string, + extra: { + enqueue?: "play" | "next" | "add" | "replace"; + announce?: boolean; + } = {} +) => { + // We set text-to-speech to announce. + if ( + !extra.enqueue && + extra.announce === undefined && + isTTSMediaSource(media_content_id) + ) { + extra.announce = true; + } + return hass.callService("media_player", "play_media", { + entity_id, + media_content_id, + media_content_type, + ...extra, + }); +}; diff --git a/src/data/supervisor/common.ts b/src/data/supervisor/common.ts new file mode 100644 index 0000000000..b47f76bc4d --- /dev/null +++ b/src/data/supervisor/common.ts @@ -0,0 +1,34 @@ +import { atLeastVersion } from "../../common/config/version"; +import { HomeAssistant } from "../../types"; +import { hassioApiResultExtractor, HassioResponse } from "../hassio/common"; + +export interface SupervisorApiCallOptions { + method?: "get" | "post" | "delete"; + data?: Record; + timeout?: number; +} + +export const supervisorApiCall = async ( + hass: HomeAssistant, + endpoint: string, + options?: SupervisorApiCallOptions +): Promise => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + // Websockets was added in 2021.2.4 + return hass.callWS({ + type: "supervisor/api", + endpoint, + method: options?.method || "get", + timeout: options?.timeout ?? null, + data: options?.data, + }); + } + return hassioApiResultExtractor( + await hass.callApi>( + // @ts-ignore + (options.method || "get").toUpperCase(), + `hassio${endpoint}`, + options?.data + ) + ); +}; diff --git a/src/data/supervisor/store.ts b/src/data/supervisor/store.ts index e9e2fbe57f..d71f96401c 100644 --- a/src/data/supervisor/store.ts +++ b/src/data/supervisor/store.ts @@ -1,7 +1,7 @@ -import { atLeastVersion } from "../../common/config/version"; import { HomeAssistant } from "../../types"; -import { AddonRepository, AddonStage } from "../hassio/addon"; -import { hassioApiResultExtractor, HassioResponse } from "../hassio/common"; +import { AddonStage } from "../hassio/addon"; +import { supervisorApiCall } from "./common"; +import { SupervisorArch } from "./supervisor"; export interface StoreAddon { advanced: boolean; @@ -13,14 +13,34 @@ export interface StoreAddon { installed: boolean; logo: boolean; name: string; - repository: AddonRepository; + repository: string; slug: string; stage: AddonStage; update_available: boolean; url: string; - version: string | null; version_latest: string; + version: null; } + +export interface StoreAddonDetails extends StoreAddon { + apparmor: boolean; + arch: SupervisorArch[]; + auth_api: boolean; + detached: boolean; + docker_api: boolean; + documentation: boolean; + full_access: boolean; + hassio_api: boolean; + hassio_role: string; + homeassistant_api: boolean; + host_network: boolean; + host_pid: boolean; + ingress: boolean; + long_description: string; + rating: number; + signed: boolean; +} + interface StoreRepository { maintainer: string; name: string; @@ -36,16 +56,25 @@ export interface SupervisorStore { export const fetchSupervisorStore = async ( hass: HomeAssistant -): Promise => { - if (atLeastVersion(hass.config.version, 2021, 2, 4)) { - return hass.callWS({ - type: "supervisor/api", - endpoint: "/store", - method: "get", - }); - } +): Promise => supervisorApiCall(hass, "/store"); - return hassioApiResultExtractor( - await hass.callApi>("GET", `hassio/store`) - ); -}; +export const fetchStoreRepositories = async ( + hass: HomeAssistant +): Promise => supervisorApiCall(hass, "/store/repositories"); + +export const addStoreRepository = async ( + hass: HomeAssistant, + repository: string +): Promise => + supervisorApiCall(hass, "/store/repositories", { + method: "post", + data: { repository }, + }); + +export const removeStoreRepository = async ( + hass: HomeAssistant, + repository: string +): Promise => + supervisorApiCall(hass, `/store/repositories/${repository}`, { + method: "delete", + }); diff --git a/src/data/translation.ts b/src/data/translation.ts index 7374f40639..e7c632ea57 100644 --- a/src/data/translation.ts +++ b/src/data/translation.ts @@ -38,7 +38,8 @@ export type TranslationCategory = | "device_automation" | "mfa_setup" | "system_health" - | "device_class"; + | "device_class" + | "application_credentials"; export const fetchTranslationPreferences = (hass: HomeAssistant) => fetchFrontendUserData(hass.connection, "language"); diff --git a/src/data/weather.ts b/src/data/weather.ts index 3fb6dca152..f77da5c9b0 100644 --- a/src/data/weather.ts +++ b/src/data/weather.ts @@ -37,14 +37,24 @@ interface ForecastAttribute { humidity?: number; condition?: string; daytime?: boolean; + pressure?: number; + wind_speed?: string; } interface WeatherEntityAttributes extends HassEntityAttributeBase { - temperature: number; + attribution?: string; humidity?: number; forecast?: ForecastAttribute[]; - wind_speed: string; - wind_bearing: string; + pressure?: number; + temperature?: number; + visibility?: number; + wind_bearing?: number | string; + wind_speed?: number; + precipitation_unit: string; + pressure_unit: string; + temperature_unit: string; + visibility_unit: string; + wind_speed_unit: string; } export interface WeatherEntity extends HassEntityBase { @@ -138,16 +148,16 @@ const cardinalDirections = [ "N", ]; -const getWindBearingText = (degree: string): string => { - const degreenum = parseInt(degree, 10); +const getWindBearingText = (degree: number | string): string => { + const degreenum = typeof degree === "number" ? degree : parseInt(degree, 10); if (isFinite(degreenum)) { // eslint-disable-next-line no-bitwise return cardinalDirections[(((degreenum + 11.25) / 22.5) | 0) % 16]; } - return degree; + return typeof degree === "number" ? degree.toString() : degree; }; -const getWindBearing = (bearing: string): string => { +const getWindBearing = (bearing: number | string): string => { if (bearing != null) { return getWindBearingText(bearing); } @@ -156,14 +166,19 @@ const getWindBearing = (bearing: string): string => { export const getWind = ( hass: HomeAssistant, - speed: string, - bearing: string + stateObj: WeatherEntity, + speed?: number, + bearing?: number | string ): string => { - const speedText = `${formatNumber(speed, hass.locale)} ${getWeatherUnit( - hass!, - "wind_speed" - )}`; - if (bearing !== null) { + const speedText = + speed !== undefined && speed !== null + ? `${formatNumber(speed, hass.locale)} ${getWeatherUnit( + hass!, + stateObj, + "wind_speed" + )}` + : "-"; + if (bearing !== undefined && bearing !== null) { const cardinalDirection = getWindBearing(bearing); return `${speedText} (${ hass.localize( @@ -176,13 +191,28 @@ export const getWind = ( export const getWeatherUnit = ( hass: HomeAssistant, + stateObj: WeatherEntity, measure: string ): string => { + const lengthUnit = hass.config.unit_system.length || ""; switch (measure) { case "visibility": - return hass.config.unit_system.length || ""; + return stateObj.attributes.visibility_unit || lengthUnit; case "precipitation": - return hass.config.unit_system.accumulated_precipitation || ""; + return stateObj.attributes.precipitation_unit || lengthUnit === "km" + ? "mm" + : "in"; + case "pressure": + return stateObj.attributes.pressure_unit || lengthUnit === "km" + ? "hPa" + : "inHg"; + case "temperature": + return ( + stateObj.attributes.temperature_unit || + hass.config.unit_system.temperature + ); + case "wind_speed": + return stateObj.attributes.wind_speed_unit || `${lengthUnit}/h`; case "humidity": case "precipitation_probability": return "%"; @@ -227,7 +257,7 @@ export const getSecondaryWeatherAttribute = ( ` : hass!.localize(`ui.card.weather.attributes.${attribute}`)} ${formatNumber(value, hass.locale, { maximumFractionDigits: 1 })} - ${getWeatherUnit(hass!, attribute)} + ${getWeatherUnit(hass!, stateObj, attribute)} `; }; @@ -262,7 +292,7 @@ const getWeatherExtrema = ( return undefined; } - const unit = getWeatherUnit(hass!, "temperature"); + const unit = getWeatherUnit(hass!, stateObj, "temperature"); return html` ${tempHigh ? `${formatNumber(tempHigh, hass.locale)} ${unit}` : ""} diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index 11bcefa4cb..a99244541d 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -1,6 +1,5 @@ import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { HomeAssistant } from "../types"; -import { DeviceRegistryEntry } from "./device_registry"; export enum InclusionState { /** The controller isn't doing anything regarding inclusion. */ @@ -85,6 +84,23 @@ enum Protocols { ZWave = 0, ZWaveLongRange = 1, } + +export enum FirmwareUpdateStatus { + Error_Timeout = -1, + Error_Checksum = 0, + Error_TransmissionFailed = 1, + Error_InvalidManufacturerID = 2, + Error_InvalidFirmwareID = 3, + Error_InvalidFirmwareTarget = 4, + Error_InvalidHeaderInformation = 5, + Error_InvalidHeaderFormat = 6, + Error_InsufficientMemory = 7, + Error_InvalidHardwareVersion = 8, + OK_WaitingForActivation = 0xfd, + OK_NoRestart = 0xfe, + OK_RestartPending = 0xff, +} + export interface QRProvisioningInformation { version: QRCodeVersion; securityClasses: SecurityClass[]; @@ -109,10 +125,6 @@ export interface PlannedProvisioningEntry { export const MINIMUM_QR_STRING_LENGTH = 52; -export interface ZWaveJSNodeIdentifiers { - home_id: string; - node_id: number; -} export interface ZWaveJSNetwork { client: ZWaveJSClient; controller: ZWaveJSController; @@ -151,7 +163,7 @@ export interface ZWaveJSController { export interface ZWaveJSNodeStatus { node_id: number; ready: boolean; - status: number; + status: NodeStatus; is_secure: boolean | string; is_routing: boolean | null; zwave_plus_version: number | null; @@ -244,6 +256,68 @@ export interface ZWaveJSControllerStatisticsUpdatedMessage { timeout_callback: number; } +export enum RssiError { + NotAvailable = 127, + ReceiverSaturated = 126, + NoSignalDetected = 125, +} + +export enum ProtocolDataRate { + ZWave_9k6 = 0x01, + ZWave_40k = 0x02, + ZWave_100k = 0x03, + LongRange_100k = 0x04, +} + +export interface ZWaveJSNodeStatisticsUpdatedMessage { + event: "statistics updated"; + source: "node"; + commands_tx: number; + commands_rx: number; + commands_dropped_tx: number; + commands_dropped_rx: number; + timeout_response: number; + rtt: number | null; + rssi: RssiError | number | null; + lwr: ZWaveJSRouteStatistics | null; + nlwr: ZWaveJSRouteStatistics | null; +} + +export interface ZWaveJSRouteStatistics { + protocol_data_rate: number; + repeaters: string[]; + rssi: RssiError | number | null; + repeater_rssi: (RssiError | number)[]; + route_failed_between: [string, string] | null; +} + +export interface ZWaveJSNodeStatusUpdatedMessage { + event: "ready" | "wake up" | "sleep" | "dead" | "alive"; + ready: boolean; + status: NodeStatus; +} + +export interface ZWaveJSNodeFirmwareUpdateProgressMessage { + event: "firmware update progress"; + sent_fragments: number; + total_fragments: number; +} + +export interface ZWaveJSNodeFirmwareUpdateFinishedMessage { + event: "firmware update finished"; + status: FirmwareUpdateStatus; + wait_time: number; +} + +export type ZWaveJSNodeFirmwareUpdateCapabilities = + | { firmware_upgradable: false } + | { + firmware_upgradable: true; + firmware_targets: number[]; + continues_to_function: boolean | null; + supports_activation: boolean | null; + }; + export interface ZWaveJSRemovedNode { node_id: number; manufacturer: string; @@ -280,25 +354,6 @@ export interface RequestedGrant { export const nodeStatus = ["unknown", "asleep", "awake", "dead", "alive"]; -export interface ZWaveJsMigrationData { - migration_device_map: Record; - zwave_entity_ids: string[]; - zwave_js_entity_ids: string[]; - migration_entity_map: Record; - migrated: boolean; -} - -export const migrateZwave = ( - hass: HomeAssistant, - entry_id: string, - dry_run = true -): Promise => - hass.callWS({ - type: "zwave_js/migrate_zwave", - entry_id, - dry_run, - }); - export const fetchZwaveNetworkStatus = ( hass: HomeAssistant, device_or_entry_id: { @@ -461,6 +516,19 @@ export const fetchZwaveNodeStatus = ( device_id, }); +export const subscribeZwaveNodeStatus = ( + hass: HomeAssistant, + device_id: string, + callbackFunction: (message: ZWaveJSNodeStatusUpdatedMessage) => void +): Promise => + hass.connection.subscribeMessage( + (message: any) => callbackFunction(message), + { + type: "zwave_js/subscribe_node_status", + device_id, + } + ); + export const fetchZwaveNodeMetadata = ( hass: HomeAssistant, device_id: string @@ -558,19 +626,6 @@ export const stopHealZwaveNetwork = ( entry_id, }); -export const subscribeZwaveNodeReady = ( - hass: HomeAssistant, - device_id: string, - callbackFunction: (message) => void -): Promise => - hass.connection.subscribeMessage( - (message: any) => callbackFunction(message), - { - type: "zwave_js/node_ready", - device_id, - } - ); - export const subscribeHealZwaveNetworkProgress = ( hass: HomeAssistant, entry_id: string, @@ -597,27 +652,96 @@ export const subscribeZwaveControllerStatistics = ( } ); -export const getZwaveJsIdentifiersFromDevice = ( - device: DeviceRegistryEntry -): ZWaveJSNodeIdentifiers | undefined => { - if (!device) { - return undefined; - } - - const zwaveJSIdentifier = device.identifiers.find( - (identifier) => identifier[0] === "zwave_js" +export const subscribeZwaveNodeStatistics = ( + hass: HomeAssistant, + device_id: string, + callbackFunction: (message: ZWaveJSNodeStatisticsUpdatedMessage) => void +): Promise => + hass.connection.subscribeMessage( + (message: any) => callbackFunction(message), + { + type: "zwave_js/subscribe_node_statistics", + device_id, + } ); - if (!zwaveJSIdentifier) { - return undefined; - } - const identifiers = zwaveJSIdentifier[1].split("-"); - return { - node_id: parseInt(identifiers[1]), - home_id: identifiers[0], - }; +export const fetchZwaveNodeIsFirmwareUpdateInProgress = ( + hass: HomeAssistant, + device_id: string +): Promise => + hass.callWS({ + type: "zwave_js/get_firmware_update_progress", + device_id, + }); + +export const fetchZwaveIsAnyFirmwareUpdateInProgress = ( + hass: HomeAssistant, + entry_id: string +): Promise => + hass.callWS({ + type: "zwave_js/get_any_firmware_update_progress", + entry_id, + }); + +export const fetchZwaveNodeFirmwareUpdateCapabilities = ( + hass: HomeAssistant, + device_id: string +): Promise => + hass.callWS({ + type: "zwave_js/get_firmware_update_capabilities", + device_id, + }); + +export const uploadFirmwareAndBeginUpdate = async ( + hass: HomeAssistant, + device_id: string, + file: File, + target?: number +) => { + const fd = new FormData(); + fd.append("file", file); + if (target !== undefined) { + fd.append("target", target.toString()); + } + const resp = await hass.fetchWithAuth( + `/api/zwave_js/firmware/upload/${device_id}`, + { + method: "POST", + body: fd, + } + ); + + if (resp.status !== 200) { + throw new Error(resp.statusText); + } }; +export const subscribeZwaveNodeFirmwareUpdate = ( + hass: HomeAssistant, + device_id: string, + callbackFunction: ( + message: + | ZWaveJSNodeFirmwareUpdateFinishedMessage + | ZWaveJSNodeFirmwareUpdateProgressMessage + ) => void +): Promise => + hass.connection.subscribeMessage( + (message: any) => callbackFunction(message), + { + type: "zwave_js/subscribe_firmware_update_status", + device_id, + } + ); + +export const abortZwaveNodeFirmwareUpdate = ( + hass: HomeAssistant, + device_id: string +): Promise => + hass.callWS({ + type: "zwave_js/abort_firmware_update", + device_id, + }); + export type ZWaveJSLogUpdate = ZWaveJSLogMessageUpdate | ZWaveJSLogConfigUpdate; interface ZWaveJSLogMessageUpdate { diff --git a/src/dialogs/domain-toggler/dialog-domain-toggler.ts b/src/dialogs/domain-toggler/dialog-domain-toggler.ts index caff99cf37..fd66cf2940 100644 --- a/src/dialogs/domain-toggler/dialog-domain-toggler.ts +++ b/src/dialogs/domain-toggler/dialog-domain-toggler.ts @@ -47,10 +47,14 @@ class DomainTogglerDialog hideActions .heading=${createCloseHeading( this.hass, - this.hass.localize("ui.dialogs.domain_toggler.title") + this._params.title || + this.hass.localize("ui.dialogs.domain_toggler.title") )} > -
+ ${this._params.description + ? html`
${this._params.description}
` + : ""} +
${domains.map( (domain) => html` @@ -92,7 +96,10 @@ class DomainTogglerDialog ha-dialog { --mdc-dialog-max-width: 500px; } - div { + .description { + margin-bottom: 8px; + } + .domains { display: grid; grid-template-columns: auto auto; grid-row-gap: 8px; diff --git a/src/dialogs/domain-toggler/show-dialog-domain-toggler.ts b/src/dialogs/domain-toggler/show-dialog-domain-toggler.ts index 702e0e3848..18426ba68e 100644 --- a/src/dialogs/domain-toggler/show-dialog-domain-toggler.ts +++ b/src/dialogs/domain-toggler/show-dialog-domain-toggler.ts @@ -1,6 +1,8 @@ import { fireEvent } from "../../common/dom/fire_event"; export interface HaDomainTogglerDialogParams { + title?: string; + description?: string; domains: string[]; exposedDomains: string[] | null; toggleDomain: (domain: string, turnOn: boolean) => void; diff --git a/src/dialogs/generic/dialog-box.ts b/src/dialogs/generic/dialog-box.ts index 4cdb0ef6c9..fdf2107756 100644 --- a/src/dialogs/generic/dialog-box.ts +++ b/src/dialogs/generic/dialog-box.ts @@ -8,7 +8,6 @@ import "../../components/ha-dialog"; import "../../components/ha-svg-icon"; import "../../components/ha-switch"; import { HaTextField } from "../../components/ha-textfield"; -import { haStyleDialog } from "../../resources/styles"; import { HomeAssistant } from "../../types"; import { DialogBoxParams } from "./show-dialog-box"; @@ -135,34 +134,34 @@ class DialogBox extends LitElement { } static get styles(): CSSResultGroup { - return [ - haStyleDialog, - css` - :host([inert]) { - pointer-events: initial !important; - cursor: initial !important; - } - a { - color: var(--primary-color); - } - p { - margin: 0; - padding-top: 6px; - padding-bottom: 24px; - color: var(--primary-text-color); - } - .no-bottom-padding { - padding-bottom: 0; - } - .secondary { - color: var(--secondary-text-color); - } - ha-dialog { - /* Place above other dialogs */ - --dialog-z-index: 104; - } - `, - ]; + return css` + :host([inert]) { + pointer-events: initial !important; + cursor: initial !important; + } + a { + color: var(--primary-color); + } + p { + margin: 0; + padding-top: 6px; + padding-bottom: 24px; + color: var(--primary-text-color); + } + .no-bottom-padding { + padding-bottom: 0; + } + .secondary { + color: var(--secondary-text-color); + } + ha-dialog { + --mdc-dialog-heading-ink-color: var(--primary-text-color); + --mdc-dialog-content-ink-color: var(--primary-text-color); + --justify-action-buttons: space-between; + /* Place above other dialogs */ + --dialog-z-index: 104; + } + `; } } diff --git a/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts b/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts index de41f1c173..0fb7401959 100644 --- a/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts +++ b/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts @@ -37,7 +37,7 @@ export class MoreInfoAlarmControlPanel extends LitElement { id="alarmCode" .label=${this.hass.localize("ui.card.alarm_control_panel.code")} type="password" - .inputmode=${this.stateObj.attributes.code_format === + .inputMode=${this.stateObj.attributes.code_format === FORMAT_NUMBER ? "numeric" : "text"} diff --git a/src/dialogs/more-info/controls/more-info-media_player.ts b/src/dialogs/more-info/controls/more-info-media_player.ts index 7989c384d8..eb725e4bde 100644 --- a/src/dialogs/more-info/controls/more-info-media_player.ts +++ b/src/dialogs/more-info/controls/more-info-media_player.ts @@ -11,7 +11,6 @@ import { } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; -import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { supportsFeature } from "../../../common/entity/supports-feature"; import { computeRTLDirection } from "../../../common/util/compute_rtl"; @@ -26,8 +25,8 @@ import { handleMediaControlClick, MediaPickedEvent, MediaPlayerEntity, + mediaPlayerPlayMedia, SUPPORT_BROWSE_MEDIA, - SUPPORT_PLAY_MEDIA, SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_BUTTONS, @@ -191,14 +190,6 @@ class MoreInfoMediaPlayer extends LitElement {
` : ""} - ${isComponentLoaded(this.hass, "tts") && - supportsFeature(stateObj, SUPPORT_PLAY_MEDIA) - ? html` -
- Text to speech has moved to the media browser. -
- ` - : ""} `; } @@ -305,20 +296,14 @@ class MoreInfoMediaPlayer extends LitElement { action: "play", entityId: this.stateObj!.entity_id, mediaPickedCallback: (pickedMedia: MediaPickedEvent) => - this._playMedia( + mediaPlayerPlayMedia( + this.hass, + this.stateObj!.entity_id, pickedMedia.item.media_content_id, pickedMedia.item.media_content_type ), }); } - - private _playMedia(media_content_id: string, media_content_type: string) { - this.hass!.callService("media_player", "play_media", { - entity_id: this.stateObj!.entity_id, - media_content_id, - media_content_type, - }); - } } declare global { diff --git a/src/dialogs/more-info/controls/more-info-weather.ts b/src/dialogs/more-info/controls/more-info-weather.ts index e93e9cbfe8..a680dbd754 100644 --- a/src/dialogs/more-info/controls/more-info-weather.ts +++ b/src/dialogs/more-info/controls/more-info-weather.ts @@ -5,7 +5,6 @@ import { mdiWaterPercent, mdiWeatherWindy, } from "@mdi/js"; -import { HassEntity } from "home-assistant-js-websocket"; import { css, CSSResultGroup, @@ -23,6 +22,7 @@ import { getWeatherUnit, getWind, isForecastHourly, + WeatherEntity, weatherIcons, } from "../../../data/weather"; import { HomeAssistant } from "../../../types"; @@ -31,7 +31,7 @@ import { HomeAssistant } from "../../../types"; class MoreInfoWeather extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property() public stateObj?: HassEntity; + @property() public stateObj?: WeatherEntity; protected shouldUpdate(changedProps: PropertyValues): boolean { if (changedProps.has("stateObj")) { @@ -58,19 +58,23 @@ class MoreInfoWeather extends LitElement { const hourly = isForecastHourly(this.stateObj.attributes.forecast); return html` -
- -
- ${this.hass.localize("ui.card.weather.attributes.temperature")} -
-
- ${formatNumber( - this.stateObj.attributes.temperature, - this.hass.locale - )} - ${getWeatherUnit(this.hass, "temperature")} -
-
+ ${this._showValue(this.stateObj.attributes.temperature) + ? html` +
+ +
+ ${this.hass.localize("ui.card.weather.attributes.temperature")} +
+
+ ${formatNumber( + this.stateObj.attributes.temperature!, + this.hass.locale + )} + ${getWeatherUnit(this.hass, this.stateObj, "temperature")} +
+
+ ` + : ""} ${this._showValue(this.stateObj.attributes.pressure) ? html`
@@ -80,10 +84,10 @@ class MoreInfoWeather extends LitElement {
${formatNumber( - this.stateObj.attributes.pressure, + this.stateObj.attributes.pressure!, this.hass.locale )} - ${getWeatherUnit(this.hass, "pressure")} + ${getWeatherUnit(this.hass, this.stateObj, "pressure")}
` @@ -97,7 +101,7 @@ class MoreInfoWeather extends LitElement {
${formatNumber( - this.stateObj.attributes.humidity, + this.stateObj.attributes.humidity!, this.hass.locale )} % @@ -115,7 +119,8 @@ class MoreInfoWeather extends LitElement {
${getWind( this.hass, - this.stateObj.attributes.wind_speed, + this.stateObj, + this.stateObj.attributes.wind_speed!, this.stateObj.attributes.wind_bearing )}
@@ -131,10 +136,10 @@ class MoreInfoWeather extends LitElement {
${formatNumber( - this.stateObj.attributes.visibility, + this.stateObj.attributes.visibility!, this.hass.locale )} - ${getWeatherUnit(this.hass, "length")} + ${getWeatherUnit(this.hass, this.stateObj, "visibility")}
` @@ -173,16 +178,24 @@ class MoreInfoWeather extends LitElement { `}
${this._showValue(item.templow) - ? `${formatNumber(item.templow, this.hass.locale)} - ${getWeatherUnit(this.hass, "temperature")}` + ? `${formatNumber(item.templow!, this.hass.locale)} + ${getWeatherUnit( + this.hass, + this.stateObj!, + "temperature" + )}` : hourly ? "" : "—"}
${this._showValue(item.temperature) - ? `${formatNumber(item.temperature, this.hass.locale)} - ${getWeatherUnit(this.hass, "temperature")}` + ? `${formatNumber(item.temperature!, this.hass.locale)} + ${getWeatherUnit( + this.hass, + this.stateObj!, + "temperature" + )}` : "—"}
` @@ -240,7 +253,7 @@ class MoreInfoWeather extends LitElement { `; } - private _showValue(item: string): boolean { + private _showValue(item: number | string | undefined): boolean { return typeof item !== "undefined" && item !== null; } } diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index c87ef4244b..ee987044ab 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -34,7 +34,7 @@ import "../../components/ha-circular-progress"; import "../../components/ha-header-bar"; import "../../components/ha-icon-button"; import "../../components/ha-textfield"; -import { fetchHassioSupervisorInfo } from "../../data/hassio/supervisor"; +import { fetchHassioAddonsInfo } from "../../data/hassio/addon"; import { domainToName } from "../../data/integration"; import { getPanelNameTranslationKey } from "../../data/panel"; import { PageNavigation } from "../../layouts/hass-tabs-subpage"; @@ -586,7 +586,7 @@ export class QuickBar extends LitElement { const sectionItems = this._generateNavigationConfigSectionCommands(); const supervisorItems: BaseNavigationCommand[] = []; if (isComponentLoaded(this.hass, "hassio")) { - const supervisorInfo = await fetchHassioSupervisorInfo(this.hass); + const addonsInfo = await fetchHassioAddonsInfo(this.hass); supervisorItems.push({ path: "/hassio/store", primaryText: this.hass.localize( @@ -599,7 +599,7 @@ export class QuickBar extends LitElement { "ui.dialogs.quick-bar.commands.navigation.addon_dashboard" ), }); - for (const addon of supervisorInfo.addons) { + for (const addon of addonsInfo.addons.filter((a) => a.version)) { supervisorItems.push({ path: `/hassio/addon/${addon.slug}`, primaryText: this.hass.localize( @@ -803,6 +803,9 @@ export class QuickBar extends LitElement { span.command-text { margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: initial; + direction: var(--direction); } mwc-list-item { diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index 3fd29bcdbf..e71667675e 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -194,6 +194,7 @@ export const provideHass = ( socket: { readyState: WebSocket.OPEN, }, + haVersion: "DEMO", } as any, connected: true, states: {}, diff --git a/src/panels/config/application_credentials/dialog-add-application-credential.ts b/src/panels/config/application_credentials/dialog-add-application-credential.ts index 516b762806..542fe41c0b 100644 --- a/src/panels/config/application_credentials/dialog-add-application-credential.ts +++ b/src/panels/config/application_credentials/dialog-add-application-credential.ts @@ -7,10 +7,12 @@ import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-circular-progress"; import "../../../components/ha-combo-box"; import { createCloseHeading } from "../../../components/ha-dialog"; +import "../../../components/ha-markdown"; import "../../../components/ha-textfield"; import { fetchApplicationCredentialsConfig, createApplicationCredential, + ApplicationCredentialsConfig, ApplicationCredential, } from "../../../data/application_credential"; import { domainToName } from "../../../data/integration"; @@ -42,17 +44,22 @@ export class DialogAddApplicationCredential extends LitElement { @state() private _name?: string; + @state() private _description?: string; + @state() private _clientId?: string; @state() private _clientSecret?: string; @state() private _domains?: Domain[]; + @state() private _config?: ApplicationCredentialsConfig; + public showDialog(params: AddApplicationCredentialDialogParams) { this._params = params; this._domain = params.selectedDomain !== undefined ? params.selectedDomain : ""; this._name = ""; + this._description = ""; this._clientId = ""; this._clientSecret = ""; this._error = undefined; @@ -61,11 +68,15 @@ export class DialogAddApplicationCredential extends LitElement { } private async _fetchConfig() { - const config = await fetchApplicationCredentialsConfig(this.hass); - this._domains = config.domains.map((domain) => ({ + this._config = await fetchApplicationCredentialsConfig(this.hass); + this._domains = Object.keys(this._config.integrations).map((domain) => ({ id: domain, name: domainToName(this.hass.localize, domain), })); + await this.hass.loadBackendTranslation("application_credentials"); + if (this._domain !== "") { + this._updateDescription(); + } } protected render(): TemplateResult { @@ -103,6 +114,12 @@ export class DialogAddApplicationCredential extends LitElement { required @value-changed=${this._handleDomainPicked} > + ${this._description + ? html`` + : ""} ${this.hass!.localize( - "ui.panel.config.cloud.alexa.manage_domains" + "ui.panel.config.cloud.alexa.manage_defaults" )} ` @@ -402,6 +402,10 @@ class CloudAlexa extends SubscribeMixin(LitElement) { private _openDomainToggler() { showDomainTogglerDialog(this, { + title: this.hass!.localize("ui.panel.config.cloud.alexa.manage_defaults"), + description: this.hass!.localize( + "ui.panel.config.cloud.alexa.manage_defaults_dialog_description" + ), domains: this._entities!.map((entity) => computeDomain(entity.entity_id) ).filter((value, idx, self) => self.indexOf(value) === idx), diff --git a/src/panels/config/cloud/google-assistant/cloud-google-assistant.ts b/src/panels/config/cloud/google-assistant/cloud-google-assistant.ts index f48fb570e4..f177094f8a 100644 --- a/src/panels/config/cloud/google-assistant/cloud-google-assistant.ts +++ b/src/panels/config/cloud/google-assistant/cloud-google-assistant.ts @@ -256,7 +256,7 @@ class CloudGoogleAssistant extends SubscribeMixin(LitElement) { slot="toolbar-icon" @click=${this._openDomainToggler} >${this.hass!.localize( - "ui.panel.config.cloud.google.manage_domains" + "ui.panel.config.cloud.google.manage_defaults" )} ` @@ -442,6 +442,12 @@ class CloudGoogleAssistant extends SubscribeMixin(LitElement) { private _openDomainToggler() { showDomainTogglerDialog(this, { + title: this.hass!.localize( + "ui.panel.config.cloud.google.manage_defaults" + ), + description: this.hass!.localize( + "ui.panel.config.cloud.google.manage_defaults_dialog_description" + ), domains: this._entities!.map((entity) => computeDomain(entity.entity_id) ).filter((value, idx, self) => self.indexOf(value) === idx), diff --git a/src/panels/config/core/ha-config-section-general.ts b/src/panels/config/core/ha-config-section-general.ts index 2f9588e8ad..a2123ddf24 100644 --- a/src/panels/config/core/ha-config-section-general.ts +++ b/src/panels/config/core/ha-config-section-general.ts @@ -273,6 +273,15 @@ class HaConfigSectionGeneral extends LitElement { } button.progress = true; + let locationConfig; + + if (this._location) { + locationConfig = { + latitude: this._location[0], + longitude: this._location[1], + }; + } + try { await saveCoreConfig(this.hass, { currency: this._currency, @@ -280,6 +289,7 @@ class HaConfigSectionGeneral extends LitElement { unit_system: this._unitSystem, time_zone: this._timeZone, location_name: this._name, + ...locationConfig, }); button.actionSuccess(); } catch (err: any) { diff --git a/src/panels/config/core/ha-config-system-navigation.ts b/src/panels/config/core/ha-config-system-navigation.ts index e8a9584326..210e9b3bf3 100644 --- a/src/panels/config/core/ha-config-system-navigation.ts +++ b/src/panels/config/core/ha-config-system-navigation.ts @@ -139,12 +139,6 @@ class HaConfigSystemNavigation extends LitElement { hasSecondary > - ${this.hass.userData?.showAdvanced - ? html` - Looking for YAML Configuration? It has moved to - Developer Tools - ` - : ""} `; diff --git a/src/panels/config/dashboard/ha-config-updates.ts b/src/panels/config/dashboard/ha-config-updates.ts index dcb02c4de4..a24bce31e2 100644 --- a/src/panels/config/dashboard/ha-config-updates.ts +++ b/src/panels/config/dashboard/ha-config-updates.ts @@ -1,6 +1,5 @@ import "@material/mwc-button/mwc-button"; import "@material/mwc-list/mwc-list"; -import "@material/mwc-list/mwc-list-item"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; @@ -10,6 +9,7 @@ import "../../../components/ha-icon-next"; import type { UpdateEntity } from "../../../data/update"; import type { HomeAssistant } from "../../../types"; import "../../../components/ha-circular-progress"; +import "../../../components/ha-list-item"; @customElement("ha-config-updates") class HaConfigUpdates extends LitElement { @@ -39,7 +39,7 @@ class HaConfigUpdates extends LitElement { ${updates.map( (entity) => html` - ` : html`` : ""} - + ` )} @@ -135,7 +135,7 @@ class HaConfigUpdates extends LitElement { outline: none; text-decoration: underline; } - mwc-list-item { + ha-list-item { cursor: pointer; font-size: 16px; } diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts index 004a2a6c95..d0843bc2ac 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/device-actions.ts @@ -1,10 +1,18 @@ import { getConfigEntries } from "../../../../../../data/config_entries"; import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; -import { fetchZwaveNodeStatus } from "../../../../../../data/zwave_js"; +import { + fetchZwaveIsAnyFirmwareUpdateInProgress, + fetchZwaveNodeFirmwareUpdateCapabilities, + fetchZwaveNodeIsFirmwareUpdateInProgress, + fetchZwaveNodeStatus, +} from "../../../../../../data/zwave_js"; +import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box"; import type { HomeAssistant } from "../../../../../../types"; import { showZWaveJSHealNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node"; +import { showZWaveJSNodeStatisticsDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics"; import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node"; import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node"; +import { showZWaveJUpdateFirmwareNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node"; import type { DeviceAction } from "../../../ha-config-device-page"; export const getZwaveDeviceActions = async ( @@ -26,13 +34,13 @@ export const getZwaveDeviceActions = async ( const entryId = configEntry.entry_id; - const node = await fetchZwaveNodeStatus(hass, device.id); + const nodeStatus = await fetchZwaveNodeStatus(hass, device.id); - if (!node || node.is_controller_node) { + if (!nodeStatus || nodeStatus.is_controller_node) { return []; } - return [ + const actions = [ { label: hass.localize( "ui.panel.config.zwave_js.device_info.device_config" @@ -52,7 +60,7 @@ export const getZwaveDeviceActions = async ( label: hass.localize("ui.panel.config.zwave_js.device_info.heal_node"), action: () => showZWaveJSHealNodeDialog(el, { - device: device, + device, }), }, { @@ -64,5 +72,57 @@ export const getZwaveDeviceActions = async ( device_id: device.id, }), }, + { + label: hass.localize( + "ui.panel.config.zwave_js.device_info.node_statistics" + ), + action: () => + showZWaveJSNodeStatisticsDialog(el, { + device, + }), + }, ]; + + if (!nodeStatus.ready) { + return actions; + } + + const [ + firmwareUpdateCapabilities, + isAnyFirmwareUpdateInProgress, + isNodeFirmwareUpdateInProgress, + ] = await Promise.all([ + fetchZwaveNodeFirmwareUpdateCapabilities(hass, device.id), + fetchZwaveIsAnyFirmwareUpdateInProgress(hass, entryId), + fetchZwaveNodeIsFirmwareUpdateInProgress(hass, device.id), + ]); + + if ( + firmwareUpdateCapabilities.firmware_upgradable && + (!isAnyFirmwareUpdateInProgress || isNodeFirmwareUpdateInProgress) + ) { + actions.push({ + label: hass.localize( + "ui.panel.config.zwave_js.device_info.update_firmware" + ), + action: async () => { + if ( + await showConfirmationDialog(el, { + text: hass.localize( + "ui.panel.config.zwave_js.update_firmware.warning" + ), + dismissText: hass.localize("ui.common.no"), + confirmText: hass.localize("ui.common.yes"), + }) + ) { + showZWaveJUpdateFirmwareNodeDialog(el, { + device, + firmwareUpdateCapabilities, + }); + } + }, + }); + } + + return actions; }; diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts index eae6b47a1b..637b895d62 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts @@ -8,6 +8,7 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import "../../../../../../components/ha-expansion-panel"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { ConfigEntry, getConfigEntries, @@ -17,13 +18,15 @@ import { fetchZwaveNodeStatus, nodeStatus, SecurityClass, + subscribeZwaveNodeStatus, ZWaveJSNodeStatus, } from "../../../../../../data/zwave_js"; import { haStyle } from "../../../../../../resources/styles"; import { HomeAssistant } from "../../../../../../types"; +import { SubscribeMixin } from "../../../../../../mixins/subscribe-mixin"; @customElement("ha-device-info-zwave_js") -export class HaDeviceInfoZWaveJS extends LitElement { +export class HaDeviceInfoZWaveJS extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public device!: DeviceRegistryEntry; @@ -41,6 +44,21 @@ export class HaDeviceInfoZWaveJS extends LitElement { } } + public hassSubscribe(): Array> { + return [ + subscribeZwaveNodeStatus(this.hass, this.device!.id, (message) => { + if (!this._node) { + return; + } + this._node = { + ...this._node, + status: message.status, + ready: message.ready, + }; + }), + ]; + } + protected async _fetchNodeDetails() { if (!this.device) { return; diff --git a/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts b/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts index cb287db2a1..662e8f3ca9 100644 --- a/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts +++ b/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts @@ -181,6 +181,9 @@ class DialogDeviceRegistryDetail extends LitElement { } ha-switch { margin-right: 16px; + margin-inline-end: 16px; + margin-inline-start: initial; + direction: var(--direction); } .row { margin-top: 8px; diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index e5cb27109d..a91b6eb9d2 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -1265,8 +1265,11 @@ export class HaConfigDevicePage extends LitElement { .card-header ha-icon-button { margin-right: -8px; + margin-inline-end: -8px; + margin-inline-start: initial; color: var(--primary-color); height: auto; + direction: var(--direction); } .device-info { @@ -1332,6 +1335,9 @@ export class HaConfigDevicePage extends LitElement { .header-right > *:not(:first-child) { margin-left: 16px; + margin-inline-start: 16px; + margin-inline-end: initial; + direction: var(--direction); } .battery { diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index 3750446e0a..799d221004 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -110,6 +110,14 @@ const OVERRIDE_SENSOR_UNITS = { pressure: ["hPa", "Pa", "kPa", "bar", "cbar", "mbar", "mmHg", "inHg", "psi"], }; +const OVERRIDE_WEATHER_UNITS = { + precipitation: ["mm", "in"], + pressure: ["hPa", "mbar", "mmHg", "inHg"], + temperature: ["°C", "°F"], + visibility: ["km", "mi"], + wind_speed: ["ft/s", "km/h", "kn", "mph", "m/s"], +}; + const SWITCH_AS_DOMAINS = ["cover", "fan", "light", "lock", "siren"]; @customElement("entity-registry-settings") @@ -140,6 +148,16 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @state() private _unit_of_measurement?: string | null; + @state() private _precipitation_unit?: string | null; + + @state() private _pressure_unit?: string | null; + + @state() private _temperature_unit?: string | null; + + @state() private _visibility_unit?: string | null; + + @state() private _wind_speed_unit?: string | null; + @state() private _error?: string; @state() private _submitting?: boolean; @@ -223,6 +241,16 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { this._unit_of_measurement = stateObj?.attributes?.unit_of_measurement; } + if (domain === "weather") { + const stateObj: HassEntity | undefined = + this.hass.states[this.entry.entity_id]; + this._precipitation_unit = stateObj?.attributes?.precipitation_unit; + this._pressure_unit = stateObj?.attributes?.pressure_unit; + this._temperature_unit = stateObj?.attributes?.temperature_unit; + this._visibility_unit = stateObj?.attributes?.visibility_unit; + this._wind_speed_unit = stateObj?.attributes?.wind_speed_unit; + } + const deviceClasses: string[][] = OVERRIDE_DEVICE_CLASSES[domain]; if (!deviceClasses) { @@ -333,7 +361,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { ` : ""} - ${this._deviceClass && + ${domain === "sensor" && + this._deviceClass && stateObj?.attributes.unit_of_measurement && OVERRIDE_SENSOR_UNITS[this._deviceClass]?.includes( stateObj?.attributes.unit_of_measurement @@ -357,6 +386,90 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { ` : ""} + ${domain === "weather" + ? html` + + ${OVERRIDE_WEATHER_UNITS.precipitation.map( + (unit: string) => html` + ${unit} + ` + )} + + + ${OVERRIDE_WEATHER_UNITS.pressure.map( + (unit: string) => html` + ${unit} + ` + )} + + + ${OVERRIDE_WEATHER_UNITS.temperature.map( + (unit: string) => html` + ${unit} + ` + )} + + + ${OVERRIDE_WEATHER_UNITS.visibility.map( + (unit: string) => html` + ${unit} + ` + )} + + + ${OVERRIDE_WEATHER_UNITS.wind_speed.map( + (unit: string) => html` + ${unit} + ` + )} + + ` + : ""} ${domain === "switch" ? html` a[1].localeCompare(b[1])); content = html` - ${items.map(([domain, label]) => { - // Only OG helpers need to be loaded prior adding one - const isLoaded = - !(domain in HELPERS) || isComponentLoaded(this.hass, domain); - return html` - - - ${label} - - ${!isLoaded - ? html` - ${this.hass.localize( - "ui.dialogs.helper_settings.platform_not_loaded", - "platform", - domain - )} - ` - : ""} - `; - })} + + ${items.map(([domain, label]) => { + // Only OG helpers need to be loaded prior adding one + const isLoaded = + !(domain in HELPERS) || isComponentLoaded(this.hass, domain); + return html` + + + ${label} + + ${!isLoaded + ? html` + ${this.hass.localize( + "ui.dialogs.helper_settings.platform_not_loaded", + "platform", + domain + )} + ` + : ""} + `; + })} + ${this.hass!.localize("ui.common.cancel")} @@ -220,15 +229,6 @@ export class DialogHelperDetail extends LitElement { } } - private _handleEnter(ev: KeyboardEvent) { - if (ev.keyCode !== 13) { - return; - } - ev.stopPropagation(); - ev.preventDefault(); - this._domainPicked(ev); - } - private _domainPicked(ev: Event): void { const domain = (ev.currentTarget! as any).domain; diff --git a/src/panels/config/info/ha-config-info.ts b/src/panels/config/info/ha-config-info.ts index 9412160247..8c9e92cd54 100644 --- a/src/panels/config/info/ha-config-info.ts +++ b/src/panels/config/info/ha-config-info.ts @@ -1,6 +1,18 @@ +import "@material/mwc-list/mwc-list"; +import { + mdiBug, + mdiFileDocument, + mdiHandsPray, + mdiHelp, + mdiHomeAssistant, + mdiPower, + mdiTshirtCrew, +} from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { property, state } from "lit/decorators"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import "../../../components/ha-card"; +import "../../../components/ha-clickable-list-item"; import "../../../components/ha-logo-svg"; import { fetchHassioHassOsInfo, @@ -9,12 +21,61 @@ import { import { fetchHassioInfo, HassioInfo } from "../../../data/hassio/supervisor"; import "../../../layouts/hass-subpage"; import { haStyle } from "../../../resources/styles"; -import { HomeAssistant, Route } from "../../../types"; +import type { HomeAssistant, Route } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; -const JS_TYPE = __BUILD__; const JS_VERSION = __VERSION__; +const PAGES: Array<{ + name: string; + path: string; + iconPath: string; + iconColor: string; +}> = [ + { + name: "change_log", + path: "/latest-release-notes/", + iconPath: mdiPower, + iconColor: "#4A5963", + }, + { + name: "thanks", + path: "/developers/credits/", + iconPath: mdiHandsPray, + iconColor: "#3B808E", + }, + { + name: "merch", + path: "/merch", + iconPath: mdiTshirtCrew, + iconColor: "#C65326", + }, + { + name: "feature", + path: "/feature-requests", + iconPath: mdiHomeAssistant, + iconColor: "#0D47A1", + }, + { + name: "bug", + path: "/issues", + iconPath: mdiBug, + iconColor: "#F1C447", + }, + { + name: "help", + path: "/community", + iconPath: mdiHelp, + iconColor: "#B1345C", + }, + { + name: "license", + path: "/developers/license/", + iconPath: mdiFileDocument, + iconColor: "#518C43", + }, +]; + class HaConfigInfo extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -42,96 +103,76 @@ class HaConfigInfo extends LitElement { back-path="/config" .header=${this.hass.localize("ui.panel.config.info.caption")} > -
- - + +
+ + + + +
+ Home Assistant ${hass.connection.haVersion} + ${this._hassioInfo + ? html`Supervisor ${this._hassioInfo.supervisor}` + : ""} + ${this._osInfo?.version + ? html`Operating System ${this._osInfo.version}` + : ""} + + ${this.hass.localize( + "ui.panel.config.info.frontend_version", + "version", + JS_VERSION + )} + +
+
+ + ${PAGES.map( + (page) => html` + +
+ +
+ + ${this.hass.localize( + `ui.panel.config.info.items.${page.name}` + )} + +
+ ` )} - > - - -
-

Home Assistant Core ${hass.connection.haVersion}

- ${this._hassioInfo - ? html` -

- Home Assistant Supervisor ${this._hassioInfo.supervisor} -

- ` - : ""} - ${this._osInfo?.version - ? html`

Home Assistant OS ${this._osInfo.version}

` - : ""} -

- ${this.hass.localize( - "ui.panel.config.info.path_configuration", - "path", - hass.config.config_dir - )} -

-

- - ${this.hass.localize("ui.panel.config.info.developed_by")} - -

-

- ${this.hass.localize("ui.panel.config.info.license")}
- ${this.hass.localize("ui.panel.config.info.source")} - ${this.hass.localize("ui.panel.config.info.server")} - — - ${this.hass.localize("ui.panel.config.info.frontend")} -

-

- ${this.hass.localize("ui.panel.config.info.built_using")} - Python 3, - Lit, - ${this.hass.localize("ui.panel.config.info.icons_by")} - Google - ${this.hass.localize("ui.common.and")} - Material Design Icons. -

-

- ${this.hass.localize( - "ui.panel.config.info.frontend_version", - "version", - JS_VERSION, - "type", - JS_TYPE - )} - ${customUiList.length > 0 - ? html` -

+ +

+ ${this.hass.localize( + "ui.panel.config.info.path_configuration", + "path", + hass.config.config_dir + )} +

+ ${!customUiList.length + ? "" + : html` +
${this.hass.localize("ui.panel.config.info.custom_uis")} ${customUiList.map( (item) => html` @@ -142,9 +183,8 @@ class HaConfigInfo extends LitElement { ` )}
- ` - : ""} -

+ `} +
`; @@ -176,40 +216,87 @@ class HaConfigInfo extends LitElement { this._osInfo = osInfo; } + private _entryClicked(ev) { + ev.currentTarget.blur(); + } + static get styles(): CSSResultGroup { return [ haStyle, css` - :host { - -ms-user-select: initial; - -webkit-user-select: initial; - -moz-user-select: initial; + .content { + padding: 28px 20px 0; + max-width: 1040px; + margin: 0 auto; } - .about { - text-align: center; - line-height: 2em; - } - - .version { - @apply --paper-font-headline; - } - - .develop { - @apply --paper-font-subhead; - } - - .about a { - color: var(--primary-color); - } ha-logo-svg { padding: 12px; - height: 180px; - width: 180px; + height: 150px; + width: 150px; } - h4 { - font-weight: 400; + ha-card { + padding: 16px; + max-width: 600px; + margin: 0 auto; + margin-bottom: 24px; + margin-bottom: max(24px, env(safe-area-inset-bottom)); + } + + .logo-versions { + display: flex; + justify-content: flex-start; + align-items: center; + } + + .versions { + display: flex; + flex-direction: column; + color: var(--secondary-text-color); + padding: 12px 0; + align-self: stretch; + justify-content: flex-start; + } + + .ha-version { + color: var(--primary-text-color); + font-weight: 500; + font-size: 16px; + } + + mwc-list { + --mdc-list-side-padding: 4px; + } + + ha-svg-icon { + height: 24px; + width: 24px; + display: block; + padding: 8px; + color: #fff; + } + + .icon-background { + border-radius: 50%; + } + + @media all and (max-width: 500px), all and (max-height: 500px) { + ha-logo-svg { + height: 100px; + width: 100px; + } + } + + .config-path { + color: var(--secondary-text-color); + text-align: center; + font-style: italic; + } + + .custom-ui { + color: var(--secondary-text-color); + text-align: center; } `, ]; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-node-statistics.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-node-statistics.ts new file mode 100644 index 0000000000..b68eb367c0 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-node-statistics.ts @@ -0,0 +1,477 @@ +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import "@material/mwc-list/mwc-list"; +import "@material/mwc-list/mwc-list-item"; +import "../../../../../components/ha-expansion-panel"; +import "../../../../../components/ha-help-tooltip"; +import "../../../../../components/ha-svg-icon"; +import { mdiSwapHorizontal } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { + DeviceRegistryEntry, + computeDeviceName, + subscribeDeviceRegistry, +} from "../../../../../data/device_registry"; +import { + subscribeZwaveNodeStatistics, + ProtocolDataRate, + ZWaveJSNodeStatisticsUpdatedMessage, + ZWaveJSRouteStatistics, + RssiError, +} from "../../../../../data/zwave_js"; +import { haStyleDialog } from "../../../../../resources/styles"; +import { HomeAssistant } from "../../../../../types"; +import { ZWaveJSNodeStatisticsDialogParams } from "./show-dialog-zwave_js-node-statistics"; +import { createCloseHeading } from "../../../../../components/ha-dialog"; + +type WorkingRouteStatistics = + | (ZWaveJSRouteStatistics & { + repeater_rssi_table?: TemplateResult; + rssi_translated?: TemplateResult | string; + route_failed_between_translated?: [string, string]; + }) + | undefined; + +@customElement("dialog-zwave_js-node-statistics") +class DialogZWaveJSNodeStatistics extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private device?: DeviceRegistryEntry; + + @state() private _nodeStatistics?: ZWaveJSNodeStatisticsUpdatedMessage & { + rssi_translated?: TemplateResult | string; + }; + + @state() private _deviceIDsToName: { [key: string]: string } = {}; + + @state() private _workingRoutes: { + lwr?: WorkingRouteStatistics; + nlwr?: WorkingRouteStatistics; + } = {}; + + private _subscribedNodeStatistics?: Promise; + + private _subscribedDeviceRegistry?: UnsubscribeFunc; + + public showDialog(params: ZWaveJSNodeStatisticsDialogParams): void { + this.device = params.device; + this._subscribeDeviceRegistry(); + this._subscribeNodeStatistics(); + } + + public closeDialog(): void { + this._nodeStatistics = undefined; + this.device = undefined; + + this._unsubscribe(); + + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this.device) { + return html``; + } + + return html` + + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_tx.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_tx.tooltip" + )} + + ${this._nodeStatistics?.commands_tx} + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_rx.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_rx.tooltip" + )} + + ${this._nodeStatistics?.commands_rx} + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_dropped_tx.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_dropped_tx.tooltip" + )} + + ${this._nodeStatistics?.commands_dropped_tx} + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_dropped_rx.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.commands_dropped_rx.tooltip" + )} + + ${this._nodeStatistics?.commands_dropped_rx} + + + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.timeout_response.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.timeout_response.tooltip" + )} + + ${this._nodeStatistics?.timeout_response} + + ${this._nodeStatistics?.rtt + ? html` + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.rtt.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.rtt.tooltip" + )} + + ${this._nodeStatistics.rtt} + ` + : ``} + ${this._nodeStatistics?.rssi_translated + ? html` + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.rssi.label" + )} + + ${this.hass.localize( + "ui.panel.config.zwave_js.node_statistics.rssi.tooltip" + )} + + ${this._nodeStatistics.rssi_translated} + ` + : ``} + + ${Object.entries(this._workingRoutes).map(([wrKey, wrValue]) => + wrValue + ? html` + +
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.protocol.label" + )} + + ${this.hass.localize( + `ui.panel.config.zwave_js.route_statistics.protocol.protocol_data_rate.${ + ProtocolDataRate[wrValue.protocol_data_rate] + }` + )} +
+
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.data_rate.label" + )} + + ${this.hass.localize( + `ui.panel.config.zwave_js.route_statistics.data_rate.protocol_data_rate.${ + ProtocolDataRate[wrValue.protocol_data_rate] + }` + )} +
+ ${wrValue.rssi_translated + ? html`
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.rssi.label" + )} + + ${wrValue.rssi_translated} +
` + : ``} +
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.route_failed_between.label" + )} + + + ${wrValue.route_failed_between_translated + ? html`${wrValue + .route_failed_between_translated[0]}${wrValue.route_failed_between_translated[1]}` + : this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.route_failed_between.not_applicable" + )} + +
+
+ + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.repeaters.label" + )} + + ${wrValue.repeater_rssi_table + ? html`
+ ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.repeaters.repeaters" + )}: + ${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.repeaters.rssi" + )}: +
+ ${wrValue.repeater_rssi_table}` + : html`${this.hass.localize( + "ui.panel.config.zwave_js.route_statistics.repeaters.direct" + )}`}
+
+
+ ` + : `` + )} +
+ `; + } + + private _computeRSSI( + rssi: number, + includeUnit: boolean + ): TemplateResult | string { + if (Object.values(RssiError).includes(rssi)) { + return html``; + } + if (includeUnit) { + return `${rssi} + ${this.hass.localize("ui.panel.config.zwave_js.rssi.unit")}`; + } + return rssi.toString(); + } + + private _computeDeviceNameById(device_id: string): "unknown device" | string { + if (!this._deviceIDsToName) { + return "unknown device"; + } + const device = this._deviceIDsToName[device_id]; + if (!device) { + return "unknown device"; + } + + return this._deviceIDsToName[device_id] || "unknown device"; + } + + private _subscribeNodeStatistics(): void { + if (!this.hass) { + return; + } + this._subscribedNodeStatistics = subscribeZwaveNodeStatistics( + this.hass, + this.device!.id, + (message: ZWaveJSNodeStatisticsUpdatedMessage) => { + this._nodeStatistics = { + ...message, + rssi_translated: message.rssi + ? this._computeRSSI(message.rssi, false) + : undefined, + }; + + const workingRoutesValueMap: [ + string, + WorkingRouteStatistics | null | undefined + ][] = [ + ["lwr", this._nodeStatistics?.lwr], + ["nlwr", this._nodeStatistics?.nlwr], + ]; + + const workingRoutes: { + lwr?: WorkingRouteStatistics; + nlwr?: WorkingRouteStatistics; + } = {}; + workingRoutesValueMap.forEach(([wrKey, wrValue]) => { + workingRoutes[wrKey] = wrValue; + + if (wrValue) { + if (wrValue.rssi) { + wrValue.rssi_translated = this._computeRSSI(wrValue.rssi, true); + } + + if (wrValue.route_failed_between) { + wrValue.route_failed_between_translated = [ + this._computeDeviceNameById(wrValue.route_failed_between[0]), + this._computeDeviceNameById(wrValue.route_failed_between[1]), + ]; + } + + if (wrValue.repeaters && wrValue.repeaters.length) { + wrValue.repeater_rssi_table = html`${wrValue.repeaters.map( + (_, idx) => + html`
+ ${this._computeDeviceNameById( + wrValue.repeaters[idx] + )}: + ${this._computeRSSI( + wrValue.repeater_rssi[idx], + true + )} +
` + )}`; + } + } + }); + this._workingRoutes = workingRoutes; + } + ); + } + + private _subscribeDeviceRegistry(): void { + if (!this.hass) { + return; + } + this._subscribedDeviceRegistry = subscribeDeviceRegistry( + this.hass.connection, + (devices: DeviceRegistryEntry[]) => { + const devicesIdToName = {}; + devices.forEach((device) => { + devicesIdToName[device.id] = computeDeviceName(device, this.hass); + }); + this._deviceIDsToName = devicesIdToName; + } + ); + } + + private _unsubscribe(): void { + if (this._subscribedNodeStatistics) { + this._subscribedNodeStatistics.then((unsub) => unsub()); + this._subscribedNodeStatistics = undefined; + } + if (this._subscribedDeviceRegistry) { + this._subscribedDeviceRegistry(); + this._subscribedDeviceRegistry = undefined; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + mwc-list-item { + height: 60px; + } + + .row { + display: flex; + justify-content: space-between; + } + + .table { + display: table; + } + + .key-cell { + display: table-cell; + padding-right: 5px; + } + + .value-cell { + display: table-cell; + padding-left: 5px; + } + + span[slot="meta"] { + font-size: 0.95em; + color: var(--primary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-zwave_js-node-statistics": DialogZWaveJSNodeStatistics; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts new file mode 100644 index 0000000000..b0f76d331d --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-update-firmware-node.ts @@ -0,0 +1,461 @@ +import "../../../../../components/ha-file-upload"; +import "../../../../../components/ha-form/ha-form"; +import "../../../../../components/ha-svg-icon"; +import "@material/mwc-button/mwc-button"; +import "@material/mwc-linear-progress/mwc-linear-progress"; +import { mdiCheckCircle, mdiCloseCircle, mdiFileUpload } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { createCloseHeading } from "../../../../../components/ha-dialog"; +import { + DeviceRegistryEntry, + computeDeviceName, +} from "../../../../../data/device_registry"; +import { + abortZwaveNodeFirmwareUpdate, + fetchZwaveNodeIsFirmwareUpdateInProgress, + fetchZwaveNodeStatus, + FirmwareUpdateStatus, + NodeStatus, + subscribeZwaveNodeStatus, + subscribeZwaveNodeFirmwareUpdate, + uploadFirmwareAndBeginUpdate, + ZWaveJSNodeFirmwareUpdateFinishedMessage, + ZWaveJSNodeFirmwareUpdateProgressMessage, + ZWaveJSNodeStatusUpdatedMessage, + ZWaveJSNodeFirmwareUpdateCapabilities, + ZWaveJSNodeStatus, +} from "../../../../../data/zwave_js"; +import { haStyleDialog } from "../../../../../resources/styles"; +import { HomeAssistant } from "../../../../../types"; +import { ZWaveJSUpdateFirmwareNodeDialogParams } from "./show-dialog-zwave_js-update-firmware-node"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../../../dialogs/generic/show-dialog-box"; +import { HaFormIntegerSchema } from "../../../../../components/ha-form/types"; + +@customElement("dialog-zwave_js-update-firmware-node") +class DialogZWaveJSUpdateFirmwareNode extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private device?: DeviceRegistryEntry; + + @state() private _uploading = false; + + @state() + private _updateFinishedMessage?: ZWaveJSNodeFirmwareUpdateFinishedMessage; + + @state() + private _updateProgressMessage?: ZWaveJSNodeFirmwareUpdateProgressMessage; + + @state() private _updateInProgress = false; + + @state() private _firmwareFile?: File; + + @state() private _nodeStatus?: ZWaveJSNodeStatus; + + @state() private _firmwareTarget? = 0; + + private _subscribedNodeStatus?: Promise; + + private _subscribedNodeFirmwareUpdate?: Promise; + + private _deviceName?: string; + + private _firmwareUpdateCapabilities?: ZWaveJSNodeFirmwareUpdateCapabilities; + + public showDialog(params: ZWaveJSUpdateFirmwareNodeDialogParams): void { + this._deviceName = computeDeviceName(params.device, this.hass!); + this.device = params.device; + this._firmwareUpdateCapabilities = params.firmwareUpdateCapabilities; + this._fetchData(); + this._subscribeNodeStatus(); + } + + public closeDialog(): void { + this._unsubscribeNodeFirmwareUpdate(); + this._unsubscribeNodeStatus(); + this.device = + this._updateProgressMessage = + this._updateFinishedMessage = + this._firmwareFile = + this._nodeStatus = + this._firmwareUpdateCapabilities = + undefined; + this._firmwareTarget = 0; + this._uploading = this._updateInProgress = false; + + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private _schema = memoizeOne( + ( + firmwareUpdateCapabilities: ZWaveJSNodeFirmwareUpdateCapabilities + ): HaFormIntegerSchema => { + if (!firmwareUpdateCapabilities.firmware_upgradable) { + // We should never get here, this is to pass type checks + throw new Error(); + } + return { + name: "firmware_target", + type: "integer", + valueMin: Math.min(...firmwareUpdateCapabilities.firmware_targets), + valueMax: Math.max(...firmwareUpdateCapabilities.firmware_targets), + }; + } + ); + + protected render(): TemplateResult { + if ( + !this.device || + !this._nodeStatus || + !this._firmwareUpdateCapabilities || + !this._firmwareUpdateCapabilities.firmware_upgradable || + this._updateInProgress === undefined + ) { + return html``; + } + + const beginFirmwareUpdateHTML = html` + ${this._firmwareUpdateCapabilities.firmware_targets.length > 1 + ? html`

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.firmware_target_intro" + )} +

+ ` + : ""} + + ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.begin_update" + )} + `; + + const abortFirmwareUpdateButton = html` + + ${this.hass.localize("ui.panel.config.zwave_js.update_firmware.abort")} + + `; + + const status = this._updateFinishedMessage + ? FirmwareUpdateStatus[this._updateFinishedMessage.status] + .split("_")[0] + .toLowerCase() + : undefined; + + return html` + + ${!this._updateProgressMessage && !this._updateFinishedMessage + ? !this._updateInProgress + ? html` +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.introduction", + { + device: html`${this._deviceName}`, + } + )} +

+ ${beginFirmwareUpdateHTML} + ` + : html` +

+ ${this._nodeStatus.status === NodeStatus.Asleep + ? this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.queued", + { + device: html`${this._deviceName}`, + } + ) + : this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.awake", + { + device: html`${this._deviceName}`, + } + )} +

+

+ ${this._nodeStatus.status === NodeStatus.Asleep + ? this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.close_queued", + { + device: html`${this._deviceName}`, + } + ) + : this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.close", + { + device: html`${this._deviceName}`, + } + )} +

+ ${abortFirmwareUpdateButton} + ` + : this._updateProgressMessage && !this._updateFinishedMessage + ? html` +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.in_progress", + { + device: html`${this._deviceName}`, + progress: ( + (this._updateProgressMessage.sent_fragments * 100) / + this._updateProgressMessage.total_fragments + ).toFixed(2), + } + )} +

+ +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.close", + { + device: html`${this._deviceName}`, + } + )} +

+ ${abortFirmwareUpdateButton} + ` + : html` +
+ +
+

+ ${this.hass.localize( + `ui.panel.config.zwave_js.update_firmware.finished_status.${status}`, + { + device: html`${this._deviceName}`, + message: this.hass.localize( + `ui.panel.config.zwave_js.update_firmware.finished_status.${ + FirmwareUpdateStatus[ + this._updateFinishedMessage!.status + ] + }` + ), + } + )} +

+
+
+ ${status === "ok" + ? html`

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.finished_status.done" + )} +

` + : html`

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.finished_status.try_again" + )} +

+ ${beginFirmwareUpdateHTML}`} +

+ ${this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.finished_status.try_again" + )} +

+ ${beginFirmwareUpdateHTML} + `} +
+ `; + } + + private async _fetchData(): Promise { + [this._nodeStatus, this._updateInProgress] = await Promise.all([ + fetchZwaveNodeStatus(this.hass, this.device!.id), + fetchZwaveNodeIsFirmwareUpdateInProgress(this.hass, this.device!.id), + ]); + if (this._updateInProgress) { + this._subscribeNodeFirmwareUpdate(); + } + } + + private async _beginFirmwareUpdate(): Promise { + this._uploading = true; + this._updateProgressMessage = this._updateFinishedMessage = undefined; + try { + this._subscribeNodeFirmwareUpdate(); + await uploadFirmwareAndBeginUpdate( + this.hass, + this.device!.id, + this._firmwareFile!, + this._firmwareTarget + ); + this._updateInProgress = true; + this._uploading = false; + } catch (err: any) { + this._unsubscribeNodeFirmwareUpdate(); + this._uploading = false; + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.upload_failed" + ), + text: err.message, + confirmText: this.hass!.localize("ui.common.close"), + }); + } + } + + private async _abortFirmwareUpdate(): Promise { + if ( + await showConfirmationDialog(this, { + text: this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.confirm_abort", + { + device: html`${this._deviceName}`, + } + ), + dismissText: this.hass!.localize("ui.common.no"), + confirmText: this.hass!.localize("ui.common.yes"), + }) + ) { + this._unsubscribeNodeFirmwareUpdate(); + try { + await abortZwaveNodeFirmwareUpdate(this.hass, this.device!.id); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.zwave_js.update_firmware.abort_failed" + ), + text: err.message, + confirmText: this.hass!.localize("ui.common.close"), + }); + } + this._firmwareFile = undefined; + this._updateFinishedMessage = undefined; + this._updateProgressMessage = undefined; + this._updateInProgress = false; + } + } + + private _subscribeNodeStatus(): void { + if (!this.hass || !this.device || this._subscribedNodeStatus) { + return; + } + this._subscribedNodeStatus = subscribeZwaveNodeStatus( + this.hass, + this.device.id, + (message: ZWaveJSNodeStatusUpdatedMessage) => { + this._nodeStatus!.status = message.status; + } + ); + } + + private _unsubscribeNodeStatus(): void { + if (!this._subscribedNodeStatus) { + return; + } + this._subscribedNodeStatus.then((unsub) => unsub()); + this._subscribedNodeStatus = undefined; + } + + private _subscribeNodeFirmwareUpdate(): void { + if (!this.hass || !this.device || this._subscribedNodeFirmwareUpdate) { + return; + } + this._subscribedNodeFirmwareUpdate = subscribeZwaveNodeFirmwareUpdate( + this.hass, + this.device.id, + ( + message: + | ZWaveJSNodeFirmwareUpdateFinishedMessage + | ZWaveJSNodeFirmwareUpdateProgressMessage + ) => { + if (message.event === "firmware update progress") { + if (!this._updateFinishedMessage) { + this._updateProgressMessage = message; + } + } else { + this._unsubscribeNodeFirmwareUpdate(); + this._updateProgressMessage = undefined; + this._updateInProgress = false; + this._updateFinishedMessage = message; + } + } + ); + } + + private _unsubscribeNodeFirmwareUpdate(): void { + if (!this._subscribedNodeFirmwareUpdate) { + return; + } + this._subscribedNodeFirmwareUpdate.then((unsub) => unsub()); + this._subscribedNodeFirmwareUpdate = undefined; + } + + private async _firmwareTargetChanged(ev) { + this._firmwareTarget = ev.detail.value.firmware_target; + } + + private async _uploadFile(ev) { + this._firmwareFile = ev.detail.files[0]; + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + .ok { + color: var(--success-color); + } + + .error { + color: var(--error-color); + } + + .flex-container { + display: flex; + align-items: center; + margin-bottom: 5px; + } + + ha-svg-icon { + width: 68px; + height: 48px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-zwave_js-update-firmware-node": DialogZWaveJSUpdateFirmwareNode; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics.ts new file mode 100644 index 0000000000..ec48d7f02c --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics.ts @@ -0,0 +1,20 @@ +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { DeviceRegistryEntry } from "../../../../../data/device_registry"; + +export interface ZWaveJSNodeStatisticsDialogParams { + device: DeviceRegistryEntry; +} + +export const loadNodeStatisticsDialog = () => + import("./dialog-zwave_js-node-statistics"); + +export const showZWaveJSNodeStatisticsDialog = ( + element: HTMLElement, + nodeStatisticsDialogParams: ZWaveJSNodeStatisticsDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-zwave_js-node-statistics", + dialogImport: loadNodeStatisticsDialog, + dialogParams: nodeStatisticsDialogParams, + }); +}; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node.ts new file mode 100644 index 0000000000..dc45f309f6 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node.ts @@ -0,0 +1,22 @@ +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { DeviceRegistryEntry } from "../../../../../data/device_registry"; +import { ZWaveJSNodeFirmwareUpdateCapabilities } from "../../../../../data/zwave_js"; + +export interface ZWaveJSUpdateFirmwareNodeDialogParams { + device: DeviceRegistryEntry; + firmwareUpdateCapabilities: ZWaveJSNodeFirmwareUpdateCapabilities; +} + +export const loadUpdateFirmwareNodeDialog = () => + import("./dialog-zwave_js-update-firmware-node"); + +export const showZWaveJUpdateFirmwareNodeDialog = ( + element: HTMLElement, + updateFirmwareNodeDialogParams: ZWaveJSUpdateFirmwareNodeDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-zwave_js-update-firmware-node", + dialogImport: loadUpdateFirmwareNodeDialog, + dialogParams: updateFirmwareNodeDialogParams, + }); +}; diff --git a/src/panels/config/logs/ha-config-logs.ts b/src/panels/config/logs/ha-config-logs.ts index 1981a08a25..d8f811fcf4 100644 --- a/src/panels/config/logs/ha-config-logs.ts +++ b/src/panels/config/logs/ha-config-logs.ts @@ -6,7 +6,7 @@ import { extractSearchParam } from "../../../common/url/search-params"; import "../../../components/ha-button-menu"; import "../../../components/search-input"; import { LogProvider } from "../../../data/error_log"; -import { fetchHassioSupervisorInfo } from "../../../data/hassio/supervisor"; +import { fetchHassioAddonsInfo } from "../../../data/hassio/addon"; import "../../../layouts/hass-subpage"; import "../../../layouts/hass-tabs-subpage"; import { haStyle } from "../../../resources/styles"; @@ -167,13 +167,15 @@ export class HaConfigLogs extends LitElement { private async _getInstalledAddons() { try { - const supervisorInfo = await fetchHassioSupervisorInfo(this.hass); + const addonsInfo = await fetchHassioAddonsInfo(this.hass); this._logProviders = [ ...this._logProviders, - ...supervisorInfo.addons.map((addon) => ({ - key: addon.slug, - name: addon.name, - })), + ...addonsInfo.addons + .filter((addon) => addon.version) + .map((addon) => ({ + key: addon.slug, + name: addon.name, + })), ]; } catch (err) { // Ignore, nothing the user can do anyway diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index 7caa849c0a..9deb2cf1b1 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -12,6 +12,7 @@ import { } from "date-fns/esm"; import { css, html, LitElement, PropertyValues } from "lit"; import { property, state } from "lit/decorators"; +import { UnsubscribeFunc } from "home-assistant-js-websocket/dist/types"; import { navigate } from "../../common/navigate"; import { createSearchParam, @@ -19,7 +20,7 @@ import { } from "../../common/url/search-params"; import { computeRTL } from "../../common/util/compute_rtl"; import "../../components/chart/state-history-charts"; -import "../../components/entity/ha-entity-picker"; +import "../../components/ha-target-picker"; import "../../components/ha-circular-progress"; import "../../components/ha-date-range-picker"; import type { DateRangePickerRanges } from "../../components/ha-date-range-picker"; @@ -29,8 +30,15 @@ import { computeHistory, fetchDateWS } from "../../data/history"; import "../../layouts/ha-app-layout"; import { haStyle } from "../../resources/styles"; import { HomeAssistant } from "../../types"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../../data/entity_registry"; +import { SubscribeMixin } from "../../mixins/subscribe-mixin"; +import { computeStateName } from "../../common/entity/compute_state_name"; +import { computeDomain } from "../../common/entity/compute_domain"; -class HaPanelHistory extends LitElement { +class HaPanelHistory extends SubscribeMixin(LitElement) { @property() hass!: HomeAssistant; @property({ reflect: true, type: Boolean }) narrow!: boolean; @@ -39,7 +47,7 @@ class HaPanelHistory extends LitElement { @property() _endDate: Date; - @property() _entityId = ""; + @property() _targetPickerValue?; @property() _isLoading = false; @@ -49,6 +57,10 @@ class HaPanelHistory extends LitElement { @state() private _ranges?: DateRangePickerRanges; + @state() private _entities?: EntityRegistryEntry[]; + + @state() private _stateEntities?: EntityRegistryEntry[]; + public constructor() { super(); @@ -61,6 +73,14 @@ class HaPanelHistory extends LitElement { this._endDate = end; } + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeEntityRegistry(this.hass.connection!, (entities) => { + this._entities = entities; + }), + ]; + } + protected render() { return html` @@ -80,25 +100,40 @@ class HaPanelHistory extends LitElement { -
- - - +
+
+ + +
+ ${this._isLoading + ? html`
+ +
` + : html` + + + `}
${this._isLoading ? html`
@@ -142,7 +177,13 @@ class HaPanelHistory extends LitElement { [addDays(weekStart, -7), addDays(weekEnd, -7)], }; - this._entityId = extractSearchParam("entity_id") ?? ""; + const entityIds = extractSearchParam("entity_id"); + if (entityIds) { + const splitEntityIds = entityIds.split(","); + this._targetPickerValue = { + entity_id: splitEntityIds, + }; + } const startDate = extractSearchParam("start_date"); if (startDate) { @@ -158,16 +199,41 @@ class HaPanelHistory extends LitElement { if ( changedProps.has("_startDate") || changedProps.has("_endDate") || - changedProps.has("_entityId") + changedProps.has("_targetPickerValue") || + changedProps.has("_entities") ) { this._getHistory(); } - if (changedProps.has("hass")) { + if (changedProps.has("hass") || changedProps.has("_entities")) { const oldHass = changedProps.get("hass") as HomeAssistant | undefined; if (!oldHass || oldHass.language !== this.hass.language) { this.rtl = computeRTL(this.hass); } + if (this._entities) { + const stateEntities: EntityRegistryEntry[] = []; + const regEntityIds = new Set( + this._entities.map((entity) => entity.entity_id) + ); + for (const entityId of Object.keys(this.hass.states)) { + if (regEntityIds.has(entityId)) { + continue; + } + stateEntities.push({ + name: computeStateName(this.hass.states[entityId]), + entity_id: entityId, + platform: computeDomain(entityId), + disabled_by: null, + hidden_by: null, + area_id: null, + config_entry_id: null, + device_id: null, + icon: null, + entity_category: null, + }); + } + this._stateEntities = stateEntities; + } } } @@ -177,12 +243,16 @@ class HaPanelHistory extends LitElement { private async _getHistory() { this._isLoading = true; - const dateHistory = await fetchDateWS( - this.hass, - this._startDate, - this._endDate, - this._entityId - ); + const entityIds = this._getEntityIds(); + const dateHistory = + entityIds.length === 0 + ? {} + : await fetchDateWS( + this.hass, + this._startDate, + this._endDate, + entityIds + ); this._stateHistory = computeHistory( this.hass, dateHistory, @@ -191,6 +261,52 @@ class HaPanelHistory extends LitElement { this._isLoading = false; } + private _filterEntity(entity: EntityRegistryEntry): boolean { + const { area_id, device_id, entity_id } = this._targetPickerValue; + if (area_id !== undefined) { + if (typeof area_id === "string" && area_id === entity.area_id) { + return true; + } + if (Array.isArray(area_id) && area_id.includes(entity.area_id)) { + return true; + } + } + if (device_id !== undefined) { + if (typeof device_id === "string" && device_id === entity.device_id) { + return true; + } + if (Array.isArray(device_id) && device_id.includes(entity.device_id)) { + return true; + } + } + if (entity_id !== undefined) { + if (typeof entity_id === "string" && entity_id === entity.entity_id) { + return true; + } + if (Array.isArray(entity_id) && entity_id.includes(entity.entity_id)) { + return true; + } + } + return false; + } + + private _getEntityIds(): string[] { + if ( + this._targetPickerValue === undefined || + this._entities === undefined || + this._stateEntities === undefined + ) { + return []; + } + const entityIds = this._entities + .filter((entity) => this._filterEntity(entity)) + .map((entity) => entity.entity_id); + const stateEntityIds = this._stateEntities + .filter((entity) => this._filterEntity(entity)) + .map((entity) => entity.entity_id); + return [...entityIds, ...stateEntityIds]; + } + private _dateRangeChanged(ev) { this._startDate = ev.detail.startDate; const endDate = ev.detail.endDate; @@ -203,8 +319,8 @@ class HaPanelHistory extends LitElement { this._updatePath(); } - private _entityPicked(ev) { - this._entityId = ev.target.value; + private _entitiesChanged(ev) { + this._targetPickerValue = ev.detail.value; this._updatePath(); } @@ -212,8 +328,8 @@ class HaPanelHistory extends LitElement { private _updatePath() { const params: Record = {}; - if (this._entityId) { - params.entity_id = this._entityId; + if (this._targetPickerValue) { + params.entity_id = this._getEntityIds().join(","); } if (this._startDate) { @@ -255,6 +371,18 @@ class HaPanelHistory extends LitElement { height: 100%; } + :host([narrow]) .narrow-wrap { + flex-wrap: wrap; + } + + .horizontal { + align-items: center; + } + + :host(:not([narrow])) .selector-padding { + padding-left: 32px; + } + .progress-wrapper { position: relative; } diff --git a/src/panels/logbook/ha-logbook-renderer.ts b/src/panels/logbook/ha-logbook-renderer.ts index 6e382b3cce..e5384b8778 100644 --- a/src/panels/logbook/ha-logbook-renderer.ts +++ b/src/panels/logbook/ha-logbook-renderer.ts @@ -1,4 +1,5 @@ import "@lit-labs/virtualizer"; +import { VisibilityChangedEvent } from "@lit-labs/virtualizer/Virtualizer"; import { css, CSSResultGroup, @@ -16,7 +17,6 @@ import { restoreScroll } from "../../common/decorators/restore-scroll"; import { fireEvent } from "../../common/dom/fire_event"; import { computeDomain } from "../../common/entity/compute_domain"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; -import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl"; import "../../components/entity/state-badge"; import "../../components/ha-circular-progress"; import "../../components/ha-relative-time"; @@ -35,6 +35,12 @@ import { import { HomeAssistant } from "../../types"; import { brandsUrl } from "../../util/brands-url"; +declare global { + interface HASSDomEvents { + "hass-logbook-live": { enable: boolean }; + } +} + const triggerDomains = ["script", "automation"]; const hasContext = (item: LogbookEntry) => @@ -56,9 +62,6 @@ class HaLogbookRenderer extends LitElement { @property({ type: Boolean, attribute: "narrow" }) public narrow = false; - @property({ attribute: "rtl", type: Boolean }) - private _rtl = false; - @property({ type: Boolean, attribute: "virtualize", reflect: true }) public virtualize = false; @@ -86,18 +89,10 @@ class HaLogbookRenderer extends LitElement { ); } - protected updated(_changedProps: PropertyValues) { - const oldHass = _changedProps.get("hass") as HomeAssistant | undefined; - - if (oldHass === undefined || oldHass.language !== this.hass.language) { - this._rtl = computeRTL(this.hass); - } - } - protected render(): TemplateResult { if (!this.entries?.length) { return html` -
+
${this.hass.localize("ui.components.logbook.entries_not_found")}
`; @@ -107,7 +102,6 @@ class HaLogbookRenderer extends LitElement {
${this.virtualize ? html`; + private _subscribed?: Promise<(() => Promise) | void>; + + private _liveUpdatesEnabled = true; + + private _pendingStreamMessages: LogbookStreamMessage[] = []; private _throttleGetLogbookEntries = throttle( () => this._getLogBookData(), @@ -127,6 +130,7 @@ export class HaLogbook extends LitElement { .entries=${this._logbookEntries} .traceContexts=${this._traceContexts} .userIdToName=${this._userIdToName} + @hass-logbook-live=${this._handleLogbookLive} > `; } @@ -136,7 +140,7 @@ export class HaLogbook extends LitElement { return; } - this._unsubscribe(); + this._unsubscribeSetLoading(); this._throttleGetLogbookEntries.cancel(); this._updateTraceContexts.cancel(); this._updateUsers.cancel(); @@ -148,13 +152,23 @@ export class HaLogbook extends LitElement { ); } - this._logbookEntries = undefined; this._throttleGetLogbookEntries(); } - protected updated(changedProps: PropertyValues): void { - super.updated(changedProps); + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + } + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (changedProps.size !== 1 || !changedProps.has("hass")) { + return true; + } + // We only respond to hass changes if the translations changed + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + return !oldHass || oldHass.localize !== this.hass.localize; + } + + protected updated(changedProps: PropertyValues): void { let changed = changedProps.has("time"); for (const key of ["entityIds", "deviceIds"]) { @@ -180,6 +194,17 @@ export class HaLogbook extends LitElement { } } + private _handleLogbookLive(ev: CustomEvent) { + if (ev.detail.enable && !this._liveUpdatesEnabled) { + // Process everything we queued up while we were scrolled down + this._pendingStreamMessages.forEach((msg) => + this._processStreamMessage(msg) + ); + this._pendingStreamMessages = []; + } + this._liveUpdatesEnabled = ev.detail.enable; + } + private get _filterAlwaysEmptyResults(): boolean { const entityIds = ensureArray(this.entityIds); const deviceIds = ensureArray(this.deviceIds); @@ -194,7 +219,15 @@ export class HaLogbook extends LitElement { private _unsubscribe(): void { if (this._subscribed) { - this._subscribed.then((unsub) => (unsub ? unsub() : undefined)); + this._subscribed.then((unsub) => + unsub + ? unsub().catch(() => { + // The backend will cancel the subscription if + // we subscribe to entities that will all be + // filtered away + }) + : undefined + ); this._subscribed = undefined; } } @@ -208,12 +241,26 @@ export class HaLogbook extends LitElement { public disconnectedCallback() { super.disconnectedCallback(); + this._unsubscribeSetLoading(); + } + + /** Unsubscribe because we are unloading + * or about to resubscribe. + * Setting this._logbookEntries to undefined + * will put the page in a loading state. + */ + private _unsubscribeSetLoading() { + this._logbookEntries = undefined; this._unsubscribe(); } - private _unsubscribeAndEmptyEntries() { - this._unsubscribe(); + /** Unsubscribe because there are no results. + * Setting this._logbookEntries to an empty + * list will show a no results message. + */ + private _unsubscribeNoResults() { this._logbookEntries = []; + this._unsubscribe(); } private _calculateLogbookPeriod() { @@ -252,20 +299,19 @@ export class HaLogbook extends LitElement { // "recent" means start time is a sliding window // so we need to calculate an expireTime to // purge old events - this._processStreamMessage( - streamMessage, - "recent" in this.time - ? findStartOfRecentTime(new Date(), this.time.recent) - : undefined - ); + if (!this._subscribed) { + // Message came in before we had a chance to unload + return; + } + this._processOrQueueStreamMessage(streamMessage); }, logbookPeriod.startTime.toISOString(), logbookPeriod.endTime.toISOString(), ensureArray(this.entityIds), ensureArray(this.deviceIds) ).catch((err) => { - this._error = err.message; this._subscribed = undefined; + this._error = err; }); return true; } @@ -274,7 +320,7 @@ export class HaLogbook extends LitElement { this._error = undefined; if (this._filterAlwaysEmptyResults) { - this._unsubscribeAndEmptyEntries(); + this._unsubscribeNoResults(); return; } @@ -282,7 +328,7 @@ export class HaLogbook extends LitElement { if (logbookPeriod.startTime > logbookPeriod.now) { // Time Travel not yet invented - this._unsubscribeAndEmptyEntries(); + this._unsubscribeNoResults(); return; } @@ -303,10 +349,21 @@ export class HaLogbook extends LitElement { ) : this._logbookEntries; - private _processStreamMessage = ( - streamMessage: LogbookStreamMessage, - purgeBeforePythonTime: number | undefined + private _processOrQueueStreamMessage = ( + streamMessage: LogbookStreamMessage ) => { + if (this._liveUpdatesEnabled) { + this._processStreamMessage(streamMessage); + return; + } + this._pendingStreamMessages.push(streamMessage); + }; + + private _processStreamMessage = (streamMessage: LogbookStreamMessage) => { + const purgeBeforePythonTime = + "recent" in this.time + ? findStartOfRecentTime(new Date(), this.time.recent) + : undefined; // Put newest ones on top. Reverse works in-place so // make a copy first. const newEntries = [...streamMessage.events].reverse(); diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index bcdcb548f7..84f330bb94 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -12,19 +12,17 @@ import { } from "date-fns/esm"; import { css, html, LitElement, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { navigate } from "../../common/navigate"; import { createSearchParam, extractSearchParamsObject, } from "../../common/url/search-params"; -import { computeRTL } from "../../common/util/compute_rtl"; import "../../components/entity/ha-entity-picker"; -import type { HaEntityPickerEntityFilterFunc } from "../../components/entity/ha-entity-picker"; import "../../components/ha-date-range-picker"; import type { DateRangePickerRanges } from "../../components/ha-date-range-picker"; import "../../components/ha-icon-button"; import "../../components/ha-menu-button"; +import { filterLogbookCompatibleEntities } from "../../data/logbook"; import "../../layouts/ha-app-layout"; import { haStyle } from "../../resources/styles"; import { HomeAssistant } from "../../types"; @@ -40,8 +38,6 @@ export class HaPanelLogbook extends LitElement { @state() _entityIds?: string[]; - @property({ reflect: true, type: Boolean }) rtl = false; - @state() private _ranges?: DateRangePickerRanges; public constructor() { @@ -89,7 +85,7 @@ export class HaPanelLogbook extends LitElement { .label=${this.hass.localize( "ui.components.entity.entity-picker.entity" )} - .entityFilter=${this._entityFilter} + .entityFilter=${filterLogbookCompatibleEntities} @change=${this._entityPicked} >
@@ -150,15 +146,6 @@ export class HaPanelLogbook extends LitElement { this._applyURLParams(); }; - protected updated(changedProps: PropertyValues) { - if (changedProps.has("hass")) { - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - if (!oldHass || oldHass.language !== this.hass.language) { - this.rtl = computeRTL(this.hass); - } - } - } - private _applyURLParams() { const searchParams = new URLSearchParams(location.search); @@ -242,17 +229,6 @@ export class HaPanelLogbook extends LitElement { this.shadowRoot!.querySelector("ha-logbook")?.refresh(); } - private _entityFilter: HaEntityPickerEntityFilterFunc = (entity) => { - if (computeStateDomain(entity) !== "sensor") { - return true; - } - - return ( - entity.attributes.unit_of_measurement === undefined && - entity.attributes.state_class === undefined - ); - }; - static get styles() { return [ haStyle, diff --git a/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts b/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts index 0d49566b04..0f8181c44c 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts @@ -56,21 +56,23 @@ export class HuiEnergyCompareCard return html` - You are comparing the period - ${formatDate(this._start!, this.hass.locale)}${dayDifference > 0 - ? ` - - ${formatDate(this._end || endOfDay(new Date()), this.hass.locale)}` - : ""} - with period - ${formatDate(this._startCompare, this.hass.locale)}${dayDifference > - 0 - ? ` - - ${formatDate(this._endCompare, this.hass.locale)}` - : ""} + ${this.hass.localize("ui.panel.energy.compare.info", { + start: html`${formatDate(this._start!, this.hass.locale)}${dayDifference > 0 + ? ` - + ${formatDate(this._end || endOfDay(new Date()), this.hass.locale)}` + : ""}`, + end: html`${formatDate( + this._startCompare, + this.hass.locale + )}${dayDifference > 0 + ? ` - + ${formatDate(this._endCompare, this.hass.locale)}` + : ""}`, + })} `; } diff --git a/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts b/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts index 6b10b5b641..c223a65de8 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-distribution-card.ts @@ -489,8 +489,8 @@ class HuiEnergyDistrubutionCard - ${formatNumber(totalBatteryOut || 0, this.hass.locale, { + >${formatNumber(totalBatteryOut || 0, this.hass.locale, { maximumFractionDigits: 1, })} kWh ${compare - ? html`` + : ""} + ${formatNumber( @@ -862,10 +865,7 @@ export class HuiEnergySourcesTableCard currency: this.hass.config.currency!, } )} - - ${showCosts - ? html`` - : ""}` + ` : ""} @@ -346,6 +346,7 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard { margin: auto; width: 100%; max-width: 300px; + direction: ltr; } #keypad mwc-button { diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts index 41d5345728..64104a6335 100644 --- a/src/panels/lovelace/cards/hui-area-card.ts +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -3,8 +3,10 @@ import { mdiLightbulbMultiple, mdiLightbulbMultipleOff, mdiRun, + mdiThermometer, mdiToggleSwitch, mdiToggleSwitchOff, + mdiWaterAlert, mdiWaterPercent, } from "@mdi/js"; import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; @@ -61,17 +63,21 @@ const TOGGLE_DOMAINS = ["light", "switch", "fan"]; const OTHER_DOMAINS = ["camera"]; const DEVICE_CLASSES = { - sensor: ["temperature"], - binary_sensor: ["motion"], + sensor: ["temperature", "humidity"], + binary_sensor: ["motion", "moisture"], }; const DOMAIN_ICONS = { light: { on: mdiLightbulbMultiple, off: mdiLightbulbMultipleOff }, switch: { on: mdiToggleSwitch, off: mdiToggleSwitchOff }, fan: { on: domainIcon("fan"), off: domainIcon("fan") }, - sensor: { humidity: mdiWaterPercent }, + sensor: { + temperature: mdiThermometer, + humidity: mdiWaterPercent, + }, binary_sensor: { motion: mdiRun, + moisture: mdiWaterAlert, }, }; diff --git a/src/panels/lovelace/cards/hui-gauge-card.ts b/src/panels/lovelace/cards/hui-gauge-card.ts index 4a6d1f60d5..98bdd36f5b 100644 --- a/src/panels/lovelace/cards/hui-gauge-card.ts +++ b/src/panels/lovelace/cards/hui-gauge-card.ts @@ -232,6 +232,7 @@ class HuiGaugeCard extends LitElement implements LovelaceCard { return segments.map((segment) => ({ level: segment?.from, stroke: segment?.color, + label: segment?.label, })); } diff --git a/src/panels/lovelace/cards/hui-light-card.ts b/src/panels/lovelace/cards/hui-light-card.ts index ce5e92c062..595c299785 100644 --- a/src/panels/lovelace/cards/hui-light-card.ts +++ b/src/panels/lovelace/cards/hui-light-card.ts @@ -276,9 +276,12 @@ export class HuiLightCard extends LitElement implements LovelaceCard { cursor: pointer; top: 0; right: 0; + inset-inline-start: initial; + inset-inline-end: 0; border-radius: 100%; color: var(--secondary-text-color); z-index: 1; + direction: var(--direction); } .content { diff --git a/src/panels/lovelace/cards/hui-media-control-card.ts b/src/panels/lovelace/cards/hui-media-control-card.ts index f1d2793ae0..18bf718876 100644 --- a/src/panels/lovelace/cards/hui-media-control-card.ts +++ b/src/panels/lovelace/cards/hui-media-control-card.ts @@ -31,6 +31,7 @@ import { handleMediaControlClick, MediaPickedEvent, MediaPlayerEntity, + mediaPlayerPlayMedia, SUPPORT_BROWSE_MEDIA, SUPPORT_SEEK, SUPPORT_TURN_ON, @@ -489,21 +490,15 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { action: "play", entityId: this._config!.entity, mediaPickedCallback: (pickedMedia: MediaPickedEvent) => - this._playMedia( + mediaPlayerPlayMedia( + this.hass, + this._config!.entity, pickedMedia.item.media_content_id, pickedMedia.item.media_content_type ), }); } - private _playMedia(media_content_id: string, media_content_type: string) { - this.hass!.callService("media_player", "play_media", { - entity_id: this._config!.entity, - media_content_id, - media_content_type, - }); - } - private _handleClick(e: MouseEvent): void { handleMediaControlClick( this.hass!, @@ -605,6 +600,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { ); height: 100%; right: 0; + opacity: 1; transition: width 0.8s, opacity 0.8s linear 0.8s; } @@ -673,6 +669,11 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { transition: padding, color; transition-duration: 0.4s; margin-left: -12px; + margin-inline-start: -12px; + margin-inline-end: initial; + padding-inline-start: 0; + padding-inline-end: 8px; + direction: var(--direction); } .controls > div { @@ -698,6 +699,9 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { ha-icon-button.browse-media { position: absolute; right: 4px; + inset-inline-start: initial; + inset-inline-end: 4px; + direction: var(--direction); --mdc-icon-size: 24px; } @@ -714,12 +718,18 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard { .icon-name ha-state-icon { padding-right: 8px; + padding-inline-start: initial; + padding-inline-end: 8px; + direction: var(--direction); } .more-info { position: absolute; top: 4px; right: 4px; + inset-inline-start: initial; + inset-inline-end: 4px; + direction: var(--direction); } .media-info { diff --git a/src/panels/lovelace/cards/hui-thermostat-card.ts b/src/panels/lovelace/cards/hui-thermostat-card.ts index 564008d158..6d6dbc7dde 100644 --- a/src/panels/lovelace/cards/hui-thermostat-card.ts +++ b/src/panels/lovelace/cards/hui-thermostat-card.ts @@ -497,9 +497,12 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard { cursor: pointer; top: 0; right: 0; + inset-inline-end: 0px; + inset-inline-start: initial; border-radius: 100%; color: var(--secondary-text-color); z-index: 1; + direction: var(--direction); } .content { @@ -550,6 +553,7 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard { height: 50%; top: 45%; left: 50%; + direction: ltr; } #set-values { diff --git a/src/panels/lovelace/cards/hui-weather-forecast-card.ts b/src/panels/lovelace/cards/hui-weather-forecast-card.ts index 7ec93b41e7..91e485d284 100644 --- a/src/panels/lovelace/cards/hui-weather-forecast-card.ts +++ b/src/panels/lovelace/cards/hui-weather-forecast-card.ts @@ -228,12 +228,21 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
- ${formatNumber( - stateObj.attributes.temperature, - this.hass.locale - )} ${getWeatherUnit(this.hass, "temperature")} + ${stateObj.attributes.temperature !== undefined && + stateObj.attributes.temperature !== null + ? html` + ${formatNumber( + stateObj.attributes.temperature, + this.hass.locale + )} ${getWeatherUnit( + this.hass, + stateObj, + "temperature" + )} + ` + : html` `}
${this._config.secondary_info_attribute !== undefined @@ -255,6 +264,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { "wind_speed" ? getWind( this.hass, + stateObj, stateObj.attributes.wind_speed, stateObj.attributes.wind_bearing ) @@ -267,6 +277,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard { )} ${getWeatherUnit( this.hass, + stateObj, this._config.secondary_info_attribute )} `} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index e6189150e1..bf243f624e 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -185,6 +185,7 @@ export interface SeverityConfig { export interface GaugeSegment { from: number; color: string; + label?: string; } export interface GaugeCardConfig extends LovelaceCardConfig { diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts index 6d102da24c..38011288fd 100644 --- a/src/panels/lovelace/components/hui-energy-period-selector.ts +++ b/src/panels/lovelace/components/hui-energy-period-selector.ts @@ -151,7 +151,9 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { dense outlined > - Compare data + ${this.hass.localize( + "ui.panel.lovelace.components.energy_period_selector.compare" + )} ` : html` - Compare data + ${this.hass.localize( + "ui.panel.lovelace.components.energy_period_selector.compare" + )} `}
diff --git a/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts index 48c6b52da0..2ec9ab4a01 100644 --- a/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-conditional-card-editor.ts @@ -332,6 +332,9 @@ export class HuiConditionalCardEditor } .condition .state ha-select { margin-right: 16px; + margin-inline-end: 16px; + margin-inline-start: initial; + direction: var(--direction); } .condition .state ha-textfield { flex-grow: 1; diff --git a/src/panels/lovelace/editor/config-elements/hui-gauge-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-gauge-card-editor.ts index 8ae5fcbf5e..2baa6c32a0 100644 --- a/src/panels/lovelace/editor/config-elements/hui-gauge-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-gauge-card-editor.ts @@ -22,6 +22,7 @@ import { baseLovelaceCardConfig } from "../structs/base-card-struct"; const gaugeSegmentStruct = object({ from: number(), color: string(), + label: optional(string()), }); const cardConfigStruct = assign( diff --git a/src/panels/lovelace/editor/config-elements/hui-logbook-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-logbook-card-editor.ts index f789c09bbe..dcbc6f325b 100644 --- a/src/panels/lovelace/editor/config-elements/hui-logbook-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-logbook-card-editor.ts @@ -17,6 +17,7 @@ import type { HomeAssistant } from "../../../../types"; import type { LogbookCardConfig } from "../../cards/types"; import type { LovelaceCardEditor } from "../../types"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; +import { filterLogbookCompatibleEntities } from "../../../../data/logbook"; const cardConfigStruct = assign( baseLovelaceCardConfig, @@ -81,6 +82,7 @@ export class HuiLogbookCardEditor diff --git a/src/panels/lovelace/entity-rows/hui-input-datetime-entity-row.ts b/src/panels/lovelace/entity-rows/hui-input-datetime-entity-row.ts index f542b2ec6d..c50a2b4958 100644 --- a/src/panels/lovelace/entity-rows/hui-input-datetime-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-input-datetime-entity-row.ts @@ -125,6 +125,9 @@ class HuiInputDatetimeEntityRow extends LitElement implements LovelaceRow { return css` ha-date-input + ha-time-input { margin-left: 4px; + margin-inline-start: 4px; + margin-inline-end: initial; + direction: var(--direction); } `; } diff --git a/src/panels/lovelace/entity-rows/hui-weather-entity-row.ts b/src/panels/lovelace/entity-rows/hui-weather-entity-row.ts index e2e0971399..3b0d17ff37 100644 --- a/src/panels/lovelace/entity-rows/hui-weather-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-weather-entity-row.ts @@ -114,7 +114,9 @@ class HuiWeatherEntityRow extends LitElement implements LovelaceRow { })} >
- ${UNAVAILABLE_STATES.includes(stateObj.state) + ${UNAVAILABLE_STATES.includes(stateObj.state) || + stateObj.attributes.temperature === undefined || + stateObj.attributes.temperature === null ? computeStateDisplay( this.hass.localize, stateObj, @@ -125,7 +127,7 @@ class HuiWeatherEntityRow extends LitElement implements LovelaceRow { stateObj.attributes.temperature, this.hass.locale )} - ${getWeatherUnit(this.hass, "temperature")} + ${getWeatherUnit(this.hass, stateObj, "temperature")} `}
diff --git a/src/panels/media-browser/ha-panel-media-browser.ts b/src/panels/media-browser/ha-panel-media-browser.ts index 1123806c3a..2de0f6c4de 100644 --- a/src/panels/media-browser/ha-panel-media-browser.ts +++ b/src/panels/media-browser/ha-panel-media-browser.ts @@ -27,6 +27,7 @@ import { BROWSER_PLAYER, MediaPickedEvent, MediaPlayerItem, + mediaPlayerPlayMedia, } from "../../data/media-player"; import { ResolvedMediaSource, @@ -208,11 +209,12 @@ class PanelMediaBrowser extends LitElement { if (this._entityId !== BROWSER_PLAYER) { this._player.showResolvingNewMediaPicked(); try { - await this.hass!.callService("media_player", "play_media", { - entity_id: this._entityId, - media_content_id: item.media_content_id, - media_content_type: item.media_content_type, - }); + await mediaPlayerPlayMedia( + this.hass, + this._entityId, + item.media_content_id, + item.media_content_type + ); } catch (err) { this._player.hideResolvingNewMediaPicked(); } diff --git a/src/state/quick-bar-mixin.ts b/src/state/quick-bar-mixin.ts index 532ffbe49e..b359c70e7f 100644 --- a/src/state/quick-bar-mixin.ts +++ b/src/state/quick-bar-mixin.ts @@ -48,9 +48,9 @@ export default >(superClass: T) => private _registerShortcut() { tinykeys(window, { - e: (ev) => this._showQuickBar(ev), - c: (ev) => this._showQuickBar(ev, true), - m: (ev) => this._createMyLink(ev), + KeyE: (ev) => this._showQuickBar(ev), + KeyC: (ev) => this._showQuickBar(ev, true), + KeyM: (ev) => this._createMyLink(ev), }); } diff --git a/src/translations/en.json b/src/translations/en.json index 28a71bc0c7..5d22072f24 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -811,6 +811,11 @@ "icon_error": "Icons should be in the format 'prefix:iconname', e.g. 'mdi:home'", "entity_id": "Entity ID", "unit_of_measurement": "Unit of Measurement", + "precipitation_unit": "Precipitation unit", + "pressure_unit": "Barometric pressure unit", + "temperature_unit": "Temperature unit", + "visibility_unit": "Visibility unit", + "wind_speed_unit": "Wind speed unit", "device_class": "Show as", "device_classes": { "binary_sensor": { @@ -1010,7 +1015,7 @@ }, "domain_toggler": { "title": "Toggle Domains", - "reset_entities": "Reset Entities" + "reset_entities": "Reset Entity overrides" }, "mqtt_device_debug_info": { "title": "{device} debug info", @@ -1160,7 +1165,7 @@ }, "tags": { "main": "Tags", - "secondary": "Setup NFC tags and QR codes" + "secondary": "Set up NFC tags and QR codes" }, "people": { "main": "People", @@ -1596,7 +1601,7 @@ "frontend": "frontend-ui", "built_using": "Built using", "icons_by": "Icons by", - "frontend_version": "Frontend version: {version} - {type}", + "frontend_version": "Frontend {version} - {type}", "custom_uis": "Custom UIs:", "system_health_error": "System Health component is not loaded. Add 'system_health:' to configuration.yaml", "documentation": "Documentation", @@ -1605,6 +1610,15 @@ "system_health": { "manage": "Manage", "more_info": "more info" + }, + "items": { + "change_log": "Change Log", + "thanks": "Thanks To", + "merch": "Merchandise", + "feature": "Feature Requests", + "bug": "Bug Reports", + "help": "Help", + "license": "License" } }, "logs": { @@ -2431,15 +2445,16 @@ }, "alexa": { "title": "Alexa", - "banner": "Editing which entities are exposed via this UI is disabled because you have configured entity filters in configuration.yaml.", - "exposed_entities": "Exposed entities", - "not_exposed_entities": "Not exposed entities", - "manage_domains": "Manage domains", - "expose_entity": "Expose entity", - "dont_expose_entity": "Don't expose entity", - "follow_domain": "Follow domain", - "exposed": "{selected} exposed", - "not_exposed": "{selected} not exposed", + "banner": "[%key:ui::panel::config::cloud::google::banner%]", + "exposed_entities": "[%key:ui::panel::config::cloud::google::exposed_entities%]", + "not_exposed_entities": "[%key:ui::panel::config::cloud::google::not_exposed_entities%]", + "manage_defaults": "[%key:ui::panel::config::cloud::google::manage_defaults%]", + "manage_defaults_dialog_description": "[%key:ui::panel::config::cloud::google::manage_defaults_dialog_description%]", + "expose_entity": "[%key:ui::panel::config::cloud::google::expose_entity%]", + "dont_expose_entity": "[%key:ui::panel::config::cloud::google::dont_expose_entity%]", + "follow_domain": "[%key:ui::panel::config::cloud::google::follow_domain%]", + "exposed": "[%key:ui::panel::config::cloud::google::exposed%]", + "not_exposed": "[%key:ui::panel::config::cloud::google::not_exposed%]", "expose": "Expose to Alexa" }, "dialog_certificate": { @@ -2456,7 +2471,8 @@ "banner": "Editing which entities are exposed via this UI is disabled because you have configured entity filters in configuration.yaml.", "exposed_entities": "Exposed entities", "not_exposed_entities": "Not exposed entities", - "manage_domains": "Manage domains", + "manage_defaults": "Manage defaults", + "manage_defaults_dialog_description": "Entities can be exposed by default based on their type.", "expose_entity": "Expose entity", "dont_expose_entity": "Don't expose entity", "follow_domain": "Follow domain", @@ -3100,10 +3116,91 @@ "reinterview_device": "Re-interview Device", "heal_node": "Heal Device", "remove_failed": "Remove Failed Device", + "update_firmware": "Update Device Firmware", "highest_security": "Highest Security", "unknown": "Unknown", "zwave_plus": "Z-Wave Plus", - "zwave_plus_version": "Version {version}" + "zwave_plus_version": "Version {version}", + "node_statistics": "Show Device Statistics" + }, + "node_statistics": { + "title": "Device Statistics", + "commands_tx": { + "label": "Commands TX", + "tooltip": "# of commands successfully sent to the node" + }, + "commands_rx": { + "label": "Commands RX", + "tooltip": "# of commands received from the node, including responses to sent commands" + }, + "commands_dropped_tx": { + "label": "Commands Dropped TX", + "tooltip": "# of outgoing commands that were dropped because they could not be sent" + }, + "commands_dropped_rx": { + "label": "Commands Dropped RX", + "tooltip": "# of commands from the node that were dropped by the host" + }, + "timeout_response": { + "label": "Timeout Response", + "tooltip": "# of Get-type commands where the node's response did not come in time" + }, + "rtt": { + "label": "RTT", + "tooltip": "Average round-trip-time in ms of commands to this node" + }, + "rssi": { + "label": "RSSI", + "tooltip": "Average RSSI in dBm of frames received by this node" + }, + "lwr": "Last Working Route", + "nlwr": "Next to Last Working Route" + }, + "route_statistics": { + "protocol": { + "label": "Protocol", + "tooltip": "The protocol for this route", + "protocol_data_rate": { + "ZWave_9k6": "Z-Wave", + "ZWave_40k": "Z-Wave", + "ZWave_100k": "Z-Wave", + "LongRange_100k": "Z-Wave Long Range" + } + }, + "data_rate": { + "label": "Data Rate", + "tooltip": "The used data rate for this route", + "protocol_data_rate": { + "ZWave_9k6": "9.6 kbps", + "ZWave_40k": "40 kbps", + "ZWave_100k": "100 kbps", + "LongRange_100k": "100 kbps" + } + }, + "repeaters": { + "label": "Repeaters + RSSI", + "tooltip": "Which nodes are repeaters for this route and their RSSI", + "repeaters": "Repeater Device", + "rssi": "RSSI", + "direct": "None, direct connection" + }, + "rssi": { + "label": "RSSI", + "tooltip": "The RSSI of the ACK frame received by the controller" + }, + "route_failed_between": { + "label": "Route Failed Between", + "tooltip": "The nodes between which the transmission failed most recently", + "not_applicable": "N/A" + } + }, + "rssi": { + "unit": "dBm", + "rssi_error": { + "NotAvailable": "Not available", + "ReceiverSaturated": "Receiver saturated", + "NoSignalDetected": "No signal detected" + } }, "node_config": { "header": "Z-Wave Device Configuration", @@ -3227,6 +3324,43 @@ "in_progress": "{device} healing is in progress.", "network_heal_in_progress": "A Z-Wave network heal is already in progress. Please wait for it to finish before healing an individual device." }, + "update_firmware": { + "title": "Update Device Firmware", + "warning": "WARNING: Firmware updates can brick your device if you do not correctly follow the manufacturer's guidance. The Home Assistant and Z-Wave JS teams do not take any responsibility for any damages to your device as a result of the firmware update and will not be able to help you if you brick your device. Would you still like to continue?", + "introduction": "Select the firmware file you would like to use to update {device}.", + "upload_firmware": "Upload Firmware", + "firmware_target_intro": "Select the firmware target (0 for the Z-Wave chip, ≥1 for other chips if they exist) for this update, or uncheck the box to have the driver attempt to figure it out from the firmware file.", + "firmware_target": "Firmware Target (chip)", + "upload_failed": "Upload Failed", + "begin_update": "Begin Firmware Update", + "queued": "The firmware update is ready to be sent to {device} but the device is asleep, wake the device to start the update.", + "close_queued": "If you close this dialog, the update will continue to be queued in the background and start automatically once the device wakes up.", + "awake": "The firmware update should start being sent to {device} shortly.", + "close": "If you close this dialog, the update will continue in the background.", + "in_progress": "The firmware update on {device} is in progress ({progress}%).", + "abort": "Abort Firmware Update", + "abort_failed": "Abort Failed", + "confirm_abort": "Are you sure you want to abort the firmware update on {device}?", + "finished_status": { + "ok": "Successfully updated firmware on {device}: {message}.", + "error": "Unable to update firmware on {device}: {message}.", + "try_again": "To attempt the firmware update again, select the new firmware file you would like to use.", + "done": "The firmware update is complete! If you want to attempt another firmware update on this device, please wait until it gets re-interviewed.", + "Error_Timeout": "Timed Out", + "Error_Checksum": "Checksum Error", + "Error_TransmissionFailed": "Transmission Failed", + "Error_InvalidManufacturerID": "Invalid Manufacturer ID", + "Error_InvalidFirmwareID": "Invalid Firmware ID", + "Error_InvalidFirmwareTarget": "Invalid Firmware Target", + "Error_InvalidHeaderInformation": "Invalid Header Information", + "Error_InvalidHeaderFormat": "Invalid Header Format", + "Error_InsufficientMemory": "Insufficient Memory", + "Error_InvalidHardwareVersion": "Invalid Hardware Version", + "OK_WaitingForActivation": "Waiting for Activiation", + "OK_NoRestart": "No Restart", + "OK_RestartPending": "Restart Pending" + } + }, "logs": { "title": "Z-Wave JS Logs", "log_level": "Log Level", diff --git a/translations/README.md b/translations/README.md index 689b55fe9d..ac763a0768 100644 --- a/translations/README.md +++ b/translations/README.md @@ -1,4 +1,6 @@ # Translation Resources -Translations are managed through [Lokalise](https://developers.home-assistant.io/docs/translations/). If you'd like to contribute, you can [join the project here](https://lokalise.co/signup/3420425759f6d6d241f598.13594006/all/). For more details, see our [translation guidelines documentation](https://developers.home-assistant.io/docs/translations/). +Translations from English into other languages are managed through [Lokalise](https://developers.home-assistant.io/docs/translations/). If you'd like to contribute, you can [join the project here](https://lokalise.co/signup/3420425759f6d6d241f598.13594006/all/). For more details, see our [translation guidelines documentation](https://developers.home-assistant.io/docs/translations/). -Don't make changes to these files directly. Instead, use `script/translations_download` to fetch the latest translations from Lokalise. +Don't make changes to these translation files directly. Instead, use `script/translations_download` to fetch the latest translations from Lokalise. + +The original English translation keys and texts are defined in `src/translations/en.json` and serve as the base for Lokalise. diff --git a/translations/frontend/en.json b/translations/frontend/en.json index 16d2933745..1c192e45aa 100644 --- a/translations/frontend/en.json +++ b/translations/frontend/en.json @@ -2429,6 +2429,10 @@ "title": "Entity not tracked" } } + }, + "compare":{ + "info": "You are comparing the period {start} with {end}" + } }, "entities": { @@ -3852,7 +3856,8 @@ "previous": "Previous", "today": "Today", "week": "Week", - "year": "Year" + "year": "Year", + "compare": "Compare data" }, "timestamp-display": { "invalid": "Invalid timestamp",