diff --git a/.github/workflows/cast_deployment.yaml b/.github/workflows/cast_deployment.yaml index 82eaf245ea29..7538244e40c9 100644 --- a/.github/workflows/cast_deployment.yaml +++ b/.github/workflows/cast_deployment.yaml @@ -21,12 +21,12 @@ jobs: url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 with: ref: dev - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn @@ -57,12 +57,12 @@ jobs: url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 with: ref: master - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f09fd4c1b799..ea51c3ab437e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,9 +24,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn @@ -37,7 +37,7 @@ jobs: - name: Build resources run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages - name: Setup lint cache - uses: actions/cache@v4.1.1 + uses: actions/cache@v4.1.2 with: path: | node_modules/.cache/prettier @@ -58,9 +58,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn @@ -76,9 +76,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn @@ -100,9 +100,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ae9a1a54067f..a98228bddb2b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. diff --git a/.github/workflows/demo_deployment.yaml b/.github/workflows/demo_deployment.yaml index dd073a03671b..452d43cac351 100644 --- a/.github/workflows/demo_deployment.yaml +++ b/.github/workflows/demo_deployment.yaml @@ -22,12 +22,12 @@ jobs: url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 with: ref: dev - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn @@ -58,12 +58,12 @@ jobs: url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 with: ref: master - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/design_deployment.yaml b/.github/workflows/design_deployment.yaml index bfcf44eadf78..f82539658397 100644 --- a/.github/workflows/design_deployment.yaml +++ b/.github/workflows/design_deployment.yaml @@ -16,10 +16,10 @@ jobs: url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/design_preview.yaml b/.github/workflows/design_preview.yaml index 18083a67f168..96b6cf442288 100644 --- a/.github/workflows/design_preview.yaml +++ b/.github/workflows/design_preview.yaml @@ -21,10 +21,10 @@ jobs: if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview') steps: - name: Check out files from GitHub - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 408ad46a81c8..7a4ee65b1b57 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -20,7 +20,7 @@ jobs: contents: write steps: - name: Checkout the repository - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.PYTHON_VERSION }} uses: actions/setup-python@v5 @@ -28,7 +28,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5d547e802bd3..2eaf2b6a26f6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -23,7 +23,7 @@ jobs: contents: write # Required to upload release assets steps: - name: Checkout the repository - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 - name: Verify version uses: home-assistant/actions/helpers/verify-version@master @@ -34,7 +34,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Setup Node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.1.0 with: node-version-file: ".nvmrc" cache: yarn diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml index ec035e3034fa..5c74aa757970 100644 --- a/.github/workflows/translations.yaml +++ b/.github/workflows/translations.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.2.1 + uses: actions/checkout@v4.2.2 - name: Upload Translations run: | diff --git a/build-scripts/gulp/locale-data.js b/build-scripts/gulp/locale-data.js index 4da1d8f218cd..4cf91711d495 100755 --- a/build-scripts/gulp/locale-data.js +++ b/build-scripts/gulp/locale-data.js @@ -24,8 +24,11 @@ const convertToJSON = async ( ) => { let localeData; try { + // use "pt" for "pt-BR", because "pt-BR" is unsupported by @formatjs + const language = lang === "pt-BR" ? "pt" : lang; + localeData = await readFile( - join(formatjsDir, pkg, subDir, `${lang}.js`), + join(formatjsDir, pkg, subDir, `${language}.js`), "utf-8" ); } catch (e) { diff --git a/cast/src/launcher/layout/hc-cast.ts b/cast/src/launcher/layout/hc-cast.ts index 2aae14bb98a5..b8c2b9bf7ec4 100644 --- a/cast/src/launcher/layout/hc-cast.ts +++ b/cast/src/launcher/layout/hc-cast.ts @@ -1,5 +1,6 @@ import "@material/mwc-button/mwc-button"; -import { ActionDetail } from "@material/mwc-list/mwc-list"; +import "@material/mwc-list/mwc-list"; +import type { ActionDetail } from "@material/mwc-list/mwc-list"; import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js"; import { Auth, Connection } from "home-assistant-js-websocket"; import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit"; @@ -89,8 +90,8 @@ class HcCast extends LitElement { generateDefaultViewConfig({}, {}, {}, {}, () => ""), ] ).map( - (view, idx) => - html` html` + `} ` + >`} + + ` )} `} diff --git a/cast/src/receiver/layout/hc-lovelace.ts b/cast/src/receiver/layout/hc-lovelace.ts index 010979e32d08..6df50c6109bb 100644 --- a/cast/src/receiver/layout/hc-lovelace.ts +++ b/cast/src/receiver/layout/hc-lovelace.ts @@ -1,10 +1,11 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, query } from "lit/decorators"; +import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../../../src/common/dom/fire_event"; import { LovelaceConfig } from "../../../../src/data/lovelace/config/types"; import { getPanelTitleFromUrlPath } from "../../../../src/data/panel"; import { Lovelace } from "../../../../src/panels/lovelace/types"; import "../../../../src/panels/lovelace/views/hui-view"; +import "../../../../src/panels/lovelace/views/hui-view-container"; import { HomeAssistant } from "../../../../src/types"; import "./hc-launch-screen"; @@ -22,8 +23,6 @@ class HcLovelace extends LitElement { @property() public urlPath: string | null = null; - @query("hui-view") private _huiView?: HTMLElement; - protected render(): TemplateResult { const index = this._viewIndex; if (index === undefined) { @@ -47,12 +46,22 @@ class HcLovelace extends LitElement { setEditMode: () => undefined, showToast: () => undefined, }; + + const viewConfig = this.lovelaceConfig.views[index]; + const background = viewConfig.background || this.lovelaceConfig.background; + return html` - + .background=${background} + .theme=${viewConfig.theme} + > + + `; } @@ -82,26 +91,6 @@ class HcLovelace extends LitElement { }${viewTitle || ""}` : undefined, }); - - const configBackground = - this.lovelaceConfig.views[index].background || - this.lovelaceConfig.background; - - const backgroundStyle = - typeof configBackground === "string" - ? configBackground - : configBackground?.image - ? `center / cover no-repeat url('${configBackground.image}')` - : undefined; - - if (backgroundStyle) { - this._huiView!.style.setProperty( - "--lovelace-background", - backgroundStyle - ); - } else { - this._huiView!.style.removeProperty("--lovelace-background"); - } } } } @@ -125,19 +114,15 @@ class HcLovelace extends LitElement { static get styles(): CSSResultGroup { return css` - :host { - min-height: 100vh; - height: 0; + hui-view-container { display: flex; - flex-direction: column; + position: relative; + min-height: 100vh; box-sizing: border-box; - background: var(--primary-background-color); - } - :host > * { - flex: 1; } hui-view { - background: var(--lovelace-background, var(--primary-background-color)); + flex: 1 1 100%; + max-width: 100%; } `; } diff --git a/hassio/src/backups/hassio-backups.ts b/hassio/src/backups/hassio-backups.ts index 095bd922f493..3512dd8bd863 100644 --- a/hassio/src/backups/hassio-backups.ts +++ b/hassio/src/backups/hassio-backups.ts @@ -48,6 +48,7 @@ import { showHassioBackupDialog } from "../dialogs/backup/show-dialog-hassio-bac import { showHassioCreateBackupDialog } from "../dialogs/backup/show-dialog-hassio-create-backup"; import { supervisorTabs } from "../hassio-tabs"; import { hassioStyle } from "../resources/hassio-style"; +import "../../../src/layouts/hass-loading-screen"; type BackupItem = HassioBackup & { secondary: string; @@ -69,6 +70,8 @@ export class HassioBackups extends LitElement { @state() private _backups?: HassioBackup[] = []; + @state() private _isLoading = false; + @query("hass-tabs-subpage-data-table", true) private _dataTable!: HaTabsSubpageDataTable; @@ -77,15 +80,10 @@ export class HassioBackups extends LitElement { public connectedCallback(): void { super.connectedCallback(); if (this.hass && this._firstUpdatedCalled) { - this.refreshData(); + this.fetchBackups(); } } - public async refreshData() { - await reloadHassioBackups(this.hass); - await this.fetchBackups(); - } - private _computeBackupContent = (backup: HassioBackup): string => { if (backup.type === "full") { return this.supervisor.localize("backup.full_backup"); @@ -115,7 +113,7 @@ export class HassioBackups extends LitElement { protected firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); if (this.hass && this.isConnected) { - this.refreshData(); + this.fetchBackups(); } this._firstUpdatedCalled = true; } @@ -175,6 +173,13 @@ export class HassioBackups extends LitElement { if (!this.supervisor) { return nothing; } + + if (this._isLoading) { + return html``; + } + return html` ) { switch (ev.detail.index) { case 0: - this.refreshData(); + this.fetchBackups(); break; case 1: showHassioBackupLocationDialog(this, { supervisor: this.supervisor }); @@ -306,13 +311,15 @@ export class HassioBackups extends LitElement { supervisor: this.supervisor, onDelete: () => this.fetchBackups(), }), - reloadBackup: () => this.refreshData(), + reloadBackup: () => this.fetchBackups(), }); } private async fetchBackups() { + this._isLoading = true; await reloadHassioBackups(this.hass); this._backups = await fetchHassioBackups(this.hass); + this._isLoading = false; } private async _deleteSelected() { @@ -339,8 +346,7 @@ export class HassioBackups extends LitElement { }); return; } - await reloadHassioBackups(this.hass); - this._backups = await fetchHassioBackups(this.hass); + await this.fetchBackups(); this._dataTable.clearSelection(); } diff --git a/hassio/src/update-available/update-available-card.ts b/hassio/src/update-available/update-available-card.ts index d2c22ac0e107..e00035b987a0 100644 --- a/hassio/src/update-available/update-available-card.ts +++ b/hassio/src/update-available/update-available-card.ts @@ -1,11 +1,10 @@ -import "@material/mwc-list/mwc-list-item"; import { css, CSSResultGroup, html, LitElement, - PropertyValues, nothing, + PropertyValues, } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; @@ -16,12 +15,12 @@ import "../../../src/components/ha-button-menu"; import "../../../src/components/ha-card"; import "../../../src/components/ha-checkbox"; import "../../../src/components/ha-faded"; -import "../../../src/components/ha-formfield"; import "../../../src/components/ha-icon-button"; import "../../../src/components/ha-markdown"; import "../../../src/components/ha-settings-row"; import "../../../src/components/ha-svg-icon"; import "../../../src/components/ha-switch"; +import type { HaSwitch } from "../../../src/components/ha-switch"; import { fetchHassioAddonChangelog, fetchHassioAddonInfo, @@ -42,6 +41,7 @@ import { updateCore } from "../../../src/data/supervisor/core"; import { StoreAddon } from "../../../src/data/supervisor/store"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../src/resources/styles"; import { HomeAssistant, Route } from "../../../src/types"; import { addonArchIsSupported, extractChangelog } from "../util/addon"; @@ -149,7 +149,7 @@ class UpdateAvailableCard extends LitElement { ` - : ""} + : nothing}

${this.supervisor.localize( @@ -164,15 +164,17 @@ class UpdateAvailableCard extends LitElement {

${["core", "addon"].includes(this._updateType) ? html` - - - +
+ + + ${this.supervisor.localize( + "update_available.create_backup" + )} + + + ` - : ""} + : nothing} ` : html` ${changelog - ? html` - - - ` - : ""} + ? html` + + + + + ` + : nothing} - + ${this.supervisor.localize("common.update")} ` - : ""} + : nothing} `; } @@ -242,9 +246,11 @@ class UpdateAvailableCard extends LitElement { if (this._updateType && !["core", "addon"].includes(this._updateType)) { return false; } - const checkbox = this.shadowRoot?.querySelector("ha-checkbox"); - if (checkbox) { - return checkbox.checked; + const createBackupSwitch = this.shadowRoot?.getElementById( + "create-backup" + ) as HaSwitch; + if (createBackupSwitch) { + return createBackupSwitch.checked; } return true; } @@ -397,41 +403,50 @@ class UpdateAvailableCard extends LitElement { } static get styles(): CSSResultGroup { - return css` - :host { - display: block; - } - ha-card { - margin: auto; - } - a { - text-decoration: none; - color: var(--primary-text-color); - } - ha-settings-row { - padding: 0; - } - .card-actions { - display: flex; - justify-content: space-between; - border-top: none; - padding: 0 8px 8px; - } + return [ + haStyle, + css` + :host { + display: block; + } + ha-card { + margin: auto; + } + a { + text-decoration: none; + color: var(--primary-text-color); + } + .card-actions { + display: flex; + justify-content: space-between; + } - ha-circular-progress { - display: block; - margin: 32px; - text-align: center; - } + ha-circular-progress { + display: block; + margin: 32px; + text-align: center; + } - .progress-text { - text-align: center; - } + .progress-text { + text-align: center; + } - ha-markdown { - padding-bottom: 8px; - } - `; + ha-markdown { + padding-bottom: 8px; + } + + ha-settings-row { + padding: 0; + margin-bottom: -16px; + } + + hr { + border-color: var(--divider-color); + border-bottom: none; + margin: 16px 0 0 0; + } + `, + ]; } } diff --git a/package.json b/package.json index 2975db6bb026..774c9e9bfa83 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "license": "Apache-2.0", "type": "module", "dependencies": { - "@babel/runtime": "7.25.9", + "@babel/runtime": "7.26.0", "@braintree/sanitize-url": "7.1.0", "@codemirror/autocomplete": "6.18.1", "@codemirror/commands": "6.7.1", @@ -35,14 +35,14 @@ "@codemirror/state": "6.4.1", "@codemirror/view": "6.34.1", "@egjs/hammerjs": "2.0.17", - "@formatjs/intl-datetimeformat": "6.15.0", - "@formatjs/intl-displaynames": "6.7.0", - "@formatjs/intl-getcanonicallocales": "2.4.0", - "@formatjs/intl-listformat": "7.6.0", - "@formatjs/intl-locale": "4.1.0", - "@formatjs/intl-numberformat": "8.13.0", - "@formatjs/intl-pluralrules": "5.2.17", - "@formatjs/intl-relativetimeformat": "11.3.0", + "@formatjs/intl-datetimeformat": "6.16.1", + "@formatjs/intl-displaynames": "6.8.1", + "@formatjs/intl-getcanonicallocales": "2.5.1", + "@formatjs/intl-listformat": "7.7.1", + "@formatjs/intl-locale": "4.2.1", + "@formatjs/intl-numberformat": "8.14.1", + "@formatjs/intl-pluralrules": "5.3.1", + "@formatjs/intl-relativetimeformat": "11.4.1", "@fullcalendar/core": "6.1.15", "@fullcalendar/daygrid": "6.1.15", "@fullcalendar/interaction": "6.1.15", @@ -114,7 +114,7 @@ "hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch", "home-assistant-js-websocket": "9.4.0", "idb-keyval": "6.2.1", - "intl-messageformat": "10.7.1", + "intl-messageformat": "10.7.3", "js-yaml": "4.1.0", "leaflet": "1.9.4", "leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch", @@ -151,12 +151,12 @@ "xss": "1.0.15" }, "devDependencies": { - "@babel/core": "7.25.9", + "@babel/core": "7.26.0", "@babel/helper-define-polyfill-provider": "0.6.2", "@babel/plugin-proposal-decorators": "7.25.9", "@babel/plugin-transform-runtime": "7.25.9", - "@babel/preset-env": "7.25.9", - "@babel/preset-typescript": "7.25.9", + "@babel/preset-env": "7.26.0", + "@babel/preset-typescript": "7.26.0", "@bundle-stats/plugin-webpack-filter": "4.16.0", "@koa/cors": "5.0.0", "@lokalise/node-api": "12.8.0", diff --git a/src/components/data-table/ha-data-table-labels.ts b/src/components/data-table/ha-data-table-labels.ts index 66c137e92a6a..e87ac80ea241 100644 --- a/src/components/data-table/ha-data-table-labels.ts +++ b/src/components/data-table/ha-data-table-labels.ts @@ -108,6 +108,7 @@ class HaDataTableLabels extends LitElement { ha-label { --ha-label-background-color: var(--color, var(--grey-color)); --ha-label-background-opacity: 0.5; + outline: 1px solid var(--outline-color); } ha-button-menu { border-radius: 10px; diff --git a/src/components/date-range-picker.ts b/src/components/date-range-picker.ts index 523ac64eb067..bc67b46bd93b 100644 --- a/src/components/date-range-picker.ts +++ b/src/components/date-range-picker.ts @@ -254,7 +254,7 @@ class DateRangePickerElement extends WrappedElement { .daterangepicker select.hourselect, .daterangepicker select.minuteselect, .daterangepicker select.secondselect { - background: transparent; + background: var(--card-background-color); border: 1px solid var(--divider-color); color: var(--primary-color); } diff --git a/src/components/ha-camera-stream.ts b/src/components/ha-camera-stream.ts index d17ca1f2c498..3d67eb9e890b 100644 --- a/src/components/ha-camera-stream.ts +++ b/src/components/ha-camera-stream.ts @@ -3,21 +3,22 @@ import { CSSResultGroup, html, LitElement, - PropertyValues, nothing, + PropertyValues, } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { isComponentLoaded } from "../common/config/is_component_loaded"; import { computeStateName } from "../common/entity/compute_state_name"; import { supportsFeature } from "../common/entity/supports-feature"; import { - CameraEntity, CAMERA_SUPPORT_STREAM, + CameraCapabilities, + CameraEntity, computeMJPEGStreamUrl, - fetchStreamUrl, + fetchCameraCapabilities, fetchThumbnailUrlWithCache, STREAM_TYPE_HLS, STREAM_TYPE_WEB_RTC, + StreamType, } from "../data/camera"; import { HomeAssistant } from "../types"; import "./ha-hls-player"; @@ -41,13 +42,15 @@ export class HaCameraStream extends LitElement { // Video background image before its loaded @state() private _posterUrl?: string; - // We keep track if we should force MJPEG if there was a failure - // to get the HLS stream url. This is reset if we change entities. - @state() private _forceMJPEG?: string; + @state() private _connected = false; - @state() private _url?: string; + @state() private _capabilities?: CameraCapabilities; - @state() private _connected = false; + @state() private _streamType?: StreamType; + + @state() private _hlsStreams?: { hasAudio: boolean; hasVideo: boolean }; + + @state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean }; public willUpdate(changedProps: PropertyValues): void { if ( @@ -57,12 +60,8 @@ export class HaCameraStream extends LitElement { (changedProps.get("stateObj") as CameraEntity | undefined)?.entity_id !== this.stateObj.entity_id ) { + this._getCapabilities(); this._getPosterUrl(); - if (this.stateObj!.attributes.frontend_stream_type === STREAM_TYPE_HLS) { - this._forceMJPEG = undefined; - this._url = undefined; - this._getStreamUrl(); - } } } @@ -87,54 +86,79 @@ export class HaCameraStream extends LitElement { : this._connected ? computeMJPEGStreamUrl(this.stateObj) : ""} - .alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`} + alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`} />`; } - if (this.stateObj.attributes.frontend_stream_type === STREAM_TYPE_HLS) { - return this._url - ? html`` - : nothing; + return html`${this._streamType === STREAM_TYPE_HLS || + (!this._streamType && + this._capabilities?.frontend_stream_types.includes(STREAM_TYPE_HLS)) + ? html`` + : nothing} + ${this._streamType === STREAM_TYPE_WEB_RTC || + (!this._streamType && + this._capabilities?.frontend_stream_types.includes(STREAM_TYPE_WEB_RTC)) + ? html`` + : nothing}`; + } + + private async _getCapabilities() { + this._capabilities = undefined; + this._hlsStreams = undefined; + this._webRtcStreams = undefined; + if (!supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM)) { + return; } - if (this.stateObj.attributes.frontend_stream_type === STREAM_TYPE_WEB_RTC) { - return html``; + this._capabilities = await fetchCameraCapabilities( + this.hass!, + this.stateObj!.entity_id + ); + if (this._capabilities.frontend_stream_types.length === 1) { + this._streamType = this._capabilities.frontend_stream_types[0]; } - return nothing; } private get _shouldRenderMJPEG() { - if (this._forceMJPEG === this.stateObj!.entity_id) { - // Fallback when unable to fetch stream url - return true; - } if (!supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM)) { // Steaming is not supported by the camera so fallback to MJPEG stream return true; } if ( - this.stateObj!.attributes.frontend_stream_type === STREAM_TYPE_WEB_RTC + this._capabilities && + (!this._capabilities.frontend_stream_types.includes(STREAM_TYPE_HLS) || + this._hlsStreams?.hasVideo === false) && + (!this._capabilities.frontend_stream_types.includes( + STREAM_TYPE_WEB_RTC + ) || + this._webRtcStreams?.hasVideo === false) ) { - // Browser support required for WebRTC - return typeof RTCPeerConnection === "undefined"; + // No video in HLS stream and no video in WebRTC stream + return true; } - // Server side stream component required for HLS - return !isComponentLoaded(this.hass!, "stream"); + return false; } private async _getPosterUrl(): Promise { @@ -151,20 +175,28 @@ export class HaCameraStream extends LitElement { } } - private async _getStreamUrl(): Promise { - try { - const { url } = await fetchStreamUrl( - this.hass!, - this.stateObj!.entity_id - ); + private _handleHlsStreams(ev: CustomEvent) { + this._hlsStreams = ev.detail; + this._pickStreamType(); + } - this._url = url; - } catch (err: any) { - // Fails if we were unable to get a stream - // eslint-disable-next-line - console.error(err); + private _handleWebRtcStreams(ev: CustomEvent) { + this._webRtcStreams = ev.detail; + this._pickStreamType(); + } - this._forceMJPEG = this.stateObj!.entity_id; + private _pickStreamType() { + if (!this._hlsStreams || !this._webRtcStreams) { + return; + } + if ( + this._hlsStreams.hasVideo && + this._hlsStreams.hasAudio && + !this._webRtcStreams.hasAudio + ) { + this._streamType = STREAM_TYPE_HLS; + } else if (this._webRtcStreams.hasVideo) { + this._streamType = STREAM_TYPE_WEB_RTC; } } @@ -178,6 +210,10 @@ export class HaCameraStream extends LitElement { img { width: 100%; } + + .hidden { + display: none; + } `; } } @@ -186,4 +222,12 @@ declare global { interface HTMLElementTagNameMap { "ha-camera-stream": HaCameraStream; } + interface HASSDomEvents { + load: undefined; + streams: { + hasAudio: boolean; + hasVideo: boolean; + codecs?: string[]; + }; + } } diff --git a/src/components/ha-header-bar.ts b/src/components/ha-header-bar.ts index aef90d635273..d3a32626f42b 100644 --- a/src/components/ha-header-bar.ts +++ b/src/components/ha-header-bar.ts @@ -46,7 +46,7 @@ export class HaHeaderBar extends LitElement { flex: none; } .mdc-top-app-bar__title { - padding-inline-start: 20px; + padding-inline-start: 24px; padding-inline-end: initial; } `, diff --git a/src/components/ha-hls-player.ts b/src/components/ha-hls-player.ts index cf3ed5a526a1..056e038892cb 100644 --- a/src/components/ha-hls-player.ts +++ b/src/components/ha-hls-player.ts @@ -12,6 +12,8 @@ import { fireEvent } from "../common/dom/fire_event"; import { nextRender } from "../common/util/render-status"; import type { HomeAssistant } from "../types"; import "./ha-alert"; +import { fetchStreamUrl } from "../data/camera"; +import { isComponentLoaded } from "../common/config/is_component_loaded"; type HlsLite = Omit< HlsType, @@ -22,9 +24,9 @@ type HlsLite = Omit< class HaHLSPlayer extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property() public url!: string; + @property() public entityid?: string; - @property() public posterUrl!: string; + @property({ attribute: "poster-url" }) public posterUrl?: string; @property({ type: Boolean, attribute: "controls" }) public controls = false; @@ -48,6 +50,8 @@ class HaHLSPlayer extends LitElement { @state() private _errorIsFatal = false; + @state() private _url!: string; + private _hlsPolyfillInstance?: HlsLite; private _exoPlayer = false; @@ -95,19 +99,44 @@ class HaHLSPlayer extends LitElement { protected updated(changedProps: PropertyValues) { super.updated(changedProps); - const urlChanged = changedProps.has("url"); + const entityChanged = changedProps.has("entityid"); - if (!urlChanged) { + if (!entityChanged) { return; } + this._getStreamUrl(); + } + private async _getStreamUrl(): Promise { this._cleanUp(); this._resetError(); - this._startHls(); + + if (!isComponentLoaded(this.hass!, "stream")) { + this._setFatalError("Streaming component is not loaded."); + return; + } + + if (!this.entityid) { + return; + } + try { + const { url } = await fetchStreamUrl(this.hass!, this.entityid); + + this._url = url; + this._cleanUp(); + this._resetError(); + this._startHls(); + } catch (err: any) { + // Fails if we were unable to get a stream + // eslint-disable-next-line + console.error(err); + + fireEvent(this, "streams", { hasAudio: false, hasVideo: false }); + } } private async _startHls(): Promise { - const masterPlaylistPromise = fetch(this.url); + const masterPlaylistPromise = fetch(this._url); const Hls: typeof HlsType = (await import("hls.js/dist/hls.light.mjs")) .default; @@ -138,10 +167,10 @@ class HaHLSPlayer extends LitElement { return; } - // Parse playlist assuming it is a master playlist. Match group 1 is whether hevc, match group 2 is regular playlist url + // Parse playlist assuming it is a master playlist. Match group 1 and 2 are codec, match group 3 is regular playlist url // See https://tools.ietf.org/html/rfc8216 for HLS spec details const playlistRegexp = - /#EXT-X-STREAM-INF:.*?(?:CODECS=".*?(hev1|hvc1)?\..*?".*?)?(?:\n|\r\n)(.+)/g; + /#EXT-X-STREAM-INF:.*?(?:CODECS=".*?([^.]*)?\..*?,([^.]*)?\..*?".*?)?(?:\n|\r\n)(.+)/g; const match = playlistRegexp.exec(masterPlaylist); const matchTwice = playlistRegexp.exec(masterPlaylist); @@ -150,13 +179,20 @@ class HaHLSPlayer extends LitElement { let playlist_url: string; if (match !== null && matchTwice === null) { // Only send the regular playlist url if we match exactly once - playlist_url = new URL(match[2], this.url).href; + playlist_url = new URL(match[3], this._url).href; } else { - playlist_url = this.url; + playlist_url = this._url; } + const codecs = match ? `${match[1]},${match[2]}` : undefined; + + this._reportStreams(codecs); + // If codec is HEVC and ExoPlayer is supported, use ExoPlayer. - if (useExoPlayer && match !== null && match[1] !== undefined) { + if ( + useExoPlayer && + (codecs?.includes("hevc") || codecs?.includes("hev1")) + ) { this._renderHLSExoPlayer(playlist_url); } else if (Hls.isSupported()) { this._renderHLSPolyfill(this._videoEl, Hls, playlist_url); @@ -313,15 +349,26 @@ class HaHLSPlayer extends LitElement { private _setFatalError(errorMessage: string) { this._error = errorMessage; this._errorIsFatal = true; + fireEvent(this, "streams", { hasAudio: false, hasVideo: false }); } private _setRetryableError(errorMessage: string) { this._error = errorMessage; this._errorIsFatal = false; + fireEvent(this, "streams", { hasAudio: false, hasVideo: false }); + } + + private _reportStreams(codecs?: string) { + const codec = codecs?.split(","); + fireEvent(this, "streams", { + hasAudio: codec?.includes("mp4a") ?? false, + hasVideo: codec?.includes("mp4a") + ? codec?.length > 1 + : Boolean(codec?.length), + }); } private _loadedData() { - // @ts-ignore fireEvent(this, "load"); } diff --git a/src/components/ha-labels-picker.ts b/src/components/ha-labels-picker.ts index 4d59f33045f3..d05cae721546 100644 --- a/src/components/ha-labels-picker.ts +++ b/src/components/ha-labels-picker.ts @@ -216,6 +216,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { ha-input-chip { --md-input-chip-selected-container-color: var(--color, var(--grey-color)); --ha-input-chip-selected-container-opacity: 0.5; + --md-input-chip-selected-outline-width: 1px; } `; } diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index d8f0ba227014..f66037776bfc 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -86,6 +86,11 @@ export class HaMarkdown extends LitElement { font-size: 1.5em; font-weight: bold; } + hr { + border-color: var(--divider-color); + border-bottom: none; + margin: 16px 0; + } `; } } diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 6b3acc0f87bc..32f25e702422 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -61,6 +61,11 @@ const showOptionalToggle = (field) => !field.required && !("boolean" in field.selector && field.default); +interface Field extends Omit { + key: string; + selector?: Selector; +} + interface ExtHassService extends Omit { fields: Array< Omit & { @@ -70,6 +75,7 @@ interface ExtHassService extends Omit { collapsed?: boolean; } >; + flatFields: Array; hasSelector: string[]; } @@ -177,7 +183,7 @@ export class HaServiceControl extends LitElement { if (!this._value.data) { this._value.data = {}; } - serviceData.fields.forEach((field) => { + serviceData.flatFields.forEach((field) => { if ( field.selector && field.required && @@ -241,22 +247,28 @@ export class HaServiceControl extends LitElement { selector: value.selector as Selector | undefined, })); + const flatFields: Field[] = []; const hasSelector: string[] = []; fields.forEach((field) => { if ((field as any).fields) { Object.entries((field as any).fields).forEach(([key, subField]) => { + flatFields.push({ ...(subField as Field), key }); if ((subField as any).selector) { hasSelector.push(key); } }); - } else if (field.selector) { - hasSelector.push(field.key); + } else { + flatFields.push(field); + if (field.selector) { + hasSelector.push(field.key); + } } }); return { ...serviceDomains[domain][serviceName], fields, + flatFields, hasSelector, }; } @@ -397,7 +409,7 @@ export class HaServiceControl extends LitElement { const hasOptional = Boolean( !shouldRenderServiceDataYaml && - serviceData?.fields.some((field) => showOptionalToggle(field)) + serviceData?.flatFields.some((field) => showOptionalToggle(field)) ); const targetEntities = this._getTargetedEntities( @@ -667,7 +679,7 @@ export class HaServiceControl extends LitElement { const field = this._getServiceInfo( this._value?.action, this.hass.services - )?.fields.find((_field) => _field.key === key); + )?.flatFields.find((_field) => _field.key === key); let defaultValue = field?.default; diff --git a/src/components/ha-settings-row.ts b/src/components/ha-settings-row.ts index 4ba42277e319..e58bc1f41610 100644 --- a/src/components/ha-settings-row.ts +++ b/src/components/ha-settings-row.ts @@ -5,6 +5,8 @@ import { customElement, property } from "lit/decorators"; export class HaSettingsRow extends LitElement { @property({ type: Boolean, reflect: true }) public narrow = false; + @property({ type: Boolean, reflect: true }) public slim = false; // remove padding and min-height + @property({ type: Boolean, attribute: "three-line" }) public threeLine = false; @@ -42,14 +44,17 @@ export class HaSettingsRow extends LitElement { padding-bottom: 8px; padding-left: 0; padding-inline-start: 0; - padding-right: 16x; + padding-right: 16px; padding-inline-end: 16px; overflow: hidden; - display: var(--layout-vertical_-_display); - flex-direction: var(--layout-vertical_-_flex-direction); - justify-content: var(--layout-center-justified_-_justify-content); - flex: var(--layout-flex_-_flex); - flex-basis: var(--layout-flex_-_flex-basis); + display: var(--layout-vertical_-_display, flex); + flex-direction: var(--layout-vertical_-_flex-direction, column); + justify-content: var( + --layout-center-justified_-_justify-content, + center + ); + flex: var(--layout-flex_-_flex, 1); + flex-basis: var(--layout-flex_-_flex-basis, 0.000000001px); } .body[three-line] { min-height: var(--paper-item-body-three-line-min-height, 88px); @@ -109,6 +114,14 @@ export class HaSettingsRow extends LitElement { display: flex; align-items: center; } + :host([slim]), + :host([slim]) .content, + :host([slim]) ::slotted(ha-switch) { + padding: 0; + } + :host([slim]) .body { + min-height: 0; + } `; } } diff --git a/src/components/ha-top-app-bar-fixed.ts b/src/components/ha-top-app-bar-fixed.ts index e6fac8a01955..bfd71d75c1d3 100644 --- a/src/components/ha-top-app-bar-fixed.ts +++ b/src/components/ha-top-app-bar-fixed.ts @@ -24,7 +24,7 @@ export class HaTopAppBarFixed extends TopAppBarFixedBase { ); } .mdc-top-app-bar__title { - padding-inline-start: 20px; + padding-inline-start: 24px; padding-inline-end: initial; } `, diff --git a/src/components/ha-two-pane-top-app-bar-fixed.ts b/src/components/ha-two-pane-top-app-bar-fixed.ts index 5daae9d61858..0c5e1eddd804 100644 --- a/src/components/ha-two-pane-top-app-bar-fixed.ts +++ b/src/components/ha-two-pane-top-app-bar-fixed.ts @@ -321,7 +321,7 @@ export class TopAppBarBaseBase extends BaseElement { overflow: auto; } .mdc-top-app-bar__title { - padding-inline-start: 20px; + padding-inline-start: 24px; padding-inline-end: initial; } `, diff --git a/src/components/ha-web-rtc-player.ts b/src/components/ha-web-rtc-player.ts index e6276aa5f82f..562bb382ff5b 100644 --- a/src/components/ha-web-rtc-player.ts +++ b/src/components/ha-web-rtc-player.ts @@ -1,4 +1,5 @@ /* eslint-disable no-console */ +import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, @@ -11,9 +12,12 @@ import { customElement, property, query, state } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../common/dom/fire_event"; import { + addWebRtcCandidate, fetchWebRtcClientConfiguration, - handleWebRtcOffer, WebRtcAnswer, + WebRTCClientConfiguration, + webRtcOffer, + WebRtcOfferEvent, } from "../data/camera"; import type { HomeAssistant } from "../types"; import "./ha-alert"; @@ -27,7 +31,7 @@ import "./ha-alert"; class HaWebRtcPlayer extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property() public entityid!: string; + @property() public entityid?: string; @property({ type: Boolean, attribute: "controls" }) public controls = false; @@ -45,12 +49,20 @@ class HaWebRtcPlayer extends LitElement { @state() private _error?: string; - @query("#remote-stream", true) private _videoEl!: HTMLVideoElement; + @query("#remote-stream") private _videoEl!: HTMLVideoElement; + + private _clientConfig?: WebRTCClientConfiguration; private _peerConnection?: RTCPeerConnection; private _remoteStream?: MediaStream; + private _unsub?: Promise; + + private _sessionId?: string; + + private _candidatesList: string[] = []; + protected override render(): TemplateResult { if (this._error) { return html`${this._error}`; @@ -70,7 +82,7 @@ class HaWebRtcPlayer extends LitElement { public override connectedCallback() { super.connectedCallback(); - if (this.hasUpdated) { + if (this.hasUpdated && this.entityid) { this._startWebRtc(); } } @@ -80,7 +92,8 @@ class HaWebRtcPlayer extends LitElement { this._cleanUp(); } - protected override updated(changedProperties: PropertyValues) { + protected override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); if (!changedProperties.has("entityid")) { return; } @@ -88,28 +101,75 @@ class HaWebRtcPlayer extends LitElement { } private async _startWebRtc(): Promise { + this._cleanUp(); + + // Browser support required for WebRTC + if (typeof RTCPeerConnection === "undefined") { + this._error = "WebRTC is not supported in this browser"; + fireEvent(this, "streams", { hasAudio: false, hasVideo: false }); + return; + } + + if (!this.hass || !this.entityid) { + return; + } + console.time("WebRTC"); this._error = undefined; console.timeLog("WebRTC", "start clientConfig"); - const clientConfig = await fetchWebRtcClientConfiguration( + this._clientConfig = await fetchWebRtcClientConfiguration( this.hass, this.entityid ); - console.timeLog("WebRTC", "end clientConfig", clientConfig); + console.timeLog("WebRTC", "end clientConfig", this._clientConfig); - const peerConnection = new RTCPeerConnection(clientConfig.configuration); + this._peerConnection = new RTCPeerConnection( + this._clientConfig.configuration + ); - if (clientConfig.dataChannel) { + if (this._clientConfig.dataChannel) { // Some cameras (such as nest) require a data channel to establish a stream // however, not used by any integrations. - peerConnection.createDataChannel(clientConfig.dataChannel); + this._peerConnection.createDataChannel(this._clientConfig.dataChannel); + } + + this._peerConnection.onnegotiationneeded = this._startNegotiation; + + this._peerConnection.onicecandidate = this._handleIceCandidate; + this._peerConnection.oniceconnectionstatechange = + this._iceConnectionStateChanged; + + // just for debugging + this._peerConnection.onsignalingstatechange = (ev) => { + switch ((ev.target as RTCPeerConnection).signalingState) { + case "stable": + console.timeLog("WebRTC", "ICE negotiation complete"); + break; + default: + console.timeLog( + "WebRTC", + "Signaling state changed", + (ev.target as RTCPeerConnection).signalingState + ); + } + }; + + // Setup callbacks to render remote stream once media tracks are discovered. + this._remoteStream = new MediaStream(); + this._peerConnection.ontrack = this._addTrack; + + this._peerConnection.addTransceiver("audio", { direction: "recvonly" }); + this._peerConnection.addTransceiver("video", { direction: "recvonly" }); + } + + private _startNegotiation = async () => { + if (!this._peerConnection) { + return; } - peerConnection.addTransceiver("audio", { direction: "recvonly" }); - peerConnection.addTransceiver("video", { direction: "recvonly" }); const offerOptions: RTCOfferOptions = { offerToReceiveAudio: true, @@ -119,98 +179,224 @@ class HaWebRtcPlayer extends LitElement { console.timeLog("WebRTC", "start createOffer", offerOptions); const offer: RTCSessionDescriptionInit = - await peerConnection.createOffer(offerOptions); + await this._peerConnection.createOffer(offerOptions); + + if (!this._peerConnection) { + return; + } console.timeLog("WebRTC", "end createOffer", offer); console.timeLog("WebRTC", "start setLocalDescription"); - await peerConnection.setLocalDescription(offer); + await this._peerConnection.setLocalDescription(offer); + + if (!this._peerConnection || !this.entityid) { + return; + } console.timeLog("WebRTC", "end setLocalDescription"); - console.timeLog("WebRTC", "start iceResolver"); - - let candidates = ""; // Build an Offer SDP string with ice candidates - const iceResolver = new Promise((resolve) => { - peerConnection.addEventListener("icecandidate", (event) => { - if (!event.candidate?.candidate) { - resolve(); // Gathering complete - return; - } - console.timeLog("WebRTC", "iceResolver candidate", event.candidate); - candidates += `a=${event.candidate.candidate}\r\n`; + let candidates = ""; + + if (this._clientConfig?.getCandidatesUpfront) { + await new Promise((resolve) => { + this._peerConnection!.onicegatheringstatechange = (ev: Event) => { + const iceGatheringState = (ev.target as RTCPeerConnection) + .iceGatheringState; + if (iceGatheringState === "complete") { + this._peerConnection!.onicegatheringstatechange = null; + resolve(); + } + + console.timeLog( + "WebRTC", + "Ice gathering state changed", + iceGatheringState + ); + }; }); - }); - await iceResolver; - console.timeLog("WebRTC", "end iceResolver", candidates); + if (!this._peerConnection || !this.entityid) { + return; + } + } + + while (this._candidatesList.length) { + const candidate = this._candidatesList.pop(); + if (candidate) { + candidates += `a=${candidate}\r\n`; + } + } const offer_sdp = offer.sdp! + candidates; - let webRtcAnswer: WebRtcAnswer; + console.timeLog("WebRTC", "start webRtcOffer", offer_sdp); + try { - console.timeLog("WebRTC", "start WebRTCOffer", offer_sdp); - webRtcAnswer = await handleWebRtcOffer( - this.hass, - this.entityid, - offer_sdp + this._unsub = webRtcOffer(this.hass, this.entityid, offer_sdp, (event) => + this._handleOfferEvent(event) ); - console.timeLog("WebRTC", "end webRtcOffer", webRtcAnswer); } catch (err: any) { this._error = "Failed to start WebRTC stream: " + err.message; - peerConnection.close(); + this._cleanUp(); + } + }; + + private _iceConnectionStateChanged = () => { + console.timeLog( + "WebRTC", + "ice connection state change", + this._peerConnection?.iceConnectionState + ); + if (this._peerConnection?.iceConnectionState === "failed") { + this._peerConnection.restartIce(); + } + }; + + private async _handleOfferEvent(event: WebRtcOfferEvent) { + if (!this.entityid) { return; } + if (event.type === "session") { + this._sessionId = event.session_id; + this._candidatesList.forEach((candidate) => + addWebRtcCandidate( + this.hass, + this.entityid!, + event.session_id, + candidate + ) + ); + this._candidatesList = []; + } + if (event.type === "answer") { + console.timeLog("WebRTC", "answer", event.answer); - // Setup callbacks to render remote stream once media tracks are discovered. - const remoteStream = new MediaStream(); - peerConnection.addEventListener("track", (event) => { - console.timeLog("WebRTC", "track", event); - remoteStream.addTrack(event.track); - this._videoEl.srcObject = remoteStream; - }); - this._remoteStream = remoteStream; + this._handleAnswer(event); + } + if (event.type === "candidate") { + console.timeLog("WebRTC", "remote ice candidate", event.candidate); + + try { + await this._peerConnection?.addIceCandidate( + new RTCIceCandidate({ candidate: event.candidate, sdpMid: "0" }) + ); + } catch (err: any) { + console.error(err); + } + } + if (event.type === "error") { + this._error = "Failed to start WebRTC stream: " + event.message; + this._cleanUp(); + } + } + + private _handleIceCandidate = (event: RTCPeerConnectionIceEvent) => { + if (!this.entityid || !event.candidate?.candidate) { + return; + } + + console.timeLog( + "WebRTC", + "local ice candidate", + event.candidate?.candidate + ); + + if (this._sessionId) { + addWebRtcCandidate( + this.hass, + this.entityid, + this._sessionId, + event.candidate?.candidate + ); + } else { + this._candidatesList.push(event.candidate?.candidate); + } + }; + + private _addTrack = async (event: RTCTrackEvent) => { + if (!this._remoteStream) { + return; + } + this._remoteStream.addTrack(event.track); + if (!this.hasUpdated) { + await this.updateComplete; + } + this._videoEl.srcObject = this._remoteStream; + }; + + private async _handleAnswer(event: WebRtcAnswer) { + if ( + !this._peerConnection?.signalingState || + ["stable", "closed"].includes(this._peerConnection.signalingState) + ) { + return; + } // Initiate the stream with the remote device const remoteDesc = new RTCSessionDescription({ type: "answer", - sdp: webRtcAnswer.answer, + sdp: event.answer, }); try { console.timeLog("WebRTC", "start setRemoteDescription", remoteDesc); - await peerConnection.setRemoteDescription(remoteDesc); - console.timeLog("WebRTC", "end setRemoteDescription"); + await this._peerConnection.setRemoteDescription(remoteDesc); } catch (err: any) { this._error = "Failed to connect WebRTC stream: " + err.message; - peerConnection.close(); - return; + this._cleanUp(); } - this._peerConnection = peerConnection; + console.timeLog("WebRTC", "end setRemoteDescription"); } private _cleanUp() { + console.timeLog("WebRTC", "stopped"); + console.timeEnd("WebRTC"); + if (this._remoteStream) { this._remoteStream.getTracks().forEach((track) => { track.stop(); }); + this._remoteStream = undefined; } - if (this._videoEl) { - this._videoEl.removeAttribute("src"); - this._videoEl.load(); + const videoEl = this._videoEl; + if (videoEl) { + videoEl.removeAttribute("src"); + videoEl.load(); } if (this._peerConnection) { this._peerConnection.close(); + + this._peerConnection.onnegotiationneeded = null; + this._peerConnection.onicecandidate = null; + this._peerConnection.oniceconnectionstatechange = null; + this._peerConnection.onicegatheringstatechange = null; + this._peerConnection.ontrack = null; + + // just for debugging + this._peerConnection.onsignalingstatechange = null; + this._peerConnection = undefined; } + this._unsub?.then((unsub) => unsub()); + this._unsub = undefined; + this._sessionId = undefined; + this._candidatesList = []; } private _loadedData() { console.timeLog("WebRTC", "loadedData"); console.timeEnd("WebRTC"); - // @ts-ignore + + const video = this._videoEl; + const stream = video.srcObject as MediaStream; + fireEvent(this, "load"); + fireEvent(this, "streams", { + hasAudio: Boolean(stream?.getAudioTracks().length), + hasVideo: Boolean(stream?.getVideoTracks().length), + }); } static get styles(): CSSResultGroup { diff --git a/src/components/map/ha-entity-marker.ts b/src/components/map/ha-entity-marker.ts index 178de1063f6c..81df58fb8149 100644 --- a/src/components/map/ha-entity-marker.ts +++ b/src/components/map/ha-entity-marker.ts @@ -43,6 +43,7 @@ class HaEntityMarker extends LitElement { .marker { display: flex; justify-content: center; + text-align: center; align-items: center; box-sizing: border-box; width: 48px; diff --git a/src/data/camera.ts b/src/data/camera.ts index 0bf40f9cb524..553853c32831 100644 --- a/src/data/camera.ts +++ b/src/data/camera.ts @@ -13,6 +13,8 @@ export const CAMERA_SUPPORT_STREAM = 2; export const STREAM_TYPE_HLS = "hls"; export const STREAM_TYPE_WEB_RTC = "web_rtc"; +export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC; + interface CameraEntityAttributes extends HassEntityAttributeBase { model_name: string; access_token: string; @@ -39,10 +41,37 @@ export interface Stream { url: string; } +export type WebRtcOfferEvent = + | WebRtcId + | WebRtcAnswer + | WebRtcCandidate + | WebRtcError; + +export interface WebRtcId { + type: "session"; + session_id: string; +} + export interface WebRtcAnswer { + type: "answer"; answer: string; } +export interface WebRtcCandidate { + type: "candidate"; + candidate: string; +} + +export interface WebRtcError { + type: "error"; + code: string; + message: string; +} + +export interface WebRtcOfferResponse { + id: string; +} + export const cameraUrlWithWidthHeight = ( base_url: string, width: number, @@ -94,15 +123,29 @@ export const fetchStreamUrl = async ( return stream; }; -export const handleWebRtcOffer = ( +export const webRtcOffer = ( hass: HomeAssistant, - entityId: string, - offer: string + entity_id: string, + offer: string, + callback: (event: WebRtcOfferEvent) => void ) => - hass.callWS({ - type: "camera/web_rtc_offer", - entity_id: entityId, - offer: offer, + hass.connection.subscribeMessage(callback, { + type: "camera/webrtc/offer", + entity_id, + offer, + }); + +export const addWebRtcCandidate = ( + hass: HomeAssistant, + entity_id: string, + session_id: string, + candidate: string +) => + hass.callWS({ + type: "camera/webrtc/candidate", + entity_id, + session_id, + candidate, }); export const fetchCameraPrefs = (hass: HomeAssistant, entityId: string) => @@ -134,9 +177,20 @@ export const isCameraMediaSource = (mediaContentId: string) => export const getEntityIdFromCameraMediaSource = (mediaContentId: string) => mediaContentId.substring(CAMERA_MEDIA_SOURCE_PREFIX.length); +export interface CameraCapabilities { + frontend_stream_types: StreamType[]; +} + +export const fetchCameraCapabilities = async ( + hass: HomeAssistant, + entity_id: string +) => + hass.callWS({ type: "camera/capabilities", entity_id }); + export interface WebRTCClientConfiguration { configuration: RTCConfiguration; dataChannel?: string; + getCandidatesUpfront: boolean; } export const fetchWebRtcClientConfiguration = async ( diff --git a/src/data/hassio/supervisor.ts b/src/data/hassio/supervisor.ts index 8bf9d715703d..e16d3220be40 100644 --- a/src/data/hassio/supervisor.ts +++ b/src/data/hassio/supervisor.ts @@ -65,6 +65,10 @@ export type HassioInfo = { timezone: string; }; +export type HassioBoots = { + boots: Record; +}; + export type HassioPanelInfo = PanelInfo< | undefined | { @@ -177,14 +181,18 @@ export const fetchHassioInfo = async ( ); }; +export const fetchHassioBoots = async (hass: HomeAssistant) => + hass.callApi>("GET", `hassio/host/logs/boots`); + export const fetchHassioLogs = async ( hass: HomeAssistant, provider: string, - range?: string + range?: string, + boot = 0 ) => hass.callApiRaw( "GET", - `hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`, + `hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs/boots/${boot}`, undefined, range ? { @@ -197,11 +205,12 @@ export const fetchHassioLogsFollow = async ( hass: HomeAssistant, provider: string, signal: AbortSignal, - lines = 100 + lines = 100, + boot = 0 ) => hass.callApiRaw( "GET", - `hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs/follow?lines=${lines}`, + `hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs/boots/${boot}/follow?lines=${lines}`, undefined, undefined, signal @@ -212,10 +221,14 @@ export const getHassioLogDownloadUrl = (provider: string) => provider.includes("_") ? `addons/${provider}` : provider }/logs`; -export const getHassioLogDownloadLinesUrl = (provider: string, lines: number) => +export const getHassioLogDownloadLinesUrl = ( + provider: string, + lines: number, + boot = 0 +) => `/api/hassio/${ provider.includes("_") ? `addons/${provider}` : provider - }/logs?lines=${lines}`; + }/logs/boots/${boot}?lines=${lines}`; export const setSupervisorOption = async ( hass: HomeAssistant, diff --git a/src/data/network.ts b/src/data/network.ts index 5bfb391c00b3..10482557ac1d 100644 --- a/src/data/network.ts +++ b/src/data/network.ts @@ -26,6 +26,12 @@ export interface NetworkConfig { configured_adapters: string[]; } +export interface NetworkUrls { + internal: string; + external: string; + cloud: string; +} + export const getNetworkConfig = (hass: HomeAssistant) => hass.callWS({ type: "network", @@ -41,3 +47,8 @@ export const setNetworkConfig = ( configured_adapters: configured_adapters, }, }); + +export const getNetworkUrls = (hass: HomeAssistant) => + hass.callWS({ + type: "network/url", + }); diff --git a/src/dialogs/more-info/const.ts b/src/dialogs/more-info/const.ts index e5589107791c..ef6ef2b28cb4 100644 --- a/src/dialogs/more-info/const.ts +++ b/src/dialogs/more-info/const.ts @@ -31,6 +31,9 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [ "valve", "water_heater", ]; +/** Domains with full height more info dialog */ +export const DOMAINS_FULL_HEIGHT_MORE_INFO = ["update"]; + /** Domains with separate more info dialog. */ export const DOMAINS_WITH_MORE_INFO = [ "alarm_control_panel", diff --git a/src/dialogs/more-info/controls/more-info-cover.ts b/src/dialogs/more-info/controls/more-info-cover.ts index a352002e4adb..bf96995fcfe8 100644 --- a/src/dialogs/more-info/controls/more-info-cover.ts +++ b/src/dialogs/more-info/controls/more-info-cover.ts @@ -93,12 +93,13 @@ class MoreInfoCover extends LitElement { supportsFeature(this.stateObj, CoverEntityFeature.CLOSE_TILT) || supportsFeature(this.stateObj, CoverEntityFeature.STOP_TILT); - const supportsOpenCloseWithoutStop = + const supportsOpenCloseOnly = supportsFeature(this.stateObj, CoverEntityFeature.OPEN) && supportsFeature(this.stateObj, CoverEntityFeature.CLOSE) && !supportsFeature(this.stateObj, CoverEntityFeature.STOP) && - !supportsFeature(this.stateObj, CoverEntityFeature.OPEN_TILT) && - !supportsFeature(this.stateObj, CoverEntityFeature.CLOSE_TILT); + !supportsTilt && + !supportsPosition && + !supportsTiltPosition; return html` ` - : html`` - : ""} -

${this.stateObj.attributes.title}

- ${this._error - ? html`${this._error}` - : ""} -
-
- ${this.hass.formatEntityAttributeName( - this.stateObj, - "installed_version" - )} -
-
- ${this.stateObj.attributes.installed_version ?? - this.hass.localize("state.default.unavailable")} -
-
-
-
- ${this.hass.formatEntityAttributeName( - this.stateObj, - "latest_version" - )} +
+ ${this.stateObj.attributes.in_progress + ? supportsFeature(this.stateObj, UpdateEntityFeature.PROGRESS) && + this.stateObj.attributes.update_percentage !== null + ? html`` + : html`` + : nothing} +

${this.stateObj.attributes.title}

+ ${this._error + ? html`${this._error}` + : nothing} +
+
+ ${this.hass.formatEntityAttributeName( + this.stateObj, + "installed_version" + )} +
+
+ ${this.stateObj.attributes.installed_version ?? + this.hass.localize("state.default.unavailable")} +
-
- ${this.stateObj.attributes.latest_version ?? - this.hass.localize("state.default.unavailable")} +
+
+ ${this.hass.formatEntityAttributeName( + this.stateObj, + "latest_version" + )} +
+
+ ${this.stateObj.attributes.latest_version ?? + this.hass.localize("state.default.unavailable")} +
-
- ${this.stateObj.attributes.release_url - ? html`` - : ""} - ${supportsFeature(this.stateObj!, UpdateEntityFeature.RELEASE_NOTES) && - !this._error - ? this._releaseNotes === undefined - ? html`
- + ${this.stateObj.attributes.release_url + ? html`` - : html`
- - - ` - : this.stateObj.attributes.release_summary - ? html`
- ` - : ""} - ${supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP) - ? html`
- - - ` - : ""} -
- ${this.stateObj.state === BINARY_STATE_OFF && - this.stateObj.attributes.skipped_version - ? html` - - ${this.hass.localize( - "ui.dialogs.more_info_control.update.clear_skipped" - )} - - ` - : html` - - ${this.hass.localize( - "ui.dialogs.more_info_control.update.skip" - )} - - `} - ${supportsFeature(this.stateObj, UpdateEntityFeature.INSTALL) + : nothing} + ${supportsFeature(this.stateObj!, UpdateEntityFeature.RELEASE_NOTES) && + !this._error + ? this._releaseNotes === undefined + ? html` +
+ ${this._markdownLoading ? this._renderLoader() : nothing} + ` + : html` +
+ + ${this._markdownLoading ? this._renderLoader() : nothing} + ` + : this.stateObj.attributes.release_summary + ? html` +
+ + ${this._markdownLoading ? this._renderLoader() : nothing} + ` + : nothing} +
+ + `; + } + + private _renderLoader() { + return html` +
+
`; } protected firstUpdated(): void { if (supportsFeature(this.stateObj!, UpdateEntityFeature.RELEASE_NOTES)) { - updateReleaseNotes(this.hass, this.stateObj!.entity_id) - .then((result) => { - this._releaseNotes = result; - }) - .catch((err) => { - this._error = err.message; - }); + this._fetchReleaseNotes(); + } + } + + private async _markdownLoaded() { + if (this._markdownLoading) { + this._markdownLoading = false; + } + } + + private async _fetchReleaseNotes() { + try { + this._releaseNotes = await updateReleaseNotes( + this.hass, + this.stateObj!.entity_id + ); + } catch (err: any) { + this._error = err.message; } } @@ -183,9 +225,11 @@ class MoreInfoUpdate extends LitElement { if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) { return null; } - const checkbox = this.shadowRoot?.querySelector("ha-checkbox"); - if (checkbox) { - return checkbox.checked; + const createBackupSwitch = this.shadowRoot?.getElementById( + "create-backup" + ) as HaSwitch; + if (createBackupSwitch) { + return createBackupSwitch.checked; } return true; } @@ -234,6 +278,12 @@ class MoreInfoUpdate extends LitElement { static get styles(): CSSResultGroup { return css` + :host { + display: flex; + flex-direction: column; + flex: 1; + justify-content: space-between; + } hr { border-color: var(--divider-color); border-bottom: none; @@ -248,26 +298,44 @@ class MoreInfoUpdate extends LitElement { flex-direction: row; justify-content: space-between; } - .actions { + + .footer { border-top: 1px solid var(--divider-color); background: var( --ha-dialog-surface-background, var(--mdc-theme-surface, #fff) ); - margin: 8px 0 0; - display: flex; - flex-wrap: wrap; - justify-content: center; position: sticky; bottom: 0; - padding: 12px 0; - margin-bottom: -24px; - z-index: 1; + margin: 0 -24px -24px -24px; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + overflow: hidden; + z-index: 10; } - .actions mwc-button { - margin: 0 4px 4px; + ha-settings-row { + width: 100%; + padding: 0 24px; + box-sizing: border-box; + margin-bottom: -16px; + margin-top: -4px; } + + .actions { + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-end; + box-sizing: border-box; + padding: 12px; + z-index: 1; + gap: 8px; + } + a { color: var(--primary-color); } @@ -282,6 +350,16 @@ class MoreInfoUpdate extends LitElement { } ha-markdown { direction: ltr; + padding-bottom: 16px; + box-sizing: border-box; + } + ha-markdown.hidden { + display: none; + } + .loader { + height: 80px; + box-sizing: border-box; + padding-bottom: 16px; } `; } diff --git a/src/dialogs/more-info/controls/more-info-valve.ts b/src/dialogs/more-info/controls/more-info-valve.ts index f7bae2a9bef5..63e23d737a50 100644 --- a/src/dialogs/more-info/controls/more-info-valve.ts +++ b/src/dialogs/more-info/controls/more-info-valve.ts @@ -83,10 +83,11 @@ class MoreInfoValve extends LitElement { supportsFeature(this.stateObj, ValveEntityFeature.CLOSE) || supportsFeature(this.stateObj, ValveEntityFeature.STOP); - const supportsOpenCloseWithoutStop = + const supportsOpenCloseOnly = supportsFeature(this.stateObj, ValveEntityFeature.OPEN) && supportsFeature(this.stateObj, ValveEntityFeature.CLOSE) && - !supportsFeature(this.stateObj, ValveEntityFeature.STOP); + !supportsFeature(this.stateObj, ValveEntityFeature.STOP) && + !supportsPosition; return html` @@ -89,7 +92,7 @@ export class MoreInfoInfo extends LitElement { .entityId=${this.entityId} >`} ({ + mode_above: inputAboveIsEntity ? "input" : "value", + mode_below: inputBelowIsEntity ? "input" : "value", + ...condition, + }) + ); + private _schema = memoizeOne( ( localize: LocalizeFunc, @@ -233,31 +245,33 @@ export default class HaNumericStateCondition extends LitElement { ] as const ); - public render() { - const inputAboveIsEntity = + public willUpdate() { + this._inputAboveIsEntity = this._inputAboveIsEntity ?? (typeof this.condition.above === "string" && ((this.condition.above as string).startsWith("input_number.") || (this.condition.above as string).startsWith("number.") || (this.condition.above as string).startsWith("sensor."))); - const inputBelowIsEntity = + this._inputBelowIsEntity = this._inputBelowIsEntity ?? (typeof this.condition.below === "string" && ((this.condition.below as string).startsWith("input_number.") || (this.condition.below as string).startsWith("number.") || (this.condition.below as string).startsWith("sensor."))); + } + public render() { const schema = this._schema( this.hass.localize, - inputAboveIsEntity, - inputBelowIsEntity + this._inputAboveIsEntity, + this._inputBelowIsEntity ); - const data = { - mode_above: inputAboveIsEntity ? "input" : "value", - mode_below: inputBelowIsEntity ? "input" : "value", - ...this.condition, - }; + const data = this._data( + this._inputAboveIsEntity!, + this._inputBelowIsEntity!, + this.condition + ); return html` ({ + mode_above: inputAboveIsEntity ? "input" : "value", + mode_below: inputBelowIsEntity ? "input" : "value", + ...trigger, + entity_id: ensureArray(trigger.entity_id), + for: createDurationData(trigger.for), + }) + ); + public render() { const schema = this._schema( this.hass.localize, this.trigger.entity_id, - inputAboveIsEntity, - inputBelowIsEntity + this._inputAboveIsEntity, + this._inputBelowIsEntity ); - const data = { - mode_above: inputAboveIsEntity ? "input" : "value", - mode_below: inputBelowIsEntity ? "input" : "value", - ...this.trigger, - entity_id: ensureArray(this.trigger.entity_id), - for: trgFor, - }; + const data = this._data( + this._inputAboveIsEntity!, + this._inputBelowIsEntity!, + this.trigger + ); return html` - ${this.hass.localize( - "ui.panel.config.cloud.account.remote.copy_link" - )} + ${this.hass.localize("ui.panel.config.common.copy_link")}
diff --git a/src/panels/config/devices/device-detail/ha-device-entities-card.ts b/src/panels/config/devices/device-detail/ha-device-entities-card.ts index 71a8b0acdf12..07993cbdb723 100644 --- a/src/panels/config/devices/device-detail/ha-device-entities-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-entities-card.ts @@ -251,9 +251,7 @@ export class HaDeviceEntitiesCard extends LitElement { display: block; } ha-icon { - margin-left: 8px; - margin-inline-start: 8px; - margin-inline-end: initial; + margin-left: -8px; } .entity-id { color: var(--secondary-text-color); @@ -283,6 +281,9 @@ export class HaDeviceEntitiesCard extends LitElement { .name { font-size: 14px; } + .name:dir(rtl) { + margin-inline-start: 8px; + } .empty { text-align: center; } @@ -302,6 +303,9 @@ export class HaDeviceEntitiesCard extends LitElement { outline: none; text-decoration: underline; } + ha-list-item { + height: 40px; + } `; } } diff --git a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts index 59b425b47d7a..87b027166afe 100644 --- a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts +++ b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts @@ -100,8 +100,8 @@ export class EntitySettingsHelperTab extends LitElement { .entry=${this.entry} .disabled=${this._submitting} @change=${this._entityRegistryChanged} - hideName - hideIcon + hide-name + hide-icon >
diff --git a/src/panels/config/entities/entity-registry-settings-editor.ts b/src/panels/config/entities/entity-registry-settings-editor.ts index da34ca591b62..158d281f7778 100644 --- a/src/panels/config/entities/entity-registry-settings-editor.ts +++ b/src/panels/config/entities/entity-registry-settings-editor.ts @@ -44,6 +44,7 @@ import { CAMERA_ORIENTATIONS, CAMERA_SUPPORT_STREAM, CameraPreferences, + fetchCameraCapabilities, fetchCameraPrefs, STREAM_TYPE_HLS, updateCameraPrefs, @@ -145,9 +146,9 @@ export class EntityRegistrySettingsEditor extends LitElement { @property({ type: Object }) public entry!: ExtEntityRegistryEntry; - @property({ type: Boolean }) public hideName = false; + @property({ type: Boolean, attribute: "hide-name" }) public hideName = false; - @property({ type: Boolean }) public hideIcon = false; + @property({ type: Boolean, attribute: "hide-icon" }) public hideIcon = false; @property({ type: Boolean }) public disabled = false; @@ -236,13 +237,7 @@ export class EntityRegistrySettingsEditor extends LitElement { if (domain === "camera" && isComponentLoaded(this.hass, "stream")) { const stateObj: HassEntity | undefined = this.hass.states[this.entry.entity_id]; - if ( - stateObj && - supportsFeature(stateObj, CAMERA_SUPPORT_STREAM) && - // The stream component for HLS streams supports a server-side pre-load - // option that client initiated WebRTC streams do not - stateObj.attributes.frontend_stream_type === STREAM_TYPE_HLS - ) { + if (stateObj && supportsFeature(stateObj, CAMERA_SUPPORT_STREAM)) { this._fetchCameraPrefs(); } } @@ -1396,6 +1391,19 @@ export class EntityRegistrySettingsEditor extends LitElement { } private async _fetchCameraPrefs() { + const capabilities = await fetchCameraCapabilities( + this.hass, + this.entry.entity_id + ); + + // The stream component for HLS streams supports a server-side pre-load + // option that client initiated WebRTC streams do not + + if (!capabilities.frontend_stream_types.includes(STREAM_TYPE_HLS)) { + this._cameraPrefs = undefined; + return; + } + this._cameraPrefs = await fetchCameraPrefs(this.hass, this.entry.entity_id); } diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index e828e7b6e101..1859f5578d75 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -1069,8 +1069,7 @@ ${ } if ( changedProps.has("_entitySources") || - (changedProps.has("hass") && !oldHass) || - !oldHass.states[entityId] + (changedProps.has("hass") && (!oldHass || !oldHass.states[entityId])) ) { changed = true; } diff --git a/src/panels/config/labels/ha-config-labels.ts b/src/panels/config/labels/ha-config-labels.ts index db2f60663106..7d0e60e22d42 100644 --- a/src/panels/config/labels/ha-config-labels.ts +++ b/src/panels/config/labels/ha-config-labels.ts @@ -103,6 +103,7 @@ export class HaConfigLabels extends LitElement { style=" background-color: ${computeCssColor(label.color)}; border-radius: 10px; + outline: 1px solid var(--outline-color); width: 20px; height: 20px;" >
` diff --git a/src/panels/config/logs/dialog-download-logs.ts b/src/panels/config/logs/dialog-download-logs.ts index cd711fa5a2b4..fdb5e7242ea4 100644 --- a/src/panels/config/logs/dialog-download-logs.ts +++ b/src/panels/config/logs/dialog-download-logs.ts @@ -65,7 +65,11 @@ class DownloadLogsDialog extends LitElement { ${this.hass.localize("ui.panel.config.logs.download_full_log")} - ${this._dialogParams.header} + + ${this._dialogParams.header}${this._dialogParams.boot === 0 + ? "" + : ` ⸱ ${this._dialogParams.boot === -1 ? this.hass.localize("ui.panel.config.logs.previous") : this.hass.localize("ui.panel.config.logs.startups_ago", { boot: this._dialogParams.boot * -1 })}`} +
@@ -104,9 +108,14 @@ class DownloadLogsDialog extends LitElement { private async _dowloadLogs() { const provider = this._dialogParams!.provider; + const boot = this._dialogParams!.boot; const timeString = new Date().toISOString().replace(/:/g, "-"); - const downloadUrl = getHassioLogDownloadLinesUrl(provider, this._lineCount); + const downloadUrl = getHassioLogDownloadLinesUrl( + provider, + this._lineCount, + boot + ); const logFileName = provider !== "core" ? `${provider}_${timeString}.log` diff --git a/src/panels/config/logs/error-log-card.ts b/src/panels/config/logs/error-log-card.ts index 41bd9eea19f6..ae17a7a16466 100644 --- a/src/panels/config/logs/error-log-card.ts +++ b/src/panels/config/logs/error-log-card.ts @@ -1,5 +1,10 @@ import "@material/mwc-list/mwc-list-item"; -import { mdiArrowCollapseDown, mdiDownload, mdiRefresh } from "@mdi/js"; +import { + mdiArrowCollapseDown, + mdiDownload, + mdiMenuDown, + mdiRefresh, +} from "@mdi/js"; import { css, CSSResultGroup, @@ -22,12 +27,17 @@ import "../../../components/ha-button"; import "../../../components/ha-icon-button"; import "../../../components/ha-svg-icon"; import "../../../components/ha-circular-progress"; +import "../../../components/chips/ha-assist-chip"; +import "../../../components/ha-menu"; +import "../../../components/ha-md-menu-item"; +import "../../../components/ha-md-divider"; import { getSignedPath } from "../../../data/auth"; import { fetchErrorLog, getErrorLogDownloadUrl } from "../../../data/error_log"; import { extractApiErrorMessage } from "../../../data/hassio/common"; import { + fetchHassioBoots, fetchHassioLogs, fetchHassioLogsFollow, getHassioLogDownloadUrl, @@ -40,6 +50,7 @@ import { atLeastVersion } from "../../../common/config/version"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { debounce } from "../../../common/util/debounce"; import { showDownloadLogsDialog } from "./show-dialog-download-logs"; +import type { HaMenu } from "../../../components/ha-menu"; const NUMBER_OF_LINES = 100; @@ -64,6 +75,8 @@ class ErrorLogCard extends LitElement { @query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml; + @query("#boots-menu") private _bootsMenu?: HaMenu; + @state() private _firstCursor?: string; @state() private _scrolledToBottomController = @@ -92,6 +105,10 @@ class ErrorLogCard extends LitElement { @state() private _numberOfLines?: number; + @state() private _boot = 0; + + @state() private _boots?: number[]; + protected render(): TemplateResult { return html`
@@ -105,6 +122,60 @@ class ErrorLogCard extends LitElement { this.hass.localize("ui.panel.config.logs.show_full_logs")}
+ ${this._streamSupported && Array.isArray(this._boots) + ? html` + + + + ${this._boots.map( + (boot) => html` + + ${boot === 0 + ? this.hass.localize( + "ui.panel.config.logs.current" + ) + : boot === -1 + ? this.hass.localize( + "ui.panel.config.logs.previous" + ) + : this.hass.localize( + "ui.panel.config.logs.startups_ago", + { boot: boot * -1 } + )} + + ${boot === 0 + ? html`` + : nothing} + ` + )} + + ` + : nothing} b - a); + } catch (err: any) { + // eslint-disable-next-line no-console + console.error(err); + } + } + } + + private _toggleBootsMenu() { + if (this._bootsMenu) { + this._bootsMenu.open = !this._bootsMenu.open; + } + } + + private _setBoot(ev: any) { + this._boot = ev.target.value; + this._loadLogs(); + } + static styles: CSSResultGroup = css` .error-log-intro { text-align: center; @@ -600,6 +703,11 @@ class ErrorLogCard extends LitElement { justify-content: center; padding: 16px; } + + ha-assist-chip { + --ha-assist-chip-container-shape: 10px; + --md-assist-chip-trailing-space: 8px; + } `; } diff --git a/src/panels/config/logs/show-dialog-download-logs.ts b/src/panels/config/logs/show-dialog-download-logs.ts index e61d0c22ad7e..e0267075fe4c 100644 --- a/src/panels/config/logs/show-dialog-download-logs.ts +++ b/src/panels/config/logs/show-dialog-download-logs.ts @@ -4,6 +4,7 @@ export interface DownloadLogsDialogParams { header?: string; provider: string; defaultLineCount?: number; + boot: number; } export const showDownloadLogsDialog = ( diff --git a/src/panels/config/network/ha-config-url-form.ts b/src/panels/config/network/ha-config-url-form.ts index 8ec30540b182..934869b79995 100644 --- a/src/panels/config/network/ha-config-url-form.ts +++ b/src/panels/config/network/ha-config-url-form.ts @@ -8,6 +8,7 @@ import { nothing, } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { mdiContentCopy, mdiEyeOff, mdiEye } from "@mdi/js"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isIPAddress } from "../../../common/string/is_ip_address"; import "../../../components/ha-alert"; @@ -18,7 +19,12 @@ import "../../../components/ha-textfield"; import type { HaTextField } from "../../../components/ha-textfield"; import { CloudStatus, fetchCloudStatus } from "../../../data/cloud"; import { saveCoreConfig } from "../../../data/core"; +import { getNetworkUrls, type NetworkUrls } from "../../../data/network"; import type { ValueChangedEvent, HomeAssistant } from "../../../types"; +import { copyToClipboard } from "../../../common/util/copy-clipboard"; +import { showToast } from "../../../util/toast"; +import type { HaSwitch } from "../../../components/ha-switch"; +import { obfuscateUrl } from "../../../util/url"; @customElement("ha-config-url-form") class ConfigUrlForm extends LitElement { @@ -28,9 +34,11 @@ class ConfigUrlForm extends LitElement { @state() private _working = false; - @state() private _external_url?: string; + @state() private _urls?: NetworkUrls; - @state() private _internal_url?: string; + @state() private _external_url: string = ""; + + @state() private _internal_url: string = ""; @state() private _cloudStatus?: CloudStatus | null; @@ -38,18 +46,29 @@ class ConfigUrlForm extends LitElement { @state() private _showCustomInternalUrl = false; + @state() private _unmaskedExternalUrl = false; + + @state() private _unmaskedInternalUrl = false; + + @state() private _cloudChecked = false; + protected render() { const canEdit = ["storage", "default"].includes( this.hass.config.config_source ); const disabled = this._working || !canEdit; - if (!this.hass.userData?.showAdvanced || this._cloudStatus === undefined) { + if (this._cloudStatus === undefined || this._urls === undefined) { return nothing; } - const internalUrl = this._internalUrlValue; - const externalUrl = this._externalUrlValue; + const internalUrl = this._showCustomInternalUrl + ? this._internal_url + : this._urls?.internal || ""; + const externalUrl = this._showCustomExternalUrl + ? this._external_url + : (this._cloudChecked ? this._urls?.cloud : this._urls?.external) || ""; + let hasCloud: boolean; let remoteEnabled: boolean; let httpUseHttps: boolean; @@ -95,49 +114,61 @@ class ConfigUrlForm extends LitElement { ${hasCloud ? html` -
-
+

+ ${this.hass.localize( + "ui.panel.config.url.external_url_label" + )} +

+ + ${this.hass.localize( - "ui.panel.config.url.external_url_label" - )} -
- - - -
+ + + ` : ""} - ${!this._showCustomExternalUrl - ? "" - : html` -
-
- ${hasCloud - ? "" - : this.hass.localize( - "ui.panel.config.url.external_url_label" - )} -
- - -
- `} +
+
+
` + } + > + ${!this._showCustomExternalUrl || !canEdit + ? html` + + ` + : nothing} +
+ + + ${this.hass.localize("ui.panel.config.common.copy_link")} + +
${hasCloud || !isComponentLoaded(this.hass, "cloud") ? "" : html` @@ -180,40 +211,65 @@ class ConfigUrlForm extends LitElement { ` : ""} -
-
- ${this.hass.localize("ui.panel.config.url.internal_url_label")} -
- - + ${this.hass.localize("ui.panel.config.url.internal_url_label")} + + + + ${this.hass.localize( "ui.panel.config.url.internal_url_automatic" )} - > - - + + + ${this.hass.localize( + "ui.panel.config.url.internal_url_automatic_description" + )} + + + + +
+
+
` + } + > + ${!this._showCustomInternalUrl || !canEdit + ? html` + + ` + : nothing} +
+ + + ${this.hass.localize("ui.panel.config.common.copy_link")} +
- - ${!this._showCustomInternalUrl - ? "" - : html` -
-
- - -
- `} ${ // If the user has configured a cert, show an error if httpUseHttps && // there is no internal url configured @@ -253,46 +309,47 @@ class ConfigUrlForm extends LitElement { protected override firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); - this._showCustomInternalUrl = this._internalUrlValue !== null; - if (isComponentLoaded(this.hass, "cloud")) { fetchCloudStatus(this.hass).then((cloudStatus) => { this._cloudStatus = cloudStatus; - if (cloudStatus.logged_in) { - this._showCustomExternalUrl = this._externalUrlValue !== null; - } else { - this._showCustomExternalUrl = true; - } + this._showCustomExternalUrl = !( + this._cloudStatus.logged_in && !this.hass.config.external_url + ); }); } else { this._cloudStatus = null; - this._showCustomExternalUrl = true; } + this._fetchUrls(); } - private get _internalUrlValue() { - return this._internal_url !== undefined - ? this._internal_url - : this.hass.config.internal_url; + private _toggleCloud(ev: Event) { + this._cloudChecked = (ev.currentTarget as HaSwitch).checked; + this._showCustomExternalUrl = !this._cloudChecked; } - private get _externalUrlValue() { - return this._external_url !== undefined - ? this._external_url - : this.hass.config.external_url; + private _toggleInternalAutomatic(ev: Event) { + this._showCustomInternalUrl = !(ev.currentTarget as HaSwitch).checked; } - private _toggleCloud(ev) { - this._showCustomExternalUrl = !ev.currentTarget.checked; + private _toggleUnmaskedInternalUrl() { + this._unmaskedInternalUrl = !this._unmaskedInternalUrl; } - private _toggleInternalAutomatic(ev) { - this._showCustomInternalUrl = !ev.currentTarget.checked; + private _toggleUnmaskedExternalUrl() { + this._unmaskedExternalUrl = !this._unmaskedExternalUrl; + } + + private async _copyURL(ev) { + const url = ev.currentTarget.url; + await copyToClipboard(url); + showToast(this, { + message: this.hass.localize("ui.common.copied_clipboard"), + }); } private _handleChange(ev: ValueChangedEvent) { const target = ev.currentTarget as HaTextField; - this[`_${target.name}`] = target.value || null; + this[`_${target.name}`] = target.value || ""; } private async _save() { @@ -307,6 +364,7 @@ class ConfigUrlForm extends LitElement { ? this._internal_url || null : null, }); + await this._fetchUrls(); } catch (err: any) { this._error = err.message || err; } finally { @@ -314,6 +372,19 @@ class ConfigUrlForm extends LitElement { } } + private async _fetchUrls() { + this._urls = await getNetworkUrls(this.hass); + this._cloudChecked = + this._urls?.cloud === this._urls?.external && + !this.hass.config.external_url; + this._showCustomInternalUrl = !!this.hass.config.internal_url; + this._showCustomExternalUrl = !( + this._cloudStatus?.logged_in && !this.hass.config.external_url + ); + this._internal_url = this._urls?.internal ?? ""; + this._external_url = this._urls?.external ?? ""; + } + static get styles(): CSSResultGroup { return css` .description { @@ -351,6 +422,31 @@ class ConfigUrlForm extends LitElement { color: var(--primary-color); text-decoration: none; } + + .url-container { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + } + .textfield-container { + position: relative; + flex: 1; + } + .textfield-container ha-textfield { + display: block; + } + .toggle-unmasked-url { + position: absolute; + top: 8px; + right: 8px; + inset-inline-start: initial; + inset-inline-end: 8px; + --mdc-icon-button-size: 40px; + --mdc-icon-size: 20px; + color: var(--secondary-text-color); + direction: var(--direction); + } `; } } diff --git a/src/panels/config/person/ha-config-person.ts b/src/panels/config/person/ha-config-person.ts index 27e065e54a72..c4c658b02340 100644 --- a/src/panels/config/person/ha-config-person.ts +++ b/src/panels/config/person/ha-config-person.ts @@ -292,6 +292,9 @@ export class HaConfigPerson extends LitElement { align-items: center; justify-content: space-around; } + mwc-list:has(+ .empty) { + display: none; + } `; } } diff --git a/src/panels/developer-tools/statistics/developer-tools-statistics.ts b/src/panels/developer-tools/statistics/developer-tools-statistics.ts index 0f0880058164..989957098f40 100644 --- a/src/panels/developer-tools/statistics/developer-tools-statistics.ts +++ b/src/panels/developer-tools/statistics/developer-tools-statistics.ts @@ -11,7 +11,7 @@ import { mdiUnfoldMoreHorizontal, } from "@mdi/js"; -import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { HassEntity } from "home-assistant-js-websocket"; import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; @@ -33,7 +33,6 @@ import "../../../components/ha-dialog"; import { HaMenu } from "../../../components/ha-menu"; import "../../../components/ha-md-menu-item"; import "../../../components/search-input-outlined"; -import { subscribeEntityRegistry } from "../../../data/entity_registry"; import { StatisticsMetaData, StatisticsValidationResult, @@ -42,7 +41,6 @@ import { updateStatisticsIssues, validateStatistics, } from "../../../data/recorder"; -import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; import { HomeAssistant } from "../../../types"; import { showConfirmationDialog } from "../../lovelace/custom-card-helpers"; @@ -76,7 +74,7 @@ type DisplayedStatisticData = StatisticData & { }; @customElement("developer-tools-statistics") -class HaPanelDevStatistics extends SubscribeMixin(LitElement) { +class HaPanelDevStatistics extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public narrow = false; @@ -107,8 +105,6 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { @query("#sort-by-menu") private _sortByMenu!: HaMenu; - private _disabledEntities = new Set(); - private _toggleGroupBy() { this._groupByMenu.open = !this._groupByMenu.open; } @@ -612,25 +608,6 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { } } - public hassSubscribe(): UnsubscribeFunc[] { - return [ - subscribeEntityRegistry(this.hass.connection!, (entities) => { - const disabledEntities = new Set(); - for (const confEnt of entities) { - if (!confEnt.disabled_by) { - continue; - } - disabledEntities.add(confEnt.entity_id); - } - // If the disabled entities changed, re-validate the statistics - if (disabledEntities !== this._disabledEntities) { - this._disabledEntities = disabledEntities; - this._validateStatistics(); - } - }), - ]; - } - private async _validateStatistics() { const [statisticIds, issues] = await Promise.all([ getStatisticIds(this.hass), @@ -641,24 +618,17 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { const statsIds = new Set(); - this._data = statisticIds - .filter( - (statistic) => !this._disabledEntities.has(statistic.statistic_id) - ) - .map((statistic) => { - statsIds.add(statistic.statistic_id); - return { - ...statistic, - state: this.hass.states[statistic.statistic_id], - issues: issues[statistic.statistic_id], - }; - }); + this._data = statisticIds.map((statistic) => { + statsIds.add(statistic.statistic_id); + return { + ...statistic, + state: this.hass.states[statistic.statistic_id], + issues: issues[statistic.statistic_id], + }; + }); Object.keys(issues).forEach((statisticId) => { - if ( - !statsIds.has(statisticId) && - !this._disabledEntities.has(statisticId) - ) { + if (!statsIds.has(statisticId)) { this._data.push({ statistic_id: statisticId, statistics_unit_of_measurement: "", diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index c16b3842c113..200e3e576ed7 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -18,6 +18,7 @@ import { HomeAssistant } from "../../types"; import "../lovelace/components/hui-energy-period-selector"; import { Lovelace } from "../lovelace/types"; import "../lovelace/views/hui-view"; +import "../lovelace/views/hui-view-container"; import { navigate } from "../../common/navigate"; import { getEnergyDataCollection, @@ -108,14 +109,18 @@ class PanelEnergy extends LitElement {
-
+ + -
+ `; } @@ -389,23 +394,19 @@ class PanelEnergy extends LitElement { line-height: 20px; flex-grow: 1; } - #view { + hui-view-container { position: relative; display: flex; - padding-top: calc(var(--header-height) + env(safe-area-inset-top)); min-height: 100vh; box-sizing: border-box; + padding-top: calc(var(--header-height) + env(safe-area-inset-top)); padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); padding-inline-start: env(safe-area-inset-left); padding-inline-end: env(safe-area-inset-right); padding-bottom: env(safe-area-inset-bottom); - background: var( - --lovelace-background, - var(--primary-background-color) - ); } - #view > * { + hui-view { flex: 1 1 100%; max-width: 100%; } diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts index ae3bd5279881..c50cb0c38eb8 100644 --- a/src/panels/lovelace/cards/hui-map-card.ts +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -50,6 +50,11 @@ interface MapEntityConfig extends EntityConfig { focus?: boolean; } +interface GeoEntity { + entity_id: string; + focus: boolean; +} + @customElement("hui-map-card") class HuiMapCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass!: HomeAssistant; @@ -332,23 +337,32 @@ class HuiMapCard extends LitElement implements LovelaceCard { return color; } - private _getSourceEntities(states?: HassEntities): string[] { + private _getSourceEntities(states?: HassEntities): GeoEntity[] { if (!states || !this._config?.geo_location_sources) { return []; } - const geoEntities: string[] = []; + const sourceObjs = this._config.geo_location_sources.map((source) => + typeof source === "string" ? { source } : source + ); + + const geoEntities: GeoEntity[] = []; // Calculate visible geo location sources - const includesAll = this._config.geo_location_sources.includes("all"); + const allSource = sourceObjs.find((s) => s.source === "all"); for (const stateObj of Object.values(states)) { + const sourceObj = sourceObjs.find( + (s) => s.source === stateObj.attributes.source + ); if ( computeDomain(stateObj.entity_id) === "geo_location" && - (includesAll || - this._config.geo_location_sources.includes( - stateObj.attributes.source - )) + (allSource || sourceObj) ) { - geoEntities.push(stateObj.entity_id); + geoEntities.push({ + entity_id: stateObj.entity_id, + focus: sourceObj + ? (sourceObj.focus ?? true) + : (allSource?.focus ?? true), + }); } } return geoEntities; @@ -364,8 +378,9 @@ class HuiMapCard extends LitElement implements LovelaceCard { name: entityConf.name, })), ...this._getSourceEntities(this.hass?.states).map((entity) => ({ - entity_id: entity, - color: this._getColor(entity), + entity_id: entity.entity_id, + focus: entity.focus, + color: this._getColor(entity.entity_id), })), ]; } diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index c14f07cf790d..6ee537db30ba 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -298,6 +298,11 @@ export interface LogbookCardConfig extends LovelaceCardConfig { theme?: string; } +interface GeoLocationSourceConfig { + source: string; + focus?: boolean; +} + export interface MapCardConfig extends LovelaceCardConfig { type: "map"; title?: string; @@ -307,7 +312,7 @@ export interface MapCardConfig extends LovelaceCardConfig { default_zoom?: number; entities?: Array; hours_to_show?: number; - geo_location_sources?: string[]; + geo_location_sources?: Array; dark_mode?: boolean; theme_mode?: ThemeMode; } diff --git a/src/panels/lovelace/common/confirm-action.ts b/src/panels/lovelace/common/confirm-action.ts new file mode 100644 index 000000000000..2b783cb3f3a6 --- /dev/null +++ b/src/panels/lovelace/common/confirm-action.ts @@ -0,0 +1,25 @@ +import type { ConfirmationRestrictionConfig } from "../../../data/lovelace/config/action"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import { HomeAssistant } from "../../../types"; + +export const confirmAction = async ( + node: HTMLElement, + hass: HomeAssistant, + config: ConfirmationRestrictionConfig, + action: string +): Promise => { + if ( + config.exemptions && + config.exemptions.some((e) => e.user === hass!.user?.id) + ) { + return true; + } + + return showConfirmationDialog(node, { + text: + config.text || + hass.localize("ui.panel.lovelace.cards.actions.action_confirmation", { + action, + }), + }); +}; diff --git a/src/panels/lovelace/components/hui-card-options.ts b/src/panels/lovelace/components/hui-card-options.ts index c5c5d8cc677d..0a9a39be8e38 100644 --- a/src/panels/lovelace/components/hui-card-options.ts +++ b/src/panels/lovelace/components/hui-card-options.ts @@ -28,7 +28,10 @@ import "../../../components/ha-icon-button"; import "../../../components/ha-list-item"; import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import { saveConfig } from "../../../data/lovelace/config/types"; -import { isStrategyView } from "../../../data/lovelace/config/view"; +import { + isStrategyView, + type LovelaceViewConfig, +} from "../../../data/lovelace/config/view"; import { showAlertDialog, showPromptDialog, @@ -40,12 +43,14 @@ import { computeCardSize } from "../common/compute-card-size"; import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"; import { addCard, + addSection, deleteCard, moveCardToContainer, moveCardToIndex, } from "../editor/config-util"; import { LovelaceCardPath, + type LovelaceContainerPath, findLovelaceItems, getLovelaceContainerPath, parseLovelaceCardPath, @@ -53,6 +58,7 @@ import { import { showSelectViewDialog } from "../editor/select-view/show-select-view-dialog"; import { Lovelace, LovelaceCard } from "../types"; import { SECTIONS_VIEW_LAYOUT } from "../views/const"; +import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; @customElement("hui-card-options") export class HuiCardOptions extends LitElement { @@ -353,34 +359,79 @@ export class HuiCardOptions extends LitElement { allowDashboardChange: true, header: this.hass!.localize("ui.panel.lovelace.editor.move_card.header"), viewSelectedCallback: async (urlPath, selectedDashConfig, viewIndex) => { - const view = selectedDashConfig.views[viewIndex]; + const fromView = selectedDashConfig.views[this.path![0]]; + let toView = selectedDashConfig.views[viewIndex]; + let newConfig = selectedDashConfig; - if (!isStrategyView(view) && view.type === SECTIONS_VIEW_LAYOUT) { + if (isStrategyView(toView)) { showAlertDialog(this, { title: this.hass!.localize( "ui.panel.lovelace.editor.move_card.error_title" ), text: this.hass!.localize( - "ui.panel.lovelace.editor.move_card.error_text_section" + "ui.panel.lovelace.editor.move_card.error_text_strategy" ), warning: true, }); return; } + const isSectionsView = toView.type === SECTIONS_VIEW_LAYOUT; + + let toPath: LovelaceContainerPath = [viewIndex]; + + // If the view is a section view and has no "imported cards" section, adds a default section. + if (isSectionsView) { + const importedCardHeading = fromView.title + ? this.hass!.localize( + "ui.panel.lovelace.editor.section.imported_card_section_title_view", + { view_title: fromView.title } + ) + : this.hass!.localize( + "ui.panel.lovelace.editor.section.imported_card_section_title_default" + ); + + let sectionIndex = toView.sections + ? toView.sections.findIndex( + (s) => + "cards" in s && + s.cards?.some( + (c) => + c.type === "heading" && c.heading === importedCardHeading + ) + ) + : -1; + if (sectionIndex === -1) { + const newSection: LovelaceSectionConfig = { + type: "grid", + cards: [ + { + type: "heading", + heading: importedCardHeading, + }, + ], + }; + newConfig = addSection(selectedDashConfig, viewIndex, newSection); + toView = newConfig.views[viewIndex] as LovelaceViewConfig; + sectionIndex = toView.sections!.length - 1; + } + toPath = [viewIndex, sectionIndex]; + } + if (urlPath === this.lovelace!.urlPath) { this.lovelace!.saveConfig( - moveCardToContainer(this.lovelace!.config, this.path!, [viewIndex]) + moveCardToContainer(newConfig, this.path!, toPath) ); showSaveSuccessToast(this, this.hass!); return; } try { const { cardIndex } = parseLovelaceCardPath(this.path!); + const card = this._cards[cardIndex]; await saveConfig( this.hass!, urlPath, - addCard(selectedDashConfig, [viewIndex], this._cards[cardIndex]) + addCard(newConfig, toPath, card) ); this.lovelace!.saveConfig( deleteCard(this.lovelace!.config, this.path!) diff --git a/src/panels/lovelace/components/hui-input-list-editor.ts b/src/panels/lovelace/components/hui-input-list-editor.ts deleted file mode 100644 index 6616753fc81f..000000000000 --- a/src/panels/lovelace/components/hui-input-list-editor.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { mdiClose } from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; -import { customElement, property } from "lit/decorators"; -import { fireEvent } from "../../../common/dom/fire_event"; -import "../../../components/ha-icon-button"; -import "../../../components/ha-textfield"; -import { HomeAssistant } from "../../../types"; -import { EditorTarget } from "../editor/types"; - -@customElement("hui-input-list-editor") -export class HuiInputListEditor extends LitElement { - @property({ type: Array }) public value?: string[]; - - @property({ attribute: false }) public hass?: HomeAssistant; - - @property() public inputLabel?: string; - - protected render() { - if (!this.value) { - return nothing; - } - - return html` - ${this.value.map( - (listEntry, index) => html` - - - - ` - )} - - `; - } - - private _addEntry(ev: Event): void { - const target = ev.target! as EditorTarget; - if (target.value === "") { - return; - } - const newEntries = this.value!.concat(target.value as string); - target.value = ""; - fireEvent(this, "value-changed", { - value: newEntries, - }); - (ev.target! as LitElement).blur(); - } - - private _valueChanged(ev: Event): void { - ev.stopPropagation(); - const target = ev.target! as EditorTarget; - const newEntries = this.value!.concat(); - newEntries[target.index!] = target.value!; - fireEvent(this, "value-changed", { - value: newEntries, - }); - } - - private _handleKeyDown(ev: KeyboardEvent) { - if (ev.key === "Enter") { - ev.stopPropagation(); - this._consolidateEntries(ev); - } - } - - private _consolidateEntries(ev: Event): void { - const target = ev.target! as EditorTarget; - if (target.value === "") { - const newEntries = this.value!.concat(); - newEntries.splice(target.index!, 1); - fireEvent(this, "value-changed", { - value: newEntries, - }); - } - } - - private _removeEntry(ev: Event): void { - const parent = (ev.currentTarget as any).parentElement; - const newEntries = this.value!.concat(); - newEntries.splice(parent.index!, 1); - fireEvent(this, "value-changed", { - value: newEntries, - }); - } - - static get styles(): CSSResultGroup { - return css` - ha-icon-button { - margin-right: -24px; - margin-inline-end: -24px; - margin-inline-start: initial; - color: var(--secondary-text-color); - } - ha-textfield { - display: block; - } - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "hui-input-list-editor": HuiInputListEditor; - } -} diff --git a/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts index e8e0c70f3c43..3f3280a91b53 100644 --- a/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-entities-card-editor.ts @@ -81,13 +81,7 @@ const callServiceEntitiesRowConfigStruct = object({ const conditionalEntitiesRowConfigStruct = object({ type: literal("conditional"), row: any(), - conditions: array( - object({ - entity: string(), - state: optional(string()), - state_not: optional(string()), - }) - ), + conditions: array(any()), }); const dividerEntitiesRowConfigStruct = object({ diff --git a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts index 7aafbb59006e..41a32456e758 100644 --- a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts @@ -15,15 +15,17 @@ import { import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../common/dom/fire_event"; import { hasLocation } from "../../../../common/entity/has_location"; +import { computeDomain } from "../../../../common/entity/compute_domain"; import "../../../../components/ha-form/ha-form"; import { SchemaUnion } from "../../../../components/ha-form/types"; +import type { SelectSelector } from "../../../../data/selector"; import "../../../../components/ha-formfield"; import "../../../../components/ha-switch"; +import "../../../../components/ha-selector/ha-selector-select"; import { HomeAssistant, ValueChangedEvent } from "../../../../types"; import { DEFAULT_HOURS_TO_SHOW, DEFAULT_ZOOM } from "../../cards/hui-map-card"; import { MapCardConfig } from "../../cards/types"; import "../../components/hui-entity-editor"; -import "../../components/hui-input-list-editor"; import { EntityConfig } from "../../entity-rows/types"; import { LovelaceCardEditor } from "../../types"; import { processEditorEntities } from "../process-editor-entities"; @@ -42,6 +44,14 @@ export const mapEntitiesConfigStruct = union([ string(), ]); +const geoSourcesConfigStruct = union([ + object({ + source: string(), + focus: optional(boolean()), + }), + string(), +]); + const cardConfigStruct = assign( baseLovelaceCardConfig, object({ @@ -51,7 +61,7 @@ const cardConfigStruct = assign( dark_mode: optional(boolean()), entities: array(mapEntitiesConfigStruct), hours_to_show: optional(number()), - geo_location_sources: optional(array(string())), + geo_location_sources: optional(array(geoSourcesConfigStruct)), auto_fit: optional(boolean()), theme_mode: optional(string()), }) @@ -67,6 +77,8 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor { @state() private _configEntities?: EntityConfig[]; + @state() private _possibleGeoSources?: { value: string; label?: string }[]; + private _schema = memoizeOne( (localize: LocalizeFunc) => [ @@ -135,8 +147,12 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor { : []; } + private _geoSourcesStrings = memoizeOne((sources): string[] | undefined => + sources?.map((s) => (typeof s === "string" ? s : s.source)) + ); + get _geo_location_sources(): string[] { - return this._config!.geo_location_sources || []; + return this._geoSourcesStrings(this._config!.geo_location_sources) || []; } protected render() { @@ -166,17 +182,40 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor { )} - + .selector=${this._selectSchema( + this._possibleGeoSources, + this.hass.localize + )} + > `; } + private _selectSchema = memoizeOne( + (options, localize: LocalizeFunc): SelectSelector => ({ + select: { + sort: true, + multiple: true, + custom_value: true, + options: options.length + ? options + : [ + { + value: "", + label: localize( + "ui.panel.lovelace.editor.card.map.no_geo_location_sources" + ), + }, + ], + }, + }) + ); + private _entitiesValueChanged(ev: EntitiesEditorEvent): void { if (ev.detail && ev.detail.entities) { this._config = { ...this._config!, entities: ev.detail.entities }; @@ -201,9 +240,16 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor { this._config = { ...this._config }; delete this._config.geo_location_sources; } else { + const newSources = value.map( + (newSource) => + this._config!.geo_location_sources?.find( + (oldSource) => + typeof oldSource === "object" && oldSource.source === newSource + ) || newSource + ); this._config = { ...this._config, - geo_location_sources: value, + geo_location_sources: newSources, }; } fireEvent(this, "config-changed", { config: this._config }); @@ -213,6 +259,25 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor { fireEvent(this, "config-changed", { config: ev.detail.value }); } + protected willUpdate() { + if (this.hass && !this._possibleGeoSources) { + const sources: Record = {}; + Object.entries(this.hass.states).forEach(([entity_id, stateObj]) => { + const domain = computeDomain(entity_id); + if (domain === "geo_location" && stateObj.attributes.source) { + sources[stateObj.attributes.source] = stateObj.attributes.attribution; + } + }); + + this._possibleGeoSources = Object.entries(sources).map( + ([source, attribution]) => ({ + value: source, + label: attribution || source, + }) + ); + } + } + private _computeLabelCallback = ( schema: SchemaUnion> ) => { diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts index 004bba358bba..7990f21eaab7 100644 --- a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts @@ -48,6 +48,7 @@ const cardConfigStruct = assign( vertical: optional(boolean()), tap_action: optional(actionConfigStruct), icon_tap_action: optional(actionConfigStruct), + hold_action: optional(actionConfigStruct), features: optional(array(any())), }) ); @@ -156,6 +157,14 @@ export class HuiTileCardEditor }, }, }, + { + name: "hold_action", + selector: { + ui_action: { + default_action: "none", + }, + }, + }, ], }, ] as const satisfies readonly HaFormSchema[] diff --git a/src/panels/lovelace/editor/structs/action-struct.ts b/src/panels/lovelace/editor/structs/action-struct.ts index d1f8d90c483a..ed918e6d1f8b 100644 --- a/src/panels/lovelace/editor/structs/action-struct.ts +++ b/src/panels/lovelace/editor/structs/action-struct.ts @@ -16,7 +16,7 @@ const actionConfigStructUser = object({ user: string(), }); -const actionConfigStructConfirmation = union([ +export const actionConfigStructConfirmation = union([ boolean(), object({ text: optional(string()), diff --git a/src/panels/lovelace/editor/structs/entities-struct.ts b/src/panels/lovelace/editor/structs/entities-struct.ts index 837f621b19f8..870e097b85f3 100644 --- a/src/panels/lovelace/editor/structs/entities-struct.ts +++ b/src/panels/lovelace/editor/structs/entities-struct.ts @@ -1,6 +1,9 @@ import { union, object, string, optional, boolean, enums } from "superstruct"; import { TIMESTAMP_RENDERING_FORMATS } from "../../components/types"; -import { actionConfigStruct } from "./action-struct"; +import { + actionConfigStruct, + actionConfigStructConfirmation, +} from "./action-struct"; export const entitiesConfigStruct = union([ object({ @@ -14,6 +17,7 @@ export const entitiesConfigStruct = union([ tap_action: optional(actionConfigStruct), hold_action: optional(actionConfigStruct), double_tap_action: optional(actionConfigStruct), + confirmation: optional(actionConfigStructConfirmation), }), string(), ]); diff --git a/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts b/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts index dadd34449ea2..ef0a34233356 100644 --- a/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts +++ b/src/panels/lovelace/editor/unused-entities/hui-unused-entities.ts @@ -167,7 +167,6 @@ export class HuiUnusedEntities extends LitElement { static get styles(): CSSResultGroup { return css` :host { - background: var(--lovelace-background); overflow: hidden; } .container { diff --git a/src/panels/lovelace/entity-rows/hui-button-entity-row.ts b/src/panels/lovelace/entity-rows/hui-button-entity-row.ts index 1b9d5cf583e7..c649b2664b1a 100644 --- a/src/panels/lovelace/entity-rows/hui-button-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-button-entity-row.ts @@ -14,6 +14,7 @@ import { hasConfigOrEntityChanged } from "../common/has-changed"; import "../components/hui-generic-entity-row"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import { ActionRowConfig, LovelaceRow } from "./types"; +import { confirmAction } from "../common/confirm-action"; @customElement("hui-button-entity-row") class HuiButtonEntityRow extends LitElement implements LovelaceRow { @@ -69,11 +70,21 @@ class HuiButtonEntityRow extends LitElement implements LovelaceRow { `; } - private _pressButton(ev): void { + private async _pressButton(ev): Promise { ev.stopPropagation(); - this.hass.callService("button", "press", { - entity_id: this._config!.entity, - }); + if ( + !this._config?.confirmation || + (await confirmAction( + this, + this.hass, + this._config.confirmation, + this.hass.localize("ui.card.button.press") + )) + ) { + this.hass.callService("button", "press", { + entity_id: this._config!.entity, + }); + } } } diff --git a/src/panels/lovelace/entity-rows/hui-input-button-entity-row.ts b/src/panels/lovelace/entity-rows/hui-input-button-entity-row.ts index 97b2a08dba6e..84697b89e5b5 100644 --- a/src/panels/lovelace/entity-rows/hui-input-button-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-input-button-entity-row.ts @@ -14,6 +14,7 @@ import { hasConfigOrEntityChanged } from "../common/has-changed"; import "../components/hui-generic-entity-row"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import { ActionRowConfig, LovelaceRow } from "./types"; +import { confirmAction } from "../common/confirm-action"; @customElement("hui-input-button-entity-row") class HuiInputButtonEntityRow extends LitElement implements LovelaceRow { @@ -69,11 +70,21 @@ class HuiInputButtonEntityRow extends LitElement implements LovelaceRow { `; } - private _pressButton(ev): void { + private async _pressButton(ev): Promise { ev.stopPropagation(); - this.hass.callService("input_button", "press", { - entity_id: this._config!.entity, - }); + if ( + !this._config?.confirmation || + (await confirmAction( + this, + this.hass, + this._config.confirmation, + this.hass.localize("ui.card.button.press") + )) + ) { + this.hass.callService("input_button", "press", { + entity_id: this._config!.entity, + }); + } } } diff --git a/src/panels/lovelace/entity-rows/hui-lock-entity-row.ts b/src/panels/lovelace/entity-rows/hui-lock-entity-row.ts index 128a80808a7c..454225cd6ee8 100644 --- a/src/panels/lovelace/entity-rows/hui-lock-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-lock-entity-row.ts @@ -13,16 +13,17 @@ import { HomeAssistant } from "../../../types"; import { hasConfigOrEntityChanged } from "../common/has-changed"; import "../components/hui-generic-entity-row"; import { createEntityNotFoundWarning } from "../components/hui-warning"; -import { EntityConfig, LovelaceRow } from "./types"; +import { ConfirmableRowConfig, LovelaceRow } from "./types"; import { callProtectedLockService } from "../../../data/lock"; +import { confirmAction } from "../common/confirm-action"; @customElement("hui-lock-entity-row") class HuiLockEntityRow extends LitElement implements LovelaceRow { @property({ attribute: false }) public hass?: HomeAssistant; - @state() private _config?: EntityConfig; + @state() private _config?: ConfirmableRowConfig; - public setConfig(config: EntityConfig): void { + public setConfig(config: ConfirmableRowConfig): void { if (!config) { throw new Error("Invalid configuration"); } @@ -73,15 +74,21 @@ class HuiLockEntityRow extends LitElement implements LovelaceRow { `; } - private _callService(ev): void { + private async _callService(ev): Promise { ev.stopPropagation(); const stateObj = this.hass!.states[this._config!.entity]; - callProtectedLockService( - this, - this.hass!, - stateObj, - stateObj.state === "locked" ? "unlock" : "lock" - ); + const action = stateObj.state === "locked" ? "unlock" : "lock"; + if ( + !this._config?.confirmation || + (await confirmAction( + this, + this.hass!, + this._config.confirmation, + this.hass!.localize(`ui.card.lock.${action}`) + )) + ) { + callProtectedLockService(this, this.hass!, stateObj, action); + } } } diff --git a/src/panels/lovelace/entity-rows/hui-scene-entity-row.ts b/src/panels/lovelace/entity-rows/hui-scene-entity-row.ts index 5d1646d9b29c..27ddae5b70f5 100644 --- a/src/panels/lovelace/entity-rows/hui-scene-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-scene-entity-row.ts @@ -16,6 +16,7 @@ import { hasConfigOrEntityChanged } from "../common/has-changed"; import "../components/hui-generic-entity-row"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import { ActionRowConfig, LovelaceRow } from "./types"; +import { confirmAction } from "../common/confirm-action"; @customElement("hui-scene-entity-row") class HuiSceneEntityRow extends LitElement implements LovelaceRow { @@ -73,9 +74,19 @@ class HuiSceneEntityRow extends LitElement implements LovelaceRow { `; } - private _callService(ev: Event): void { + private async _callService(ev: Event): Promise { ev.stopPropagation(); - activateScene(this.hass, this._config!.entity); + if ( + !this._config?.confirmation || + (await confirmAction( + this, + this.hass, + this._config.confirmation, + this._config.action_name || this.hass.localize("ui.card.scene.activate") + )) + ) { + activateScene(this.hass, this._config!.entity); + } } } diff --git a/src/panels/lovelace/entity-rows/hui-script-entity-row.ts b/src/panels/lovelace/entity-rows/hui-script-entity-row.ts index 87602c939f22..e884f405074f 100644 --- a/src/panels/lovelace/entity-rows/hui-script-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-script-entity-row.ts @@ -16,6 +16,7 @@ import "../components/hui-generic-entity-row"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import { ActionRowConfig, LovelaceRow } from "./types"; import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog"; +import { confirmAction } from "../common/confirm-action"; @customElement("hui-script-entity-row") class HuiScriptEntityRow extends LitElement implements LovelaceRow { @@ -91,12 +92,20 @@ class HuiScriptEntityRow extends LitElement implements LovelaceRow { this._callService("turn_off"); } - private _runScript(ev): void { + private async _runScript(ev): Promise { ev.stopPropagation(); if (hasScriptFields(this.hass!, this._config!.entity)) { showMoreInfoDialog(this, { entityId: this._config!.entity }); - } else { + } else if ( + !this._config?.confirmation || + (await confirmAction( + this, + this.hass!, + this._config.confirmation, + this._config.action_name || this.hass!.localize("ui.card.script.run") + )) + ) { this._callService("turn_on"); } } diff --git a/src/panels/lovelace/entity-rows/types.ts b/src/panels/lovelace/entity-rows/types.ts index d5c20534183c..b8dd9c8d4c7b 100644 --- a/src/panels/lovelace/entity-rows/types.ts +++ b/src/panels/lovelace/entity-rows/types.ts @@ -1,4 +1,7 @@ -import type { ActionConfig } from "../../../data/lovelace/config/action"; +import type { + ActionConfig, + ConfirmationRestrictionConfig, +} from "../../../data/lovelace/config/action"; import type { HomeAssistant } from "../../../types"; import type { LegacyStateFilter } from "../common/evaluate-filter"; import type { Condition } from "../common/validate-condition"; @@ -11,7 +14,12 @@ export interface EntityConfig { icon?: string; image?: string; } -export interface ActionRowConfig extends EntityConfig { + +export interface ConfirmableRowConfig extends EntityConfig { + confirmation?: ConfirmationRestrictionConfig; +} + +export interface ActionRowConfig extends ConfirmableRowConfig { action_name?: string; } export interface EntityFilterEntityConfig extends EntityConfig { diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index 939d063e8203..3c749fe1a87f 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -62,6 +62,7 @@ import { fetchDashboards, updateDashboard, } from "../../data/lovelace/dashboard"; +import { getPanelTitle } from "../../data/panel"; import { showAlertDialog, showConfirmationDialog, @@ -80,8 +81,8 @@ import { getLovelaceStrategy } from "./strategies/get-strategy"; import { isLegacyStrategyConfig } from "./strategies/legacy-strategy"; import type { Lovelace } from "./types"; import "./views/hui-view"; +import "./views/hui-view-container"; import type { HUIView } from "./views/hui-view"; -import { getPanelTitle } from "../../data/panel"; @customElement("hui-root") class HUIRoot extends LitElement { @@ -291,6 +292,8 @@ class HUIRoot extends LitElement { ? getPanelTitle(this.hass, this.panel) : undefined; + const background = curViewConfig?.background || this.config.background; + return html`
-
+ +
`; } @@ -937,21 +947,6 @@ class HUIRoot extends LitElement { view.hass = this.hass; view.narrow = this.narrow; - const configBackground = viewConfig.background || this.config.background; - - const backgroundStyle = - typeof configBackground === "string" - ? configBackground - : configBackground?.image - ? `center / cover no-repeat url('${configBackground.image}')` - : undefined; - - if (backgroundStyle) { - root.style.setProperty("--lovelace-background", backgroundStyle); - } else { - root.style.removeProperty("--lovelace-background"); - } - root.appendChild(view); } @@ -1063,30 +1058,26 @@ class HUIRoot extends LitElement { mwc-button.warning:not([disabled]) { color: var(--error-color); } - #view { + hui-view-container { position: relative; display: flex; - padding-top: calc(var(--header-height) + env(safe-area-inset-top)); min-height: 100vh; box-sizing: border-box; + padding-top: calc(var(--header-height) + env(safe-area-inset-top)); padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); padding-inline-start: env(safe-area-inset-left); padding-inline-end: env(safe-area-inset-right); padding-bottom: env(safe-area-inset-bottom); - background: var( - --lovelace-background, - var(--primary-background-color) - ); } - #view > * { + hui-view-container > * { flex: 1 1 100%; max-width: 100%; } /** * In edit mode we have the tab bar on a new line * */ - .edit-mode #view { + .edit-mode hui-view-container { padding-top: calc( var(--header-height) + 48px + env(safe-area-inset-top) ); diff --git a/src/panels/lovelace/views/hui-view-container.ts b/src/panels/lovelace/views/hui-view-container.ts new file mode 100644 index 000000000000..73da5a04c1f4 --- /dev/null +++ b/src/panels/lovelace/views/hui-view-container.ts @@ -0,0 +1,142 @@ +import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; +import { listenMediaQuery } from "../../../common/dom/media_query"; +import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; +import type { HomeAssistant } from "../../../types"; + +type BackgroundConfig = LovelaceViewConfig["background"]; + +@customElement("hui-view-container") +class HuiViewContainer extends LitElement { + @property({ attribute: false }) hass?: HomeAssistant; + + @property({ attribute: false }) background?: BackgroundConfig; + + @property({ attribute: false }) theme?: LovelaceViewConfig["theme"]; + + @state() themeBackground?: string; + + private _unsubMediaQuery?: () => void; + + public connectedCallback(): void { + super.connectedCallback(); + this._setUpMediaQuery(); + this._applyTheme(); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + this._clearmediaQuery(); + } + + private _clearmediaQuery() { + if (this._unsubMediaQuery) { + this._unsubMediaQuery(); + this._unsubMediaQuery = undefined; + } + } + + private _setUpMediaQuery() { + this._unsubMediaQuery = listenMediaQuery( + "(prefers-color-scheme: dark)", + this._applyTheme.bind(this) + ); + } + + private _isFixedBackground(background?: BackgroundConfig) { + if (typeof background === "string") { + return background.includes(" fixed"); + } + return false; + } + + private _computeBackgroundProperty(background?: BackgroundConfig) { + if (typeof background === "object" && background.image) { + return `center / cover no-repeat url('${background.image}')`; + } + if (typeof background === "string") { + return background; + } + return null; + } + + protected willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + if (changedProperties.has("hass") && this.hass) { + const oldHass = changedProperties.get("hass"); + if ( + !oldHass || + this.hass.themes !== oldHass.themes || + this.hass.selectedTheme !== oldHass.selectedTheme + ) { + this._applyTheme(); + return; + } + } + + if (changedProperties.has("theme") || changedProperties.has("background")) { + this._applyTheme(); + } + } + + render() { + return html``; + } + + private _applyTheme() { + if (this.hass) { + applyThemesOnElement(this, this.hass?.themes, this.theme); + } + + const computedStyles = getComputedStyle(this); + const themeBackground = computedStyles.getPropertyValue( + "--lovelace-background" + ); + + const fixedBackground = this._isFixedBackground( + this.background || themeBackground + ); + const viewBackground = this._computeBackgroundProperty(this.background); + this.toggleAttribute("fixed-background", fixedBackground); + this.style.setProperty("--view-background", viewBackground); + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: relative; + } + /* Fixed background hack for Safari iOS */ + :host([fixed-background]) ::slotted(*):before { + display: block; + content: ""; + z-index: -1; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: 100%; + width: 100%; + background: var( + --view-background, + var(--lovelace-background, var(--primary-background-color)) + ); + background-attachment: scroll !important; + } + :host(:not(fixed-background)) { + background: var( + --view-background, + var(--lovelace-background, var(--primary-background-color)) + ); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-view-container": HuiViewContainer; + } +} diff --git a/src/panels/lovelace/views/hui-view.ts b/src/panels/lovelace/views/hui-view.ts index 397be95d51bd..12aa24eb8e60 100644 --- a/src/panels/lovelace/views/hui-view.ts +++ b/src/panels/lovelace/views/hui-view.ts @@ -1,6 +1,5 @@ import { PropertyValues, ReactiveElement } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { HASSDomEvent } from "../../../common/dom/fire_event"; import "../../../components/entity/ha-state-label-badge"; import "../../../components/ha-svg-icon"; @@ -84,8 +83,6 @@ export class HUIView extends ReactiveElement { private _layoutElement?: LovelaceViewElement; - private _viewConfigTheme?: string; - private _createCardElement(cardConfig: LovelaceCardConfig) { const element = document.createElement("hui-card"); element.hass = this.hass; @@ -138,11 +135,6 @@ export class HUIView extends ReactiveElement { return this; } - public connectedCallback(): void { - super.connectedCallback(); - this._applyTheme(); - } - public willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); @@ -194,18 +186,6 @@ export class HUIView extends ReactiveElement { }); this._layoutElement.hass = this.hass; - - const oldHass = changedProperties.get("hass") as - | this["hass"] - | undefined; - - if ( - !oldHass || - this.hass.themes !== oldHass.themes || - this.hass.selectedTheme !== oldHass.selectedTheme - ) { - this._applyTheme(); - } } if (changedProperties.has("narrow")) { this._layoutElement.narrow = this.narrow; @@ -237,28 +217,6 @@ export class HUIView extends ReactiveElement { } } - private _applyTheme() { - applyThemesOnElement(this, this.hass.themes, this._viewConfigTheme); - if (this._viewConfigTheme) { - // Set lovelace background color to root element, so it will be placed under the header too - const computedStyles = getComputedStyle(this); - let lovelaceBackground = computedStyles.getPropertyValue( - "--lovelace-background" - ); - if (!lovelaceBackground) { - lovelaceBackground = computedStyles.getPropertyValue( - "--primary-background-color" - ); - } - if (lovelaceBackground) { - this.parentElement?.style.setProperty( - "--lovelace-background", - lovelaceBackground - ); - } - } - } - private async _initializeConfig() { let viewConfig = this.lovelace.config.views[this.index]; let isStrategy = false; @@ -296,9 +254,6 @@ export class HUIView extends ReactiveElement { this._layoutElement!.badges = this._badges; this._layoutElement!.sections = this._sections; - applyThemesOnElement(this, this.hass.themes, viewConfig.theme); - this._viewConfigTheme = viewConfig.theme; - if (addLayoutElement) { while (this.lastChild) { this.removeChild(this.lastChild); diff --git a/src/translations/en.json b/src/translations/en.json index 11c77f0f77a9..223d64e39867 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1191,6 +1191,7 @@ "skip": "Skip", "clear_skipped": "Clear skipped", "install": "Install", + "update": "Update", "create_backup": "Create backup before updating", "auto_update_enabled_title": "Can not skip version", "auto_update_enabled_text": "Automatic updates for this item have been enabled; skipping it is, therefore, unavailable. You can either install this update now or wait for Home Assistant to do it automatically." @@ -1929,7 +1930,10 @@ "multiselect": { "failed": "Failed to update {number} items." }, - "learn_more": "Learn more" + "learn_more": "Learn more", + "show_url": "Show full URL", + "hide_url": "Hide URL", + "copy_link": "Copy link" }, "updates": { "caption": "Updates", @@ -2397,7 +2401,9 @@ "enable_remote": "[%key:ui::common::enable%]", "internal_url_automatic": "Automatic", "internal_url_https_error_title": "Invalid local network URL", - "internal_url_https_error_description": "You have configured an HTTPS certificate in Home Assistant. This means that your internal URL needs to be set to a domain covered by the certficate." + "internal_url_https_error_description": "You have configured an HTTPS certificate in Home Assistant. This means that your internal URL needs to be set to a domain covered by the certficate.", + "internal_url_automatic_description": "Use the configured network settings", + "internal_url_placeholder": "http://:8123" }, "hardware": { "caption": "Hardware", @@ -2490,6 +2496,9 @@ "scroll_down_button": "New logs - Click to scroll", "provider_not_found": "Log provider not found", "provider_not_available": "Logs for ''{provider}'' are not available on your system.", + "current": "Current", + "previous": "Previous", + "startups_ago": "{boot} startups ago", "detail": { "logger": "Logger", "source": "Source", @@ -2861,6 +2870,7 @@ "copy_to_clipboard": "Copy to clipboard", "search_in": "Search · {group}", "unknown_entity": "unknown entity", + "edit_unknown_device": "Editor not available for unknown device", "triggers": { "name": "Triggers", "header": "When", @@ -3910,9 +3920,6 @@ "info": "Home Assistant Cloud provides a secure remote access to your instance while away from home. For more information on remote access and these settings visit our security documentation.", "info_instance_will_be_available": "Your instance will be available at your Nabu Casa URL.", "link_learn_how_it_works": "Learn how it works", - "show_url": "Show full URL", - "hide_url": "Hide URL", - "copy_link": "Copy link", "security_options": "Security options", "external_activation": "Allow external activation of remote access", "external_activation_secondary": "If you disable remote access on this page, having this setting enabled allows you to reactivate it remotely via your Nabu Casa account.", @@ -5696,8 +5703,8 @@ }, "move_card": { "header": "Choose a view to move the card to", - "error_title": "Impossible to move the card", - "error_text_section": "Moving a card to a section view is not supported yet. Use copy/cut/paste instead." + "strategy_error_title": "Impossible to move the card", + "strategy_error_text_strategy": "Moving a card to an auto generated view is not supported." }, "change_position": { "title": "Change card position", @@ -5715,7 +5722,9 @@ "add_badge": "Add badge", "add_card": "[%key:ui::panel::lovelace::editor::edit_card::add%]", "create_section": "Create section", - "default_section_title": "New section" + "default_section_title": "New section", + "imported_card_section_title_view": "Imported cards from ''{view_title}'' view", + "imported_card_section_title_default": "Imported cards from another view" }, "delete_section": { "title": "Delete section", @@ -6081,6 +6090,7 @@ "map": { "name": "Map", "geo_location_sources": "Geolocation sources", + "no_geo_location_sources": "No geolocation sources available", "dark_mode": "Dark mode?", "appearance": "Appearance", "theme_mode": "Theme Mode", @@ -7664,6 +7674,7 @@ }, "backup": { "search": "[%key:ui::panel::config::backup::picker::search%]", + "loading_backups": "Loading backups. This can take a few seconds.", "no_backups": "You don't have any backups yet.", "create_blocked_not_running": "Creating a backup is not possible right now because the system is in \"{state}\" state.", "restore_blocked_not_running": "Restoring a backup is not possible right now because the system is in \"{state}\" state.", diff --git a/src/util/url.ts b/src/util/url.ts new file mode 100644 index 000000000000..ed3da5bb5926 --- /dev/null +++ b/src/util/url.ts @@ -0,0 +1,9 @@ +export function obfuscateUrl(url: string) { + if (url.endsWith(".ui.nabu.casa")) { + return "https://•••••••••••••••••.ui.nabu.casa"; + } + // hide any words that look like they might be a hostname or IP address + return url.replace(/(?<=:\/\/)[\w-]+|(?<=\.)[\w-]+/g, (match) => + "•".repeat(match.length) + ); +} diff --git a/yarn.lock b/yarn.lock index 8f8c64437907..5a866a60dd77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28,7 +28,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.11, @babel/code-frame@npm:^7.25.9": +"@babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.11, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.0": version: 7.26.0 resolution: "@babel/code-frame@npm:7.26.0" dependencies: @@ -39,37 +39,37 @@ __metadata: languageName: node linkType: hard -"@babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.25.9": +"@babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.25.9, @babel/compat-data@npm:^7.26.0": version: 7.26.0 resolution: "@babel/compat-data@npm:7.26.0" checksum: 10/e847d58222eb567da4bcc2c8e4e44b508d1a34626922858fe12edeb73b5f3c486e7e77a351725b4347525d623dc5046b8a6355df76f368560ca6cbac10fef2c5 languageName: node linkType: hard -"@babel/core@npm:7.25.9, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.0, @babel/core@npm:^7.24.4": - version: 7.25.9 - resolution: "@babel/core@npm:7.25.9" +"@babel/core@npm:7.26.0, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.0, @babel/core@npm:^7.24.4": + version: 7.26.0 + resolution: "@babel/core@npm:7.26.0" dependencies: "@ampproject/remapping": "npm:^2.2.0" - "@babel/code-frame": "npm:^7.25.9" - "@babel/generator": "npm:^7.25.9" + "@babel/code-frame": "npm:^7.26.0" + "@babel/generator": "npm:^7.26.0" "@babel/helper-compilation-targets": "npm:^7.25.9" - "@babel/helper-module-transforms": "npm:^7.25.9" - "@babel/helpers": "npm:^7.25.9" - "@babel/parser": "npm:^7.25.9" + "@babel/helper-module-transforms": "npm:^7.26.0" + "@babel/helpers": "npm:^7.26.0" + "@babel/parser": "npm:^7.26.0" "@babel/template": "npm:^7.25.9" "@babel/traverse": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" + "@babel/types": "npm:^7.26.0" convert-source-map: "npm:^2.0.0" debug: "npm:^4.1.0" gensync: "npm:^1.0.0-beta.2" json5: "npm:^2.2.3" semver: "npm:^6.3.1" - checksum: 10/92cc69d9d59a5eb057527e69c41db46f05d0a8eeeb5ebab3f34e5ad040b74f34f20a4d97c3f3ede6476537cac93d2b46e3915b572269d2a039301dab068fd2e8 + checksum: 10/65767bfdb1f02e80d3af4f138066670ef8fdd12293de85ef151758a901c191c797e86d2e99b11c4cdfca33c72385ecaf38bbd7fa692791ec44c77763496b9b93 languageName: node linkType: hard -"@babel/generator@npm:^7.25.9": +"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.26.0": version: 7.26.0 resolution: "@babel/generator@npm:7.26.0" dependencies: @@ -179,7 +179,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.25.9": +"@babel/helper-module-transforms@npm:^7.25.9, @babel/helper-module-transforms@npm:^7.26.0": version: 7.26.0 resolution: "@babel/helper-module-transforms@npm:7.26.0" dependencies: @@ -286,7 +286,7 @@ __metadata: languageName: node linkType: hard -"@babel/helpers@npm:^7.25.9": +"@babel/helpers@npm:^7.26.0": version: 7.26.0 resolution: "@babel/helpers@npm:7.26.0" dependencies: @@ -410,7 +410,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-import-assertions@npm:^7.12.1, @babel/plugin-syntax-import-assertions@npm:^7.25.9": +"@babel/plugin-syntax-import-assertions@npm:^7.12.1, @babel/plugin-syntax-import-assertions@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-syntax-import-assertions@npm:7.26.0" dependencies: @@ -421,7 +421,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-import-attributes@npm:^7.25.9": +"@babel/plugin-syntax-import-attributes@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-syntax-import-attributes@npm:7.26.0" dependencies: @@ -548,7 +548,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-class-static-block@npm:^7.25.9": +"@babel/plugin-transform-class-static-block@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-transform-class-static-block@npm:7.26.0" dependencies: @@ -940,6 +940,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-regexp-modifiers@npm:^7.26.0": + version: 7.26.0 + resolution: "@babel/plugin-transform-regexp-modifiers@npm:7.26.0" + dependencies: + "@babel/helper-create-regexp-features-plugin": "npm:^7.25.9" + "@babel/helper-plugin-utils": "npm:^7.25.9" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/726deca486bbd4b176f8a966eb0f4aabc19d9def3b8dabb8b3a656778eca0df1fda3f3c92b213aa5a184232fdafd5b7bd73b4e24ca4345c498ef6baff2bda4e1 + languageName: node + linkType: hard + "@babel/plugin-transform-reserved-words@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-reserved-words@npm:7.25.9" @@ -1085,11 +1097,11 @@ __metadata: languageName: node linkType: hard -"@babel/preset-env@npm:7.25.9, @babel/preset-env@npm:^7.11.0, @babel/preset-env@npm:^7.13.0": - version: 7.25.9 - resolution: "@babel/preset-env@npm:7.25.9" +"@babel/preset-env@npm:7.26.0, @babel/preset-env@npm:^7.11.0, @babel/preset-env@npm:^7.13.0": + version: 7.26.0 + resolution: "@babel/preset-env@npm:7.26.0" dependencies: - "@babel/compat-data": "npm:^7.25.9" + "@babel/compat-data": "npm:^7.26.0" "@babel/helper-compilation-targets": "npm:^7.25.9" "@babel/helper-plugin-utils": "npm:^7.25.9" "@babel/helper-validator-option": "npm:^7.25.9" @@ -1099,8 +1111,8 @@ __metadata: "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "npm:^7.25.9" "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "npm:^7.25.9" "@babel/plugin-proposal-private-property-in-object": "npm:7.21.0-placeholder-for-preset-env.2" - "@babel/plugin-syntax-import-assertions": "npm:^7.25.9" - "@babel/plugin-syntax-import-attributes": "npm:^7.25.9" + "@babel/plugin-syntax-import-assertions": "npm:^7.26.0" + "@babel/plugin-syntax-import-attributes": "npm:^7.26.0" "@babel/plugin-syntax-unicode-sets-regex": "npm:^7.18.6" "@babel/plugin-transform-arrow-functions": "npm:^7.25.9" "@babel/plugin-transform-async-generator-functions": "npm:^7.25.9" @@ -1108,7 +1120,7 @@ __metadata: "@babel/plugin-transform-block-scoped-functions": "npm:^7.25.9" "@babel/plugin-transform-block-scoping": "npm:^7.25.9" "@babel/plugin-transform-class-properties": "npm:^7.25.9" - "@babel/plugin-transform-class-static-block": "npm:^7.25.9" + "@babel/plugin-transform-class-static-block": "npm:^7.26.0" "@babel/plugin-transform-classes": "npm:^7.25.9" "@babel/plugin-transform-computed-properties": "npm:^7.25.9" "@babel/plugin-transform-destructuring": "npm:^7.25.9" @@ -1141,6 +1153,7 @@ __metadata: "@babel/plugin-transform-private-property-in-object": "npm:^7.25.9" "@babel/plugin-transform-property-literals": "npm:^7.25.9" "@babel/plugin-transform-regenerator": "npm:^7.25.9" + "@babel/plugin-transform-regexp-modifiers": "npm:^7.26.0" "@babel/plugin-transform-reserved-words": "npm:^7.25.9" "@babel/plugin-transform-shorthand-properties": "npm:^7.25.9" "@babel/plugin-transform-spread": "npm:^7.25.9" @@ -1159,7 +1172,7 @@ __metadata: semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/7a9ca1a19949426498fdffc37eed919f9581226ea45b18be764d29ce81bbf8c8f2f37152300c3524b7a3861831d33f10d481810f3011dff702f780d3f79aa789 + checksum: 10/a7a80314f845deea713985a6316361c476621c76cfe5c6c28e8b9558f01634b49bbfdd3581ef94b5d6cff5c2b8830468aa53a73f5b5c1224db2dfea5db7e676f languageName: node linkType: hard @@ -1176,9 +1189,9 @@ __metadata: languageName: node linkType: hard -"@babel/preset-typescript@npm:7.25.9": - version: 7.25.9 - resolution: "@babel/preset-typescript@npm:7.25.9" +"@babel/preset-typescript@npm:7.26.0": + version: 7.26.0 + resolution: "@babel/preset-typescript@npm:7.26.0" dependencies: "@babel/helper-plugin-utils": "npm:^7.25.9" "@babel/helper-validator-option": "npm:^7.25.9" @@ -1187,16 +1200,16 @@ __metadata: "@babel/plugin-transform-typescript": "npm:^7.25.9" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/bcb730ffc777e941eb34ade5052815b7091a0f1c49c6ae9515ebc3ffbfb52b2195dff3d289498b767e9ee898fc30ed100496f4f336a8be51725f2b4a73d7227a + checksum: 10/81a60826160163a3daae017709f42147744757b725b50c9024ef3ee5a402ee45fd2e93eaecdaaa22c81be91f7940916249cfb7711366431cfcacc69c95878c03 languageName: node linkType: hard -"@babel/runtime@npm:7.25.9, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.4": - version: 7.25.9 - resolution: "@babel/runtime@npm:7.25.9" +"@babel/runtime@npm:7.26.0, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.4": + version: 7.26.0 + resolution: "@babel/runtime@npm:7.26.0" dependencies: regenerator-runtime: "npm:^0.14.0" - checksum: 10/8d904cfcb433374b3bb90369452751c94ae69547cdd3679950de4527ac5d04195b9c4a1840482a6f3a84694cb22a6403a7f98b826d60cd945918223a4a6b479c + checksum: 10/9f4ea1c1d566c497c052d505587554e782e021e6ccd302c2ad7ae8291c8e16e3f19d4a7726fb64469e057779ea2081c28b7dbefec6d813a22f08a35712c0f699 languageName: node linkType: hard @@ -1400,150 +1413,150 @@ __metadata: languageName: node linkType: hard -"@formatjs/ecma402-abstract@npm:2.2.0": - version: 2.2.0 - resolution: "@formatjs/ecma402-abstract@npm:2.2.0" +"@formatjs/ecma402-abstract@npm:2.2.1": + version: 2.2.1 + resolution: "@formatjs/ecma402-abstract@npm:2.2.1" dependencies: - "@formatjs/fast-memoize": "npm:2.2.1" - "@formatjs/intl-localematcher": "npm:0.5.5" - tslib: "npm:^2.7.0" - checksum: 10/f836dfa8787c8d0d2022f7c77fd15025a402ee519f9cb6e68dfce2b937956059e4f96b6059c40506c7260d93bdfd0517001b568cdbea8db08b439ce5d21f437e + "@formatjs/fast-memoize": "npm:2.2.2" + "@formatjs/intl-localematcher": "npm:0.5.6" + tslib: "npm:2" + checksum: 10/8c281e14cb5f12b8697225be6b0ac13d057911e257d3c23928aad985b535df90b7bb2a235aab22753a6e57aef98f00b826514fc3703e69018ccc98c8d9848f38 languageName: node linkType: hard -"@formatjs/fast-memoize@npm:2.2.1": - version: 2.2.1 - resolution: "@formatjs/fast-memoize@npm:2.2.1" +"@formatjs/fast-memoize@npm:2.2.2": + version: 2.2.2 + resolution: "@formatjs/fast-memoize@npm:2.2.2" dependencies: - tslib: "npm:^2.7.0" - checksum: 10/7bb12904d4c93cfae17006388b26d97cc0e32fef5af510f80974437382cd13a88b439b4407d96b6646af1806977cf34113f3dc8f1c96b28f73856700ee7857fd + tslib: "npm:2" + checksum: 10/c6e958753eb41bb0875734762a44126a0d570706a31b32bb409e759cd372184c28e294b02fce0b0f0999c171ef717d513eaf7936862c498d78428b97db446ff8 languageName: node linkType: hard -"@formatjs/icu-messageformat-parser@npm:2.8.0": - version: 2.8.0 - resolution: "@formatjs/icu-messageformat-parser@npm:2.8.0" +"@formatjs/icu-messageformat-parser@npm:2.9.1": + version: 2.9.1 + resolution: "@formatjs/icu-messageformat-parser@npm:2.9.1" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - "@formatjs/icu-skeleton-parser": "npm:1.8.4" - tslib: "npm:^2.7.0" - checksum: 10/26464d14b19da2cb2b4c946ac619cac68936e0613324b3c5d317aaa5192089c3c56f53673c0a8549bb66d4939d60c617a010e7998e73f93755d2a46a82d8b255 + "@formatjs/ecma402-abstract": "npm:2.2.1" + "@formatjs/icu-skeleton-parser": "npm:1.8.5" + tslib: "npm:2" + checksum: 10/f52c7c55b1dfc141910089a0494abd98d1c13c0a359cfb3bfa0668a5e2015c0c579bf161978fdb3ab40fa9a7374a37ac062f8710ed285429bf60abde4a5d1183 languageName: node linkType: hard -"@formatjs/icu-skeleton-parser@npm:1.8.4": - version: 1.8.4 - resolution: "@formatjs/icu-skeleton-parser@npm:1.8.4" +"@formatjs/icu-skeleton-parser@npm:1.8.5": + version: 1.8.5 + resolution: "@formatjs/icu-skeleton-parser@npm:1.8.5" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - tslib: "npm:^2.7.0" - checksum: 10/15610bdbf7b8405191a41f044ccd2ba152364690b20a23bec32b98764e3de44029bb03cb897ef0bce0c2f1643ba2333dc79e99724f2619a69154c13617eb27af + "@formatjs/ecma402-abstract": "npm:2.2.1" + tslib: "npm:2" + checksum: 10/5b9c57f80b751483bef8897ff9607a9eb215fd7a8d8ae9fa5c631edf6d16fa4532c853395f20b7f3f38d6d4d1a35b98cd06421291203c7ad333f52077ef2a406 languageName: node linkType: hard -"@formatjs/intl-datetimeformat@npm:6.15.0": - version: 6.15.0 - resolution: "@formatjs/intl-datetimeformat@npm:6.15.0" +"@formatjs/intl-datetimeformat@npm:6.16.1": + version: 6.16.1 + resolution: "@formatjs/intl-datetimeformat@npm:6.16.1" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - "@formatjs/intl-localematcher": "npm:0.5.5" - tslib: "npm:^2.7.0" - checksum: 10/847c2c8acc6a9782e30fbb771639513ef3f73d1d96dbbff5042509925ca4e26c106c0fd87bf488bc93ceaaddad463c089f9d03d9a700075928c2fc937cec44b1 + "@formatjs/ecma402-abstract": "npm:2.2.1" + "@formatjs/intl-localematcher": "npm:0.5.6" + tslib: "npm:2" + checksum: 10/494868322d396e0eede6a27c16047858944f42fd3b45cf5d155f963df62e694b842ac0bef07e23aa73fa55cf143956d642d05ea62a3e762632101451975b5fc4 languageName: node linkType: hard -"@formatjs/intl-displaynames@npm:6.7.0": - version: 6.7.0 - resolution: "@formatjs/intl-displaynames@npm:6.7.0" +"@formatjs/intl-displaynames@npm:6.8.1": + version: 6.8.1 + resolution: "@formatjs/intl-displaynames@npm:6.8.1" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - "@formatjs/intl-localematcher": "npm:0.5.5" - tslib: "npm:^2.7.0" - checksum: 10/975c39d987920883cc520d3d950afeb52850fc36e5ebabbf006a579b1e5a99e1b5ef6d7126181f0d5449bf12ef8c63e1e8952c456475c77d41f0d021fdc8c6cb + "@formatjs/ecma402-abstract": "npm:2.2.1" + "@formatjs/intl-localematcher": "npm:0.5.6" + tslib: "npm:2" + checksum: 10/627fc625e14b4d1bea5b2bf41e40050eb9775d0f66780e155719e21c062f9b3331d08b488ebcd3608c60999498af5a39e67cb5fd2a6d54a0e7395d7a63bfe643 languageName: node linkType: hard -"@formatjs/intl-enumerator@npm:1.7.0": - version: 1.7.0 - resolution: "@formatjs/intl-enumerator@npm:1.7.0" +"@formatjs/intl-enumerator@npm:1.8.1": + version: 1.8.1 + resolution: "@formatjs/intl-enumerator@npm:1.8.1" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - tslib: "npm:^2.7.0" - checksum: 10/de37c701e37cbb1af250df406b0282c4df1bf62d440b92d6d5c11dce88edd780b7ba7885fb9561de317a40bb0ab5169c63178effbeccb78124a7758d2c01a833 + "@formatjs/ecma402-abstract": "npm:2.2.1" + tslib: "npm:2" + checksum: 10/0e4250de905e757fb88d6ff072968c72ed3a39de8ddaed73c38c0099825f11530c9b8e224573ae6e46cf49f1318e463f40ba2cdfa25cb7415382ba952b570bdc languageName: node linkType: hard -"@formatjs/intl-getcanonicallocales@npm:2.4.0": - version: 2.4.0 - resolution: "@formatjs/intl-getcanonicallocales@npm:2.4.0" +"@formatjs/intl-getcanonicallocales@npm:2.5.1": + version: 2.5.1 + resolution: "@formatjs/intl-getcanonicallocales@npm:2.5.1" dependencies: - tslib: "npm:^2.7.0" - checksum: 10/ec1dc0498c05189441bf1cfe5c89f9d5e9bad4e1c41ec735c8d3248852b885d39a24436cccfdcee8a2246158365f20e975af7854c83f947c38b40496d29e1428 + tslib: "npm:2" + checksum: 10/5e83c0b3574333e5027c3c4f74ea20800e50e36fb8efa69361457b57f618738f478b5d22777ba30a2b7a15bdff60101d8119169c909b33577244747d52e59614 languageName: node linkType: hard -"@formatjs/intl-listformat@npm:7.6.0": - version: 7.6.0 - resolution: "@formatjs/intl-listformat@npm:7.6.0" +"@formatjs/intl-listformat@npm:7.7.1": + version: 7.7.1 + resolution: "@formatjs/intl-listformat@npm:7.7.1" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - "@formatjs/intl-localematcher": "npm:0.5.5" - tslib: "npm:^2.7.0" - checksum: 10/3024b00f5965385c816a890eccc5891fc7b9d843dbc6a17dd560c9db8c5e45fc1656258375f9e06e61266869e6303a685fc9e3d7f9e8ba078244db8198f48259 + "@formatjs/ecma402-abstract": "npm:2.2.1" + "@formatjs/intl-localematcher": "npm:0.5.6" + tslib: "npm:2" + checksum: 10/a64581f1d2e8e0c0c83c5d56334a3e3786ed251e1a882d7610d2588d8602eacb32c9167032891e2796c30df3437c9ce52c7284786dca6f1f44250301060169ea languageName: node linkType: hard -"@formatjs/intl-locale@npm:4.1.0": - version: 4.1.0 - resolution: "@formatjs/intl-locale@npm:4.1.0" +"@formatjs/intl-locale@npm:4.2.1": + version: 4.2.1 + resolution: "@formatjs/intl-locale@npm:4.2.1" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - "@formatjs/intl-enumerator": "npm:1.7.0" - "@formatjs/intl-getcanonicallocales": "npm:2.4.0" - tslib: "npm:^2.7.0" - checksum: 10/dd598576957ee92399572a3a327a557fd6089763e4d372abdddd23cbce0b49f963d4a4d7e017beaa4488827a1671f15567da057c8d0bf967bc1eefc8d3c7977c + "@formatjs/ecma402-abstract": "npm:2.2.1" + "@formatjs/intl-enumerator": "npm:1.8.1" + "@formatjs/intl-getcanonicallocales": "npm:2.5.1" + tslib: "npm:2" + checksum: 10/4cba0fbeded2c7c5806528806f176cb833c43765bf1717470f4e001ab42581d5f0b52bf1893afef9597fba96dc3d4659507e490030f231523d460ec6686b9562 languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.5.5": - version: 0.5.5 - resolution: "@formatjs/intl-localematcher@npm:0.5.5" +"@formatjs/intl-localematcher@npm:0.5.6": + version: 0.5.6 + resolution: "@formatjs/intl-localematcher@npm:0.5.6" dependencies: - tslib: "npm:^2.7.0" - checksum: 10/179069eb3a23510e17f118efa3b6a9873f18683977cb813e57ef08cba09bbac73fce13f42c557deb62d3da4f32a8c7ad7d522aae4f1b41625835ce564e59e750 + tslib: "npm:2" + checksum: 10/14eac6bb25dcfeedd7960f44dec5a137999729da00b294ddf1133abe760ced4342f37734bc750b4c47f8dd8d5633a7da38d274503f80d7e965bb1f6fb6f2988c languageName: node linkType: hard -"@formatjs/intl-numberformat@npm:8.13.0": - version: 8.13.0 - resolution: "@formatjs/intl-numberformat@npm:8.13.0" +"@formatjs/intl-numberformat@npm:8.14.1": + version: 8.14.1 + resolution: "@formatjs/intl-numberformat@npm:8.14.1" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - "@formatjs/intl-localematcher": "npm:0.5.5" - tslib: "npm:^2.7.0" - checksum: 10/c1c5ebcf559137978332045e56d228b4851a7ab96063a07a9ceef51e4f41c034f549d9c0ab21fa7d11f5b19f97a9ca5114d4367636bf22f7a23fd7e22c5e2412 + "@formatjs/ecma402-abstract": "npm:2.2.1" + "@formatjs/intl-localematcher": "npm:0.5.6" + tslib: "npm:2" + checksum: 10/51152d1b9607a35c64e6089e44b90c7ec90be3b1925ba47ffc559ddb4fd72afae76e83af3d436831ea0fc47dc0e9fee9cd3d576280440f2dce03cb6bd24e0bed languageName: node linkType: hard -"@formatjs/intl-pluralrules@npm:5.2.17": - version: 5.2.17 - resolution: "@formatjs/intl-pluralrules@npm:5.2.17" +"@formatjs/intl-pluralrules@npm:5.3.1": + version: 5.3.1 + resolution: "@formatjs/intl-pluralrules@npm:5.3.1" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - "@formatjs/intl-localematcher": "npm:0.5.5" - tslib: "npm:^2.7.0" - checksum: 10/fabbff5b30a31e68579fa6005578ebf17cc398b4914459c863f1ed6fa29f81c112365a182b8aa2329fdbbbec5a3c3f5a931dd67f1fc1e8a5b46c55442e59dfb3 + "@formatjs/ecma402-abstract": "npm:2.2.1" + "@formatjs/intl-localematcher": "npm:0.5.6" + tslib: "npm:2" + checksum: 10/fc83c3547a9f0af6331c2970f265234fde967848ff738730f2e87ce816636d8778ead1185f5ecccc692cb8b63c11412dc85deac9d3425f44fe3a6a6c30c8b776 languageName: node linkType: hard -"@formatjs/intl-relativetimeformat@npm:11.3.0": - version: 11.3.0 - resolution: "@formatjs/intl-relativetimeformat@npm:11.3.0" +"@formatjs/intl-relativetimeformat@npm:11.4.1": + version: 11.4.1 + resolution: "@formatjs/intl-relativetimeformat@npm:11.4.1" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - "@formatjs/intl-localematcher": "npm:0.5.5" - tslib: "npm:^2.7.0" - checksum: 10/675f8a15eab8e073f1bb61e1b13e206472aa92b82979592ae2f31a434631cab049e67e2d957223b1b0a3ea45c0e80143f1bb5b3f9c762ef77fac2082567390f9 + "@formatjs/ecma402-abstract": "npm:2.2.1" + "@formatjs/intl-localematcher": "npm:0.5.6" + tslib: "npm:2" + checksum: 10/80817403301baed257fbd8c793b9ed077a2e6dd0414a6895b5bfde3619aebc818f30535da9b560a6186fac783cf09561c495d2c6568a980bd635736194655af5 languageName: node linkType: hard @@ -8694,13 +8707,13 @@ __metadata: version: 0.0.0-use.local resolution: "home-assistant-frontend@workspace:." dependencies: - "@babel/core": "npm:7.25.9" + "@babel/core": "npm:7.26.0" "@babel/helper-define-polyfill-provider": "npm:0.6.2" "@babel/plugin-proposal-decorators": "npm:7.25.9" "@babel/plugin-transform-runtime": "npm:7.25.9" - "@babel/preset-env": "npm:7.25.9" - "@babel/preset-typescript": "npm:7.25.9" - "@babel/runtime": "npm:7.25.9" + "@babel/preset-env": "npm:7.26.0" + "@babel/preset-typescript": "npm:7.26.0" + "@babel/runtime": "npm:7.26.0" "@braintree/sanitize-url": "npm:7.1.0" "@bundle-stats/plugin-webpack-filter": "npm:4.16.0" "@codemirror/autocomplete": "npm:6.18.1" @@ -8711,14 +8724,14 @@ __metadata: "@codemirror/state": "npm:6.4.1" "@codemirror/view": "npm:6.34.1" "@egjs/hammerjs": "npm:2.0.17" - "@formatjs/intl-datetimeformat": "npm:6.15.0" - "@formatjs/intl-displaynames": "npm:6.7.0" - "@formatjs/intl-getcanonicallocales": "npm:2.4.0" - "@formatjs/intl-listformat": "npm:7.6.0" - "@formatjs/intl-locale": "npm:4.1.0" - "@formatjs/intl-numberformat": "npm:8.13.0" - "@formatjs/intl-pluralrules": "npm:5.2.17" - "@formatjs/intl-relativetimeformat": "npm:11.3.0" + "@formatjs/intl-datetimeformat": "npm:6.16.1" + "@formatjs/intl-displaynames": "npm:6.8.1" + "@formatjs/intl-getcanonicallocales": "npm:2.5.1" + "@formatjs/intl-listformat": "npm:7.7.1" + "@formatjs/intl-locale": "npm:4.2.1" + "@formatjs/intl-numberformat": "npm:8.14.1" + "@formatjs/intl-pluralrules": "npm:5.3.1" + "@formatjs/intl-relativetimeformat": "npm:11.4.1" "@fullcalendar/core": "npm:6.1.15" "@fullcalendar/daygrid": "npm:6.1.15" "@fullcalendar/interaction": "npm:6.1.15" @@ -8849,7 +8862,7 @@ __metadata: husky: "npm:9.1.6" idb-keyval: "npm:6.2.1" instant-mocha: "npm:1.5.3" - intl-messageformat: "npm:10.7.1" + intl-messageformat: "npm:10.7.3" js-yaml: "npm:4.1.0" jszip: "npm:3.10.1" leaflet: "npm:1.9.4" @@ -9299,15 +9312,15 @@ __metadata: languageName: node linkType: hard -"intl-messageformat@npm:10.7.1": - version: 10.7.1 - resolution: "intl-messageformat@npm:10.7.1" +"intl-messageformat@npm:10.7.3": + version: 10.7.3 + resolution: "intl-messageformat@npm:10.7.3" dependencies: - "@formatjs/ecma402-abstract": "npm:2.2.0" - "@formatjs/fast-memoize": "npm:2.2.1" - "@formatjs/icu-messageformat-parser": "npm:2.8.0" - tslib: "npm:^2.7.0" - checksum: 10/e8bf10555a2352043552aca9a437a5890602e000f9895cec1e0736edda89e2e711b2dfa1672f1b92907da3550b9dcfe36842e19c9a0abffaef449caf99219313 + "@formatjs/ecma402-abstract": "npm:2.2.1" + "@formatjs/fast-memoize": "npm:2.2.2" + "@formatjs/icu-messageformat-parser": "npm:2.9.1" + tslib: "npm:2" + checksum: 10/e387f7f37a295d9d386af0c6392ba135a4580e86177161f1f400d470fed1f8c7b3cb6c724cbc2f50a7ded2e20f202977d8bf5e2bbc626f72016a5b5b6752b76d languageName: node linkType: hard @@ -13773,7 +13786,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.2, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.7.0": +"tslib@npm:2, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.2, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0": version: 2.8.0 resolution: "tslib@npm:2.8.0" checksum: 10/1bc7c43937477059b4d26f2dbde7e49ef0fb4f38f3014e0603eaea76d6a885742c8b1762af45949145e5e7408a736d20ded949da99dabc8ccba1fc5531d2d927