Compare commits

..

38 Commits

Author SHA1 Message Date
Simon Lamon 90edd05c41 Fix indentation in dependabot.yml for codeql-action 2026-07-19 09:23:52 +02:00
Simon Lamon 3ef904dea7 Add CodeQL action to Dependabot groups 2026-07-19 09:18:54 +02:00
renovate[bot] 650444e92d Update dependency @lokalise/node-api to v16.2.0 (#53177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:13:40 +02:00
dependabot[bot] da9a78f752 Bump actions/stale from 10.3.0 to 10.4.0 (#53188)
Bumps [actions/stale](https://github.com/actions/stale) from 10.3.0 to 10.4.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899...1e223db275d687790206a7acac4d1a11bd6fe629)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 09:13:19 +02:00
dependabot[bot] 9077290b39 Bump actions/labeler from 6.1.0 to 6.2.0 (#53187)
Bumps [actions/labeler](https://github.com/actions/labeler) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/f27b608878404679385c85cfa523b85ccb86e213...b8dd2d9be0f68b860e7dae5dae7d772984eacd6d)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 09:12:59 +02:00
renovate[bot] e4c6ea29b3 Lock file maintenance (#53179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:12:40 +02:00
renovate[bot] aa57c27739 Update dependency @rsdoctor/rspack-plugin to v1.6.0 (#53174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 19:12:42 +02:00
Niklas Wagner 420137478e Add manufacturer, model, and model_id filtering to entity filter selector (#29636)
* Add manufacturer, model, and model_id filtering to entity selector

* Implement suggestion from review

* fix: simplify device data type

* Nest device filtering under `device` key in entity selector

* Fix race condition when fetching data

* Avoid config entries refetch loop on fetch failure

The catch handler reset _fetchedConfigEntries to false while also
assigning _configEntries, which re-rendered the component and re-armed
the fetch guard in updated() — an unbounded, no-backoff retry loop on
persistent failure. Keep the flag set so a failed fetch is not retried
on every re-render; the connection-change handler still retries on
reconnect.

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-18 09:51:15 +02:00
renovate[bot] 25bc81cf1f Update dependency @rspack/core to v2.1.4 (#53170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-17 17:38:02 +02:00
Paul Bottein 6b0f543fdb Add conditional field visibility to ha-form (#53165) 2026-07-17 14:06:04 +01:00
Bram Kragten bbd26ce5a9 Improve messaging and error handling http config (#53126)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-07-17 14:38:25 +02:00
pcan08 d3c23dd704 Limit clock card date picker to one value per group (#53160) 2026-07-17 08:53:28 +01:00
Paulus Schoutsen 7d4e5dc03f Use /store/reload instead of legacy /addons/reload alias when checking for add-on updates (#53162)
Use /store/reload instead of legacy /addons/reload alias

Supervisor's /addons/reload is a backwards-compat alias for
/store/reload. Core now refreshes the add-on update entities after a
/store/reload call proxied through the supervisor/api websocket command,
so use the canonical endpoint.


Claude-Session: https://claude.ai/code/session_01DCaDoYyqULQbZoc226wCp3

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-17 03:45:51 -04:00
renovate[bot] db22be5a30 Update dependency typescript-eslint to v8.64.0 (#53164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-17 09:44:24 +02:00
Simon Lamon b896b20409 Output different url for demo deployment (#53158)
Output different url
2026-07-17 09:38:28 +02:00
renovate[bot] cb3b528ba3 Update dependency fuse.js to v7.5.0 (#53157) 2026-07-17 08:37:35 +01:00
karwosts 2ed8e0bd0d Add activity-panel CSV download (#53154) 2026-07-17 08:31:55 +01:00
renovate[bot] adc3393422 Update dependency @lokalise/node-api to v16.1.0 (#53156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 18:11:34 +00:00
TowyTowy 06b9b08809 Skip custom rows in entities/glance card format migration (#53074)
* Skip custom rows in entities/glance card format migration

The 2026.7.0 `format` -> `time_format` migration in the entities and
glance cards was applied to every row, including `type: custom:...` rows.
Custom rows own their config schema and some use `format` with unrelated
semantics (e.g. custom:multiple-entity-row uses `format: precision1`), so
rewriting `format` to `time_format` silently drops the option and breaks
the row's rendering.

Skip rows whose type starts with `custom:` so their config is left
untouched, matching how the frontend treats custom card/row configs as
opaque elsewhere.

Fixes #52935

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract entities/glance format migration into a testable module

Move `migrateEntitiesCardConfig` and `migrateGlanceCardConfig` out of the
card element modules into a standalone `migrate-card-config.ts` so they can
be imported by unit tests without pulling in the element modules'
build-time globals. Both card modules re-export the functions, so existing
importers (the card editors) are unaffected.

Add vitest coverage for the migration: native `format` -> `time_format`
migration, custom rows left untouched, unchanged configs returned by
identity, string rows passed through, and a pre-existing `time_format`
taking precedence over the legacy `format`.

Drop the custom-row guard from the glance migration. The glance card
renders each entity directly (name/icon/state) and never instantiates
custom rows via createRowElement, so a `custom:` type on a glance entity is
inert and the guard was dead code. The entities card does render custom
rows, so its guard stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:48:44 +00:00
Bruno Pantaleão Gonçalves ece84d42dc Send frontend/loaded event to companion apps when launch screen is removed (#53150) 2026-07-16 15:22:38 +02:00
renovate[bot] e031e59b31 Update dependency barcode-detector to v3.2.1 (#53144)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2026-07-16 09:48:13 +01:00
Paul Bottein 5bdccd2e4a Extract repeated indexed lookups into local variables (#53143) 2026-07-16 08:40:04 +01:00
Aidan Timson be27615851 Fix E2E theme scenario preferences (#53142)
* Fix E2E theme scenario preferences

* Clarify mock theme selection
2026-07-15 18:02:52 +02:00
Paul Bottein 48f3d45a2e Fix history graph editor crash on unavailable entity (#53141) 2026-07-15 15:48:02 +01:00
Aidan Timson f2a049b7c6 Install TypeScript 7 alongside TypeScript 6 API for tooling (#53122)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-15 15:22:31 +01:00
Aidan Timson 1e691f0b64 Add date to clock card (#28866)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-07-15 15:22:21 +01:00
Aidan Timson 23d7f4772a Migrate demo theme settings storage (#53139) 2026-07-15 15:47:16 +02:00
Paul Bottein bd23592e77 Fix save button not enabling on whitespace-only entity name changes (#53138) 2026-07-15 14:34:21 +01:00
Stefan Agner 0fe80ef5f9 Remove legacy Supervisor REST API fallbacks from data layer (#53131)
* Remove legacy Supervisor REST fallbacks from data layer

The WebSocket proxy command (supervisor/api) was added in Core 2021.2.4
and the REST fallbacks only existed because the Supervisor panel used to
ship with Supervisor itself and could run against older Core versions.
Since the panel moved into the main frontend, the frontend is
version-locked with Core and these fallback paths are unreachable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update create session test for WebSocket-only path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix ts-ignore placement in create session test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use Pick<HomeAssistant, "callWS"> for ingress session functions

Narrows the hass parameter so test mocks typecheck without ts-ignore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:23:32 +02:00
Aidan Timson 4327871c30 Add clear/reset action to Activity panel (#53137) 2026-07-15 11:56:24 +01:00
Bruno Pantaleão Gonçalves 88381e0b11 Revert "Add App settings entry to dashboard more menu" (#53135) 2026-07-15 08:11:33 +00:00
Bruno Pantaleão Gonçalves f44590a2aa Add App settings entry to dashboard more menu (#53130) 2026-07-15 08:39:52 +01:00
Bram Kragten 1c03daac41 Add safe area handling and opt-out to custom panels and apps (#53127) 2026-07-15 08:35:54 +01:00
Yosi Levy a2216c6bf5 Minor RTL fix (#53132) 2026-07-15 08:33:08 +01:00
renovate[bot] 4a755e00ac Update dependency tar to v7.5.20 (#53133) 2026-07-15 08:28:09 +01:00
Aidan Timson 36a9da37f8 Update FormatJS dependencies (#53121) 2026-07-14 12:31:09 +02:00
renovate[bot] cee5361525 Update dependency eslint to v10.7.0 (#53116)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 07:12:03 +02:00
renovate[bot] 5f46594ee2 Update tsparticles to v4.3.2 (#53112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-13 13:15:36 +02:00
89 changed files with 4832 additions and 2124 deletions
+4
View File
@@ -15,6 +15,10 @@ updates:
cooldown:
default-days: 7
open-pull-requests-limit: 10
groups:
codeql-action:
patterns:
- "github/codeql-action/*"
labels:
- Dependencies
- GitHub Actions
@@ -1,126 +0,0 @@
name: CI accelerator benchmark
on:
workflow_dispatch:
push:
branches:
- workflows-global-build-image
env:
NODE_OPTIONS: --max_old_space_size=6144
permissions:
contents: read
jobs:
baseline:
name: Baseline / ${{ matrix.workload }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
workload:
- lint
- test
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Check for duplicate dependencies
if: matrix.workload == 'lint'
run: yarn dedupe --check
- name: Build resources
id: build_resources
run: >-
./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
${{ matrix.workload == 'lint' && 'gather-gallery-pages' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup lint cache
if: matrix.workload == 'lint'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules/.cache/prettier
node_modules/.cache/eslint
node_modules/.cache/typescript
key: lint-${{ github.sha }}
restore-keys: lint-
- name: Run eslint
if: matrix.workload == 'lint'
run: yarn run lint:eslint --quiet
- name: Run tsc
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
run: yarn run lint:types
- name: Run lit-analyzer
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
run: yarn run lint:lit --quiet
- name: Run prettier
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
run: yarn run lint:prettier
- name: Check dependency licenses
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
run: yarn run lint:licenses
- name: Run tests
if: matrix.workload == 'test'
run: yarn run test
image:
name: Image / ${{ matrix.workload }}
runs-on: ubuntu-latest
container:
image: ghcr.io/timmo001/home-assistant-frontend-ci-images/frontend-workflow@sha256:ffa4d621b4495bf456b7ab3c632f550e329fe78c2d571167aa0899a52c4932c3
strategy:
fail-fast: false
matrix:
workload:
- lint
- test
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install dependencies
run: frontend-install
- name: Check for duplicate dependencies
if: matrix.workload == 'lint'
run: yarn dedupe --check
- name: Build resources
id: build_resources
run: >-
./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
${{ matrix.workload == 'lint' && 'gather-gallery-pages' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup lint cache
if: matrix.workload == 'lint'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules/.cache/prettier
node_modules/.cache/eslint
node_modules/.cache/typescript
key: lint-${{ github.sha }}
restore-keys: lint-
- name: Run eslint
if: matrix.workload == 'lint'
run: yarn run lint:eslint --quiet
- name: Run tsc
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
run: yarn run lint:types
- name: Run lit-analyzer
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
run: yarn run lint:lit --quiet
- name: Run prettier
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
run: yarn run lint:prettier
- name: Check dependency licenses
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
run: yarn run lint:licenses
- name: Run tests
if: matrix.workload == 'test'
run: yarn run test
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
if: github.event_name != 'push' || github.ref_name != 'master'
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.netlify_url }}
url: ${{ steps.deploy.outputs.unique_deploy_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
with:
sync-labels: true
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90
+1 -1
View File
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
version: "5.8.0",
licenseFile: "license-mit",
},
];
+11 -2
View File
@@ -1,7 +1,16 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { energyEntities } from "../stubs/entities";
import type { DemoConfig } from "./types";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
if (typeof theme === "function") {
hass.mockTheme(theme());
return;
}
hass.mockTheme(null, getDemoTheme(theme));
};
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
() => import("./sections").then((mod) => mod.demoSections),
@@ -31,5 +40,5 @@ export const setDemoConfig = async (
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
lovelace.saveConfig(config.lovelace(hass.localize));
hass.mockTheme(config.theme());
applyDemoTheme(hass, config.theme);
};
+1 -1
View File
@@ -8,5 +8,5 @@ export const demoSections: DemoConfig = {
name: "Home Demo",
lovelace: demoLovelaceSections,
entities: demoEntitiesSections,
theme: () => ({}),
theme: { theme: "default", dark: false },
};
+4 -1
View File
@@ -2,6 +2,9 @@ import type { TemplateResult } from "lit";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
export interface DemoConfig {
index?: number;
@@ -12,5 +15,5 @@ export interface DemoConfig {
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
theme: () => Record<string, string> | null;
theme: DemoTheme;
}
+2 -4
View File
@@ -5,7 +5,7 @@ import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
import { provideHass } from "../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { selectedDemoConfig } from "./configs/demo-configs";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry } from "./stubs/area_registry";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
@@ -173,9 +173,7 @@ export class HaDemo extends HomeAssistantAppEl {
Promise.all([selectedDemoConfig, localizePromise]).then(
([conf, localize]) => {
hass.addEntities(conf.entities(localize));
if (conf.theme) {
hass.mockTheme(conf.theme());
}
applyDemoTheme(hass, conf.theme);
}
);
+38 -3
View File
@@ -1,10 +1,37 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { ThemeSettings } from "../../../src/types";
let sidebarChangeCallback;
let sidebarChangeCallback: ((data: { value: unknown }) => void) | undefined;
let themeChangeCallback: ((data: { value: ThemeSettings }) => void) | undefined;
const THEME_STORAGE_KEY = "demo_theme";
const DEFAULT_THEME: ThemeSettings = { theme: "default", dark: false };
export const getDemoTheme = (
fallback: ThemeSettings = DEFAULT_THEME
): ThemeSettings => {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (!storedTheme) {
return fallback;
}
try {
return JSON.parse(storedTheme) as ThemeSettings;
} catch {
localStorage.removeItem(THEME_STORAGE_KEY);
return fallback;
}
};
export const mockFrontend = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_user_data", () => ({ value: null }));
hass.mockWS("frontend/get_user_data", ({ key }) => ({
value: key === "theme" ? getDemoTheme() : null,
}));
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
if (key === "theme") {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value));
themeChangeCallback?.({ value });
localStorage.removeItem("selectedTheme");
}
if (key === "sidebar") {
sidebarChangeCallback?.({
value: {
@@ -14,10 +41,18 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
});
}
});
hass.mockWS("frontend/subscribe_user_data", (msg, _hass, onChange) => {
hass.mockWS("frontend/subscribe_user_data", (msg, currentHass, onChange) => {
if (msg.key === "sidebar") {
sidebarChangeCallback = onChange;
}
if (msg.key === "theme") {
themeChangeCallback = onChange;
onChange?.({
value: getDemoTheme(currentHass.selectedTheme ?? undefined),
});
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
}
onChange?.({ value: null });
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
+22 -21
View File
@@ -12,7 +12,7 @@
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "tsc",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
@@ -52,15 +52,15 @@
"@codemirror/view": "6.43.6",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.10",
"@formatjs/intl-displaynames": "7.3.11",
"@formatjs/intl-durationformat": "0.10.16",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.11",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.12",
"@formatjs/intl-pluralrules": "6.3.11",
"@formatjs/intl-relativetimeformat": "12.3.11",
"@formatjs/intl-datetimeformat": "7.5.0",
"@formatjs/intl-displaynames": "7.3.12",
"@formatjs/intl-durationformat": "0.10.17",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.12",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.3.13",
"@formatjs/intl-pluralrules": "6.3.12",
"@formatjs/intl-relativetimeformat": "12.3.12",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
@@ -83,13 +83,13 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.3.1",
"@tsparticles/preset-links": "4.3.1",
"@tsparticles/engine": "4.3.2",
"@tsparticles/preset-links": "4.3.2",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.0",
"barcode-detector": "3.2.1",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -102,12 +102,12 @@
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.2",
"fuse.js": "7.5.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.10",
"intl-messageformat": "11.2.11",
"js-yaml": "5.2.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -146,13 +146,13 @@
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.0.0",
"@lokalise/node-api": "16.2.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.5.18",
"@rspack/core": "2.1.3",
"@rsdoctor/rspack-plugin": "1.6.0",
"@rspack/core": "2.1.4",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -168,12 +168,13 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:typescript@7.0.2",
"@vitest/coverage-v8": "4.1.10",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.6.0",
"eslint": "10.7.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -206,11 +207,11 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.19",
"tar": "7.5.20",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.63.0",
"typescript-eslint": "8.64.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
+3 -2
View File
@@ -51,8 +51,9 @@ class StorageClass {
storageKey: string,
callback: Callback
): UnsubscribeFunc {
if (this._listeners[storageKey]) {
this._listeners[storageKey].push(callback);
const listeners = this._listeners[storageKey];
if (listeners) {
listeners.push(callback);
} else {
this._listeners[storageKey] = [callback];
}
+52 -3
View File
@@ -1,4 +1,53 @@
const regexp =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// Unanchored regex fragments, shared as the single source of truth for both
// the boolean validators below and the HTML `pattern` attribute (the browser
// anchors a pattern as `^(?:…)$`).
const IPV4 =
"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
export const isIPAddress = (input: string): boolean => regexp.test(input);
const IPV6 =
"(?:([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
// CIDR prefix lengths: 0-32 for IPv4, 0-128 for IPv6.
const IPV4_PREFIX = "(?:3[0-2]|[12]?[0-9])";
const IPV6_PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])";
// IPv4 or IPv6 address.
export const IP_ADDRESS_PATTERN = `${IPV4}|${IPV6}`;
// IPv4/IPv6 address, optionally with a CIDR prefix (network).
export const IP_ADDRESS_OR_NETWORK_PATTERN = `${IPV4}(?:/${IPV4_PREFIX})?|${IPV6}(?:/${IPV6_PREFIX})?`;
const anchored = (pattern: string): RegExp => new RegExp(`^(?:${pattern})$`);
const ipv4Regexp = anchored(IPV4);
const ipv6Regexp = anchored(IPV6);
// IPv4 address, e.g. 192.168.1.10
export const isIPAddress = (input: string): boolean => ipv4Regexp.test(input);
// IPv6 address, e.g. fe80::85d:e82c:9446:7995
export const isIPv6Address = (input: string): boolean => ipv6Regexp.test(input);
// IPv4 or IPv6 address
export const isIPAddressV4OrV6 = (input: string): boolean =>
isIPAddress(input) || isIPv6Address(input);
// IP network in CIDR notation, e.g. 192.168.1.0/24 or fd00::/8
export const isIPNetwork = (input: string): boolean => {
const parts = input.split("/");
if (parts.length !== 2) {
return false;
}
const [address, prefix] = parts;
if (!/^\d{1,3}$/.test(prefix)) {
return false;
}
const prefixLength = Number(prefix);
if (isIPAddress(address)) {
return prefixLength >= 0 && prefixLength <= 32;
}
if (isIPv6Address(address)) {
return prefixLength >= 0 && prefixLength <= 128;
}
return false;
};
+4 -5
View File
@@ -33,13 +33,12 @@ const extractVarFromBase = (
varName: string,
baseVars: Record<string, string>
): string | undefined => {
if (baseVars[varName] && baseVars[varName].startsWith("var(")) {
const baseVarName = baseVars[varName]
.substring(6, baseVars[varName].length - 1)
.trim();
const value = baseVars[varName];
if (value && value.startsWith("var(")) {
const baseVarName = value.substring(6, value.length - 1).trim();
return extractVarFromBase(baseVarName, baseVars);
}
return baseVars[varName];
return value;
};
/**
@@ -0,0 +1,579 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import { configContext, internationalizationContext } from "../data/context";
import {
CLOCK_CARD_DATE_PARTS,
formatClockCardDate,
} from "../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-assist-chip";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-input-helper-text";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
"month",
"year",
"weekday",
"separator",
];
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "",
};
const getClockDatePartSection = (
part: ClockCardDatePart
): ClockDatePartSection => {
if (part.startsWith("weekday-")) {
return "weekday";
}
if (part.startsWith("day-")) {
return "day";
}
if (part.startsWith("month-")) {
return "month";
}
if (part.startsWith("year-")) {
return "year";
}
return "separator";
};
interface ClockDatePartSectionData {
id: ClockDatePartSection;
title: string;
items: PickerComboBoxItem[];
}
/**
* Filters out sections whose group already has a value in `value`, so only
* one non-separator value per group can be selected in the picker. The whole
* group of the item at `excludeIndex` (the one being edited) stays
* selectable, even if that group has more than one value in `value` (e.g. a
* pre-existing config authored outside the picker via YAML).
* The separator group is always kept.
*/
export const getAvailableClockDatePartSections = (
sections: ClockDatePartSectionData[],
value: string[],
excludeIndex?: number
): ClockDatePartSectionData[] => {
const editedItem = excludeIndex != null ? value[excludeIndex] : undefined;
const editedSection = editedItem
? getClockDatePartSection(editedItem as ClockCardDatePart)
: undefined;
const usedSections = new Set<ClockDatePartSection>();
value.forEach((item) => {
const section = getClockDatePartSection(item as ClockCardDatePart);
if (section !== "separator" && section !== editedSection) {
usedSections.add(section);
}
});
return sections.filter(
(sectionData) =>
sectionData.id === "separator" || !usedSections.has(sectionData.id)
);
};
interface ClockDatePartValueItem {
key: string;
item: string;
idx: number;
}
@customElement("ha-clock-date-format-picker")
export class HaClockDateFormatPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public label?: string;
@property() public value?: string[] | string;
@property() public helper?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
@state() private _editIndex?: number;
protected render() {
const value = this._value;
const valueItems = this._getValueItems(value);
const sections = this._buildSections();
const pickerSections = getAvailableClockDatePartSections(
sections,
value,
this._editIndex
);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
.sections=${this._getSectionHeaders(pickerSections)}
.getItems=${this._getItems(pickerSections)}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
valueItems,
(entry: ClockDatePartValueItem) => entry.key,
({ item, idx }) => this._renderValueChip(item, idx, sections)
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._i18n.localize("ui.common.add")}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private _getValueItems = memoizeOne(
(value: string[]): ClockDatePartValueItem[] => {
const occurrences = new Map<string, number>();
return value.map((item, idx) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);
return {
key: `${item}:${occurrence}`,
item,
idx,
};
});
}
);
private _renderValueChip(
item: string,
idx: number,
sections: ClockDatePartSectionData[]
) {
const label = this._getItemLabel(item, sections);
const isValid = !!label;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label ?? item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private get _value() {
return !this.value ? [] : ensureArray(this.value);
}
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
value.length === 0 ? undefined : value
);
private _buildSections(): ClockDatePartSectionData[] {
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
weekday: [],
day: [],
month: [],
year: [],
separator: [],
};
const previewDate = new Date();
const previewTimeZone = resolveTimeZone(
this._i18n.locale.time_zone,
this._hassConfig.config.time_zone
);
CLOCK_CARD_DATE_PARTS.forEach((part) => {
const section = getClockDatePartSection(part);
const label =
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
) ?? part;
const secondary =
section === "separator"
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
: formatClockCardDate(
previewDate,
{ parts: [part] },
this._i18n.locale.language,
previewTimeZone
);
itemsBySection[section].push({
id: part,
primary: label,
secondary,
sorting_label: label,
});
});
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
id: section,
title:
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
) ?? section,
items: itemsBySection[section],
})).filter((section) => section.items.length > 0);
}
private _getSectionHeaders(
sections: ClockDatePartSectionData[]
): { id: string; label: string }[] {
return sections.map((section) => ({
id: section.id,
label: section.title,
}));
}
private _getItems = memoizeOne(
(sections: ClockDatePartSectionData[]) =>
(
searchString?: string,
section?: string
): (PickerComboBoxItem | string)[] => {
const normalizedSearch = searchString?.trim().toLowerCase();
const filteredSections = sections
.map((sectionData) => {
if (!normalizedSearch) {
return sectionData;
}
return {
...sectionData,
items: sectionData.items.filter(
(item) =>
item.primary.toLowerCase().includes(normalizedSearch) ||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
item.id.toLowerCase().includes(normalizedSearch)
),
};
})
.filter((sectionData) => sectionData.items.length > 0);
if (section) {
return (
filteredSections.find((candidate) => candidate.id === section)
?.items || []
);
}
const groupedItems: (PickerComboBoxItem | string)[] = [];
filteredSections.forEach((sectionData) => {
groupedItems.push(sectionData.title, ...sectionData.items);
});
return groupedItems;
}
);
private _getItemLabel(
value: string,
sections: ClockDatePartSectionData[]
): string | undefined {
for (const section of sections) {
const item = section.items.find((candidate) => candidate.id === value);
if (item) {
if (section.id === "separator") {
if (value === "separator-new-line") {
return item.primary;
}
return item.secondary ?? item.primary;
}
return `${item.secondary} [${item.primary} ${section.title}]`;
}
}
return undefined;
}
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
return this._value[this._editIndex];
}
return undefined;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.preventDefault();
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
if (Number.isNaN(idx)) {
return;
}
const value = [...this._value];
value.splice(idx, 1);
if (this._editIndex !== undefined) {
if (this._editIndex === idx) {
this._editIndex = undefined;
} else if (this._editIndex > idx) {
this._editIndex -= 1;
}
}
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const newValue = [...this._value];
if (this._editIndex != null) {
newValue[this._editIndex] = value;
this._editIndex = undefined;
} else {
newValue.push(value);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: string[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transition:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-clock-date-format-picker": HaClockDateFormatPicker;
}
}
+73
View File
@@ -0,0 +1,73 @@
import type {
HaFormBaseSchema,
HaFormCondition,
HaFormDataContainer,
HaFormFieldCondition,
HaFormSchema,
} from "./types";
const isEmpty = (value: unknown): boolean =>
value === undefined || value === null || value === "";
const matchFieldCondition = (
condition: HaFormFieldCondition,
data: HaFormDataContainer | undefined
): boolean => {
const actual = data?.[condition.field];
switch (condition.operator ?? "eq") {
case "eq":
return actual === condition.value;
case "not_eq":
return actual !== condition.value;
case "in":
return (
Array.isArray(condition.value) &&
condition.value.includes(actual as any)
);
case "not_in":
return (
Array.isArray(condition.value) &&
!condition.value.includes(actual as any)
);
case "exists":
return !isEmpty(actual);
case "not_exists":
return isEmpty(actual);
default:
return false;
}
};
export const evaluateCondition = (
condition: HaFormCondition,
data: HaFormDataContainer | undefined
): boolean => {
if ("condition" in condition) {
switch (condition.condition) {
case "and":
return condition.conditions.every((c) => evaluateCondition(c, data));
case "or":
return condition.conditions.some((c) => evaluateCondition(c, data));
case "not":
return !condition.conditions.some((c) => evaluateCondition(c, data));
default:
return false;
}
}
return matchFieldCondition(condition, data);
};
export const isFieldHidden = (
schema: HaFormSchema,
data: HaFormDataContainer | undefined
): boolean => {
const { hidden } = schema as HaFormBaseSchema;
if (!hidden) {
return false;
}
if (hidden === true) {
return true;
}
const conditions = Array.isArray(hidden) ? hidden : [hidden];
return conditions.every((condition) => evaluateCondition(condition, data));
};
+16 -13
View File
@@ -2,6 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import { isFieldHidden } from "./conditions";
import "./ha-form";
import type { HaForm } from "./ha-form";
import type {
@@ -68,19 +69,21 @@ export class HaFormGrid extends LitElement implements HaFormElement {
protected render(): TemplateResult {
return html`
${this.schema.schema.map(
(item) => html`
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${[item]}
.disabled=${this.disabled}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
`
)}
${this.schema.schema
.filter((item) => !isFieldHidden(item, this.data))
.map(
(item) => html`
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${[item]}
.disabled=${this.disabled}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
`
)}
`;
}
+11 -2
View File
@@ -1,11 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-selector/ha-selector";
import { isFieldHidden } from "./conditions";
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
const LOAD_ELEMENTS = {
@@ -98,7 +99,11 @@ export class HaForm extends LitElement implements HaFormElement {
let isValid = true;
let firstInvalidElement: HTMLElement | undefined;
this.schema.forEach((item, index) => {
const visibleSchema = this.schema.filter(
(item) => !isFieldHidden(item, this.data)
);
visibleSchema.forEach((item, index) => {
const element = elements[index];
if (!element) {
return;
@@ -164,6 +169,10 @@ export class HaForm extends LitElement implements HaFormElement {
: ""
}
${this.schema.map((item) => {
if (isFieldHidden(item, this.data)) {
return nothing;
}
const error = getError(this.error, item);
const warning = getWarning(this.warning, item);
+33
View File
@@ -22,6 +22,9 @@ export interface HaFormBaseSchema {
default?: HaFormData;
required?: boolean;
disabled?: boolean;
// Field is hidden while the condition holds. Serializable so it can be
// shared with the backend and other renderers.
hidden?: boolean | HaFormCondition | HaFormCondition[];
description?: {
suffix?: string;
// This value will be set initially when form is loaded
@@ -30,6 +33,36 @@ export interface HaFormBaseSchema {
context?: Record<string, string>;
}
export type HaFormConditionOperator =
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
export interface HaFormFieldCondition {
field: string;
operator?: HaFormConditionOperator;
value?: HaFormData | readonly HaFormData[];
}
export interface HaFormAndCondition {
condition: "and";
conditions: readonly HaFormCondition[];
}
export interface HaFormOrCondition {
condition: "or";
conditions: readonly HaFormCondition[];
}
export interface HaFormNotCondition {
condition: "not";
conditions: readonly HaFormCondition[];
}
export type HaFormCondition =
| HaFormFieldCondition
| HaFormAndCondition
| HaFormOrCondition
| HaFormNotCondition;
export interface HaFormGridSchema extends HaFormBaseSchema {
type: "grid";
flatten?: boolean;
@@ -133,7 +133,13 @@ export class HaAreaSelector extends LitElement {
}
return ensureArray(this.selector.area.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices
)
);
};
@@ -147,7 +147,13 @@ export class HaDeviceSelector extends LitElement {
private _filterEntities = (entity: HassEntity): boolean =>
ensureArray(this.selector.device!.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices
)
);
}
@@ -2,8 +2,12 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { EntitySelector } from "../../data/selector";
@@ -23,6 +27,8 @@ export class HaEntitySelector extends LitElement {
@state() private _entitySources?: EntitySources;
@state() private _configEntries?: ConfigEntry[];
@property() public value?: any;
@property() public label?: string;
@@ -37,12 +43,38 @@ export class HaEntitySelector extends LitElement {
@state() private _createDomains: string[] | undefined;
private _hasIntegration(selector: EntitySelector) {
return (
selector.entity?.filter &&
ensureArray(selector.entity.filter).some((filter) => filter.integration)
);
}
private _deviceIntegrationLookup = memoizeOne(
(
entitySources: EntitySources,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
configEntries?: ConfigEntry[]
) =>
getDeviceIntegrationLookup(
entitySources,
Object.values(entities),
Object.values(devices),
configEntries
)
);
// Which async data the current filter needs to be evaluated: a top-level or
// device `integration` filter needs entity sources, and a `device.integration`
// filter additionally needs config entries (the device integration lookup is
// built from both).
private _dataNeeds = memoizeOne((selector: EntitySelector) => {
const filters = selector.entity?.filter
? ensureArray(selector.entity.filter)
: [];
return {
entitySources: filters.some(
(f) => f.integration || f.device?.integration
),
configEntries: filters.some((f) => f.device?.integration),
};
});
private _fetchedConfigEntries = false;
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.get("selector") && this.value !== undefined) {
@@ -57,7 +89,11 @@ export class HaEntitySelector extends LitElement {
}
protected render() {
if (this._hasIntegration(this.selector) && !this._entitySources) {
const needs = this._dataNeeds(this.selector);
if (
(needs.entitySources && !this._entitySources) ||
(needs.configEntries && !this._configEntries)
) {
return nothing;
}
@@ -96,15 +132,37 @@ export class HaEntitySelector extends LitElement {
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (
changedProps.has("selector") &&
this._hasIntegration(this.selector) &&
!this._entitySources
) {
// The connection changed (e.g. reconnect); refetch config entries.
const oldHass = changedProps.get("hass");
if (oldHass && oldHass.connection !== this.hass.connection) {
this._fetchedConfigEntries = false;
this._configEntries = undefined;
}
const needs = this._dataNeeds(this.selector);
if (needs.entitySources && !this._entitySources) {
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
}
if (needs.configEntries && !this._fetchedConfigEntries) {
this._fetchedConfigEntries = true;
getConfigEntries(this.hass)
.then((entries) => {
this._configEntries = entries;
})
.catch(() => {
// Fall back to no entries so the picker still renders. We keep
// `_fetchedConfigEntries` set so the failed fetch is not retried on
// every re-render; the connection-change handler above retries on
// reconnect.
this._configEntries = [];
});
}
if (changedProps.has("selector")) {
this._createDomains = computeCreateDomains(this.selector);
}
@@ -114,8 +172,25 @@ export class HaEntitySelector extends LitElement {
if (!this.selector?.entity?.filter) {
return true;
}
const deviceIntegrationLookup =
this._entitySources && this._dataNeeds(this.selector).configEntries
? this._deviceIntegrationLookup(
this._entitySources,
this.hass.entities,
this.hass.devices,
this._configEntries
)
: undefined;
return ensureArray(this.selector.entity.filter).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices,
deviceIntegrationLookup
)
);
};
}
@@ -133,7 +133,13 @@ export class HaFloorSelector extends LitElement {
}
return ensureArray(this.selector.floor.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices
)
);
};
@@ -181,49 +181,49 @@ export class HaSelectorSelector extends LitElement {
return true;
}
private _schema = memoizeOne(
(choice: string, localize: LocalizeFunc) =>
[
{
name: "type",
required: true,
selector: {
select: {
mode: "dropdown",
options: Object.keys(SELECTOR_SCHEMAS)
.concat("manual")
.map((key) => ({
label:
localize(
`ui.components.selectors.selector.types.${key}` as LocalizeKeys
) || key,
value: key,
})),
},
private _schema = memoizeOne((choice: string, localize: LocalizeFunc) => {
const schemas = SELECTOR_SCHEMAS[choice];
return [
{
name: "type",
required: true,
selector: {
select: {
mode: "dropdown",
options: Object.keys(SELECTOR_SCHEMAS)
.concat("manual")
.map((key) => ({
label:
localize(
`ui.components.selectors.selector.types.${key}` as LocalizeKeys
) || key,
value: key,
})),
},
},
...(choice === "manual"
? ([
},
...(choice === "manual"
? ([
{
name: "manual",
selector: { object: {} },
},
] as const)
: []),
...(schemas
? schemas.length > 1
? [
{
name: "manual",
selector: { object: {} },
name: "",
type: "expandable",
title: localize("ui.components.selectors.selector.options"),
schema: schemas,
},
] as const)
: []),
...(SELECTOR_SCHEMAS[choice]
? SELECTOR_SCHEMAS[choice].length > 1
? [
{
name: "",
type: "expandable",
title: localize("ui.components.selectors.selector.options"),
schema: SELECTOR_SCHEMAS[choice],
},
]
: SELECTOR_SCHEMAS[choice]
: []),
] as const
);
]
: schemas
: []),
] as const;
});
protected render() {
let data;
@@ -93,7 +93,13 @@ export class HaTargetSelector extends LitElement {
}
return ensureArray(this.selector.target.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices
)
);
};
+10 -1
View File
@@ -28,6 +28,10 @@ export class HaTextSelector extends LitElement {
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
@query("ha-input-multi") private _inputMulti?: {
reportValidity: () => boolean;
};
public async focus() {
await this.updateComplete;
this._input?.focus();
@@ -35,7 +39,7 @@ export class HaTextSelector extends LitElement {
public reportValidity(): boolean {
if (this.selector.text?.multiple) {
return true;
return this._inputMulti?.reportValidity() ?? true;
}
return this._input?.reportValidity() ?? true;
}
@@ -52,6 +56,8 @@ export class HaTextSelector extends LitElement {
.inputPrefix=${this.selector.text?.prefix}
.helper=${this.helper}
.autocomplete=${this.selector.text?.autocomplete}
.pattern=${this.selector.text?.pattern}
.validationMessage=${this.selector.text?.validation_message}
@value-changed=${this._handleChange}
>
</ha-input-multi>
@@ -80,6 +86,9 @@ export class HaTextSelector extends LitElement {
.hint=${this.helper}
.disabled=${this.disabled}
.type=${this.selector.text?.type}
.pattern=${this.selector.text?.pattern}
.validationMessage=${this.selector.text?.validation_message}
.autoValidate=${this.selector.text?.pattern !== undefined}
@input=${this._handleChange}
@change=${this._handleChange}
.label=${this.label || ""}
@@ -0,0 +1,41 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { UiClockDateFormatSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-clock-date-format-picker";
@customElement("ha-selector-ui_clock_date_format")
export class HaSelectorUiClockDateFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-clock-date-format-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-clock-date-format-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
}
}
@@ -65,6 +65,7 @@ const LOAD_ELEMENTS = {
location: () => import("./ha-selector-location"),
color_temp: () => import("./ha-selector-color-temp"),
ui_action: () => import("./ha-selector-ui-action"),
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
+14 -16
View File
@@ -305,6 +305,10 @@ export class HaServiceControl extends LitElement {
) {
return null;
}
const isPrimaryEntity = (entityId: string) => {
const entity = this.hass.entities[entityId];
return !entity?.entity_category && !entity?.hidden;
};
const targetEntities =
ensureArray(
value?.target?.entity_id || value?.data?.entity_id
@@ -333,11 +337,7 @@ export class HaServiceControl extends LitElement {
targetSelector
);
targetDevices.push(...expanded.devices);
const primaryEntities = expanded.entities.filter(
(entityId) =>
!this.hass.entities[entityId]?.entity_category &&
!this.hass.entities[entityId]?.hidden
);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
targetEntities.push(primaryEntities);
targetAreas.push(...expanded.areas);
});
@@ -362,11 +362,7 @@ export class HaServiceControl extends LitElement {
this.hass.entities,
targetSelector
);
const primaryEntities = expanded.entities.filter(
(entityId) =>
!this.hass.entities[entityId]?.entity_category &&
!this.hass.entities[entityId]?.hidden
);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
targetEntities.push(...primaryEntities);
targetDevices.push(...expanded.devices);
});
@@ -379,11 +375,7 @@ export class HaServiceControl extends LitElement {
this.hass.entities,
targetSelector
);
const primaryEntities = expanded.entities.filter(
(entityId) =>
!this.hass.entities[entityId]?.entity_category &&
!this.hass.entities[entityId]?.hidden
);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
targetEntities.push(...primaryEntities);
});
}
@@ -903,7 +895,13 @@ export class HaServiceControl extends LitElement {
}
if (targetEntities.length) {
targetEntities = targetEntities.filter((entity) =>
entityMeetsTargetSelector(this.hass.states[entity], targetSelector)
entityMeetsTargetSelector(
this.hass.states[entity],
targetSelector,
undefined,
this.hass.entities,
this.hass.devices
)
);
}
target = {
+29 -1
View File
@@ -2,7 +2,13 @@ import { consume, type ContextType } from "@lit/context";
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { uid } from "../../common/util/uid";
@@ -50,6 +56,13 @@ class HaInputMulti extends LitElement {
@property() public autocomplete?: string;
/** Regular expression each entry is validated against (HTML `pattern`). */
@property() public pattern?: string;
/** Message shown on an entry when it fails `pattern` validation. */
@property({ attribute: "validation-message" })
public validationMessage?: string;
@property({ attribute: "add-label" }) public addLabel?: string;
@property({ attribute: "remove-label" }) public removeLabel?: string;
@@ -70,6 +83,8 @@ class HaInputMulti extends LitElement {
@query("ha-input[data-last]") private _lastInput?: HaInput;
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
// Stable key per row, kept in sync with `value`. Because items are plain
// strings we cannot use a WeakMap (as the object-based sortable lists do),
// so we track keys in a parallel array. Keys stay fixed while a row is
@@ -89,6 +104,16 @@ class HaInputMulti extends LitElement {
}
}
public reportValidity(): boolean {
let valid = true;
this._inputs?.forEach((input) => {
if (!input.reportValidity()) {
valid = false;
}
});
return valid;
}
protected render() {
return html`
<ha-sortable
@@ -109,6 +134,9 @@ class HaInputMulti extends LitElement {
.type=${this.inputType}
.autocomplete=${this.autocomplete}
.disabled=${this.disabled}
.pattern=${this.pattern}
.validationMessage=${this.validationMessage}
.autoValidate=${this.pattern !== undefined}
dialogInitialFocus=${index}
.index=${index}
class="flex-auto"
+11 -10
View File
@@ -213,12 +213,14 @@ export const findBatteryEntity = <T extends { entity_id: string }>(
entities: T[]
): T | undefined => {
const batteryEntities = entities
.filter(
(entity) =>
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery" &&
.filter((entity) => {
const state = states[entity.entity_id];
return (
state &&
state.attributes.device_class === "battery" &&
batteryPriorities.includes(computeDomain(entity.entity_id))
)
);
})
.sort(
(a, b) =>
batteryPriorities.indexOf(computeDomain(a.entity_id)) -
@@ -235,11 +237,10 @@ export const findBatteryChargingEntity = <T extends { entity_id: string }>(
states: HomeAssistant["states"],
entities: T[]
): T | undefined =>
entities.find(
(entity) =>
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery_charging"
);
entities.find((entity) => {
const state = states[entity.entity_id];
return state && state.attributes.device_class === "battery_charging";
});
export const computeEntityRegistryName = (
hass: HomeAssistant,
+1 -1
View File
@@ -140,7 +140,7 @@ export interface HassioAddonSetOptionParams {
export const reloadHassioAddons = async (hass: HomeAssistant) => {
await hass.callWS({
type: "supervisor/api",
endpoint: "/addons/reload",
endpoint: "/store/reload",
method: "post",
});
};
-3
View File
@@ -17,9 +17,6 @@ export interface HassioStats {
network_tx: number;
}
export const hassioApiResultExtractor = <T>(response: HassioResponse<T>) =>
response.data;
export const extractApiErrorMessage = (error: any): string =>
typeof error === "object"
? typeof error.body === "object"
+17 -47
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
type HassioDockerRegistries = Record<
string,
@@ -10,59 +7,32 @@ type HassioDockerRegistries = Record<
export const fetchHassioDockerRegistries = async (
hass: HomeAssistant
): Promise<HassioDockerRegistries> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"GET",
"hassio/docker/registries"
)
);
};
): Promise<HassioDockerRegistries> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "get",
});
export const addHassioDockerRegistry = async (
hass: HomeAssistant,
data: HassioDockerRegistries
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"POST",
"hassio/docker/registries",
data
);
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "post",
data,
});
};
export const removeHassioDockerRegistry = async (
hass: HomeAssistant,
registry: string
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries/${registry}`,
method: "delete",
});
return;
}
await hass.callApi<HassioResponse<void>>(
"DELETE",
`hassio/docker/registries/${registry}`
);
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries/${registry}`,
method: "delete",
});
};
+12 -35
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHardwareAudioDevice {
device?: string | null;
@@ -30,38 +27,18 @@ export interface HassioHardwareInfo {
export const fetchHassioHardwareAudio = async (
hass: HomeAssistant
): Promise<HassioHardwareAudioList> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/audio`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareAudioList>>(
"GET",
"hassio/hardware/audio"
)
);
};
): Promise<HassioHardwareAudioList> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/audio`,
method: "get",
});
export const fetchHassioHardwareInfo = async (
hass: HomeAssistant
): Promise<HassioHardwareInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/info`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareInfo>>(
"GET",
"hassio/hardware/info"
)
);
};
): Promise<HassioHardwareInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/info`,
method: "get",
});
+70 -143
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHostInfo {
agent_version: string;
@@ -54,156 +51,86 @@ export interface HostDisksUsage {
export const fetchHassioHostInfo = async (
hass: HomeAssistant
): Promise<HassioHostInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/info",
method: "get",
});
}
const response = await hass.callApi<HassioResponse<HassioHostInfo>>(
"GET",
"hassio/host/info"
);
return hassioApiResultExtractor(response);
};
): Promise<HassioHostInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/info",
method: "get",
});
export const fetchHassioHassOsInfo = async (
hass: HomeAssistant
): Promise<HassioHassOSInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/info",
method: "get",
});
}
): Promise<HassioHassOSInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/info",
method: "get",
});
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHassOSInfo>>(
"GET",
"hassio/os/info"
)
);
};
export const rebootHost = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/reboot",
method: "post",
timeout: null,
});
export const rebootHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/reboot",
method: "post",
timeout: null,
});
}
export const shutdownHost = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/shutdown",
method: "post",
timeout: null,
});
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/reboot");
};
export const updateOS = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/update",
method: "post",
timeout: null,
});
export const shutdownHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/shutdown",
method: "post",
timeout: null,
});
}
export const configSyncOS = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/config/sync",
method: "post",
timeout: null,
});
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/shutdown");
};
export const changeHostOptions = async (hass: HomeAssistant, options: any) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/options",
method: "post",
data: options,
});
export const updateOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/update",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/update");
};
export const configSyncOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/config/sync",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/config/sync");
};
export const changeHostOptions = async (hass: HomeAssistant, options: any) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/options",
method: "post",
data: options,
});
}
return hass.callApi<HassioResponse<void>>(
"POST",
"hassio/host/options",
options
);
};
export const moveDatadisk = async (hass: HomeAssistant, device: string) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/datadisk/move",
method: "post",
timeout: null,
data: { device },
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/datadisk/move");
};
export const moveDatadisk = async (hass: HomeAssistant, device: string) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/datadisk/move",
method: "post",
timeout: null,
data: { device },
});
export const listDatadisks = async (
hass: HomeAssistant
): Promise<DatadiskList> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS<DatadiskList>({
type: "supervisor/api",
endpoint: "/os/datadisk/list",
method: "get",
timeout: null,
});
}
): Promise<DatadiskList> =>
hass.callWS<DatadiskList>({
type: "supervisor/api",
endpoint: "/os/datadisk/list",
method: "get",
timeout: null,
});
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<DatadiskList>>("GET", "/os/datadisk/list")
);
};
export const fetchHostDisksUsage = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HostDisksUsage>>(
"GET",
"hassio/host/disks/default/usage"
)
);
};
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
});
+14 -33
View File
@@ -1,9 +1,6 @@
import { getCollection, type Connection } from "home-assistant-js-websocket";
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import { supervisorApiWsRequest } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import type { CreateSessionResponse } from "./supervisor";
function setIngressCookie(session: string): string {
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
@@ -13,21 +10,14 @@ function setIngressCookie(session: string): string {
}
export const createHassioSession = async (
hass: HomeAssistant
hass: Pick<HomeAssistant, "callWS">
): Promise<string> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
const wsResponse: { session: string } = await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/session",
method: "post",
});
return setIngressCookie(wsResponse.session);
}
const restResponse: { data: { session: string } } = await hass.callApi<
HassioResponse<CreateSessionResponse>
>("POST", "hassio/ingress/session");
return setIngressCookie(restResponse.data.session);
const wsResponse: { session: string } = await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/session",
method: "post",
});
return setIngressCookie(wsResponse.session);
};
export interface IngressPanelInfo {
@@ -50,22 +40,13 @@ export const getIngressPanelInfoCollection = (conn: Connection) =>
);
export const validateHassioSession = async (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
session: string
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/validate_session",
method: "post",
data: { session },
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
"hassio/ingress/validate_session",
{ session }
);
await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/validate_session",
method: "post",
data: { session },
});
};
+20 -52
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
interface IpConfiguration {
address: string[];
@@ -55,66 +52,37 @@ export interface NetworkInfo {
export const fetchNetworkInfo = async (
hass: HomeAssistant
): Promise<NetworkInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/network/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<NetworkInfo>>(
"GET",
"hassio/network/info"
)
);
};
): Promise<NetworkInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/network/info",
method: "get",
});
export const updateNetworkInterface = async (
hass: HomeAssistant,
network_interface: string,
options: Partial<NetworkInterface>
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/update`,
method: "post",
data: options,
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<NetworkInfo>>(
"POST",
`hassio/network/interface/${network_interface}/update`,
options
);
await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/update`,
method: "post",
data: options,
timeout: null,
});
};
export const accesspointScan = async (
hass: HomeAssistant,
network_interface: string
): Promise<AccessPoints> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/accesspoints`,
method: "get",
timeout: null,
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<AccessPoints>>(
"GET",
`hassio/network/interface/${network_interface}/accesspoints`
)
);
};
): Promise<AccessPoints> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/accesspoints`,
method: "get",
timeout: null,
});
export const parseAddress = (address: string) => {
const [ip, cidr] = address.split("/");
+6 -19
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant, TranslationDict } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioResolution {
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
@@ -12,19 +9,9 @@ export interface HassioResolution {
export const fetchHassioResolution = async (
hass: HomeAssistant
): Promise<HassioResolution> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/resolution/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioResolution>>(
"GET",
"hassio/resolution/info"
)
);
};
): Promise<HassioResolution> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/resolution/info",
method: "get",
});
+41 -98
View File
@@ -1,8 +1,6 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHomeAssistantInfo {
arch: SupervisorArch;
@@ -77,10 +75,6 @@ export type HassioPanelInfo = PanelInfo<
}
>;
export interface CreateSessionResponse {
session: string;
}
export interface SupervisorOptions {
channel?: "beta" | "dev" | "stable";
diagnostics?: boolean;
@@ -88,99 +82,57 @@ export interface SupervisorOptions {
}
export const reloadSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/reload",
method: "post",
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/reload`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/reload",
method: "post",
});
};
export const restartSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/restart",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/restart`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/restart",
method: "post",
timeout: null,
});
};
export const updateSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/update",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/update`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/update",
method: "post",
timeout: null,
});
};
export const fetchHassioHomeAssistantInfo = async (
hass: HomeAssistant
): Promise<HassioHomeAssistantInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/core/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
"GET",
"hassio/core/info"
)
);
};
): Promise<HassioHomeAssistantInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/core/info",
method: "get",
});
export const fetchHassioSupervisorInfo = async (
hass: HomeAssistant
): Promise<HassioSupervisorInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioSupervisorInfo>>(
"GET",
"hassio/supervisor/info"
)
);
};
): Promise<HassioSupervisorInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/info",
method: "get",
});
export const fetchHassioInfo = async (
hass: HomeAssistant
): Promise<HassioInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info")
);
};
): Promise<HassioInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/info",
method: "get",
});
export const fetchHassioBoots = async (hass: HomeAssistant) =>
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
@@ -263,21 +215,12 @@ export const setSupervisorOption = async (
hass: HomeAssistant,
data: SupervisorOptions
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/options",
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
"hassio/supervisor/options",
data
);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/options",
method: "post",
data,
});
};
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
+15
View File
@@ -21,6 +21,21 @@ export interface HttpConfigState {
revert_at: string | null;
}
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
"server_port",
"server_host",
"ssl_certificate",
"ssl_key",
"ssl_peer_certificate",
"ssl_profile",
"cors_allowed_origins",
"use_x_forwarded_for",
"trusted_proxies",
"use_x_frame_options",
"ip_ban_enabled",
"login_attempts_threshold",
];
export interface SaveHttpConfigResult {
restart: boolean;
}
+14 -15
View File
@@ -325,35 +325,34 @@ export const getCategoryIcons = async <
domain?: string,
force = false
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
const categoryResources = resources[category];
if (!domain) {
if (!force && resources[category].all) {
return resources[category].all as Promise<
Record<string, CategoryType[T]>
>;
if (!force && categoryResources.all) {
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
}
resources[category].all = getHassIcons(connection, category).then((res) => {
resources[category].domains = res.resources as any;
categoryResources.all = getHassIcons(connection, category).then((res) => {
categoryResources.domains = res.resources as any;
return res?.resources as Record<string, CategoryType[T]>;
}) as any;
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
}
if (!force && domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
if (!force && domain in categoryResources.domains) {
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
}
if (resources[category].all && !force) {
await resources[category].all;
if (domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
if (categoryResources.all && !force) {
await categoryResources.all;
if (domain in categoryResources.domains) {
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
}
}
if (!isComponentLoaded(hassConfig, domain)) {
return undefined;
}
const result = getHassIcons(connection, category, domain);
resources[category].domains[domain] = result.then(
categoryResources.domains[domain] = result.then(
(res) => res?.resources[domain]
) as any;
return resources[category].domains[domain] as Promise<CategoryType[T]>;
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
};
export const getServiceIcons = async (
+5
View File
@@ -7,6 +7,11 @@ export interface CustomPanelConfig {
js_url?: string;
module_url?: string;
html_url?: string;
// When true, the panel takes care of the safe-area insets itself (e.g. it
// consumes the `--safe-area-inset-*` variables or draws into the safe area on
// purpose). Home Assistant then skips adding its own safe-area padding around
// the panel to avoid doubling the insets.
handle_safe_area?: boolean;
}
export type CustomPanelInfo<T = Record<string, unknown>> = PanelInfo<
+62 -10
View File
@@ -80,6 +80,7 @@ export type Selector =
| TTSVoiceSelector
| SerialPortSelector
| UiActionSelector
| UiClockDateFormatSelector
| UiColorSelector
| UiStateContentSelector
| UiTimeFormatSelector
@@ -265,6 +266,10 @@ interface EntitySelectorFilter {
unit_of_measurement?: string | readonly string[];
}
interface EntitySelectorEntityFilter extends EntitySelectorFilter {
device?: DeviceSelectorFilter;
}
export interface EntitySelectorExtraOption {
id: string;
primary: string;
@@ -280,7 +285,7 @@ export interface EntitySelector {
multiple?: boolean;
include_entities?: string[];
exclude_entities?: string[];
filter?: EntitySelectorFilter | readonly EntitySelectorFilter[];
filter?: EntitySelectorEntityFilter | readonly EntitySelectorEntityFilter[];
reorder?: boolean;
extra_options?: EntitySelectorExtraOption[];
} | null;
@@ -530,6 +535,10 @@ export interface StringSelector {
placeholder?: string;
autocomplete?: string;
multiple?: true;
// Regular expression the value must match (HTML `pattern`); with `multiple`
// every entry is validated. `validation_message` is shown when it fails.
pattern?: string;
validation_message?: string;
} | null;
}
@@ -577,6 +586,10 @@ export interface UiActionSelector {
} | null;
}
export interface UiClockDateFormatSelector {
ui_clock_date_format: {} | null;
}
export interface UiColorExtraOption {
value: string;
label: string;
@@ -662,7 +675,9 @@ export const expandLabelTarget = (
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
entitySources,
hass.entities,
hass.devices
)
) {
newEntities.push(entity.entity_id);
@@ -728,7 +743,9 @@ export const expandAreaTarget = (
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
entitySources,
hass.entities,
hass.devices
)
) {
newEntities.push(entity.entity_id);
@@ -751,7 +768,9 @@ export const expandDeviceTarget = (
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
entitySources,
hass.entities,
hass.devices
)
) {
newEntities.push(entity.entity_id);
@@ -792,7 +811,9 @@ export const areaMeetsTargetSelector = (
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
entitySources,
hass.entities,
hass.devices
)
) {
return true;
@@ -840,14 +861,22 @@ export const deviceMeetsTargetSelector = (
export const entityMeetsTargetSelector = (
entity: HassEntity | undefined,
targetSelector: TargetSelector,
entitySources?: EntitySources
entitySources?: EntitySources,
entities?: HomeAssistant["entities"],
devices?: HomeAssistant["devices"]
): boolean => {
if (!entity) {
return false;
}
if (targetSelector.target?.entity) {
return ensureArray(targetSelector.target!.entity).some((filterEntity) =>
filterSelectorEntities(filterEntity, entity, entitySources)
filterSelectorEntities(
filterEntity,
entity,
entitySources,
entities,
devices
)
);
}
return true;
@@ -886,9 +915,12 @@ export const filterSelectorDevices = (
};
export const filterSelectorEntities = (
filterEntity: EntitySelectorFilter,
filterEntity: EntitySelectorEntityFilter,
entity: HassEntity,
entitySources?: EntitySources
entitySources?: EntitySources,
entityRegistry?: HomeAssistant["entities"],
devices?: HomeAssistant["devices"],
deviceIntegrationLookup?: Record<string, Set<string>>
): boolean => {
const {
domain: filterDomain,
@@ -896,6 +928,7 @@ export const filterSelectorEntities = (
supported_features: filterSupportedFeature,
unit_of_measurement: filterUnitOfMeasurement,
integration: filterIntegration,
device: filterDevice,
} = filterEntity;
if (filterDomain) {
@@ -942,6 +975,24 @@ export const filterSelectorEntities = (
}
}
if (filterDevice) {
if (!entityRegistry || !devices) {
return false;
}
const deviceId = entityRegistry[entity.entity_id]?.device_id;
if (!deviceId) {
return false;
}
const device = devices[deviceId];
if (!device) {
return false;
}
if (!filterSelectorDevices(filterDevice, device, deviceIntegrationLookup)) {
return false;
}
}
if (
filterIntegration &&
entitySources?.[entity.entity_id]?.domain !== filterIntegration
@@ -1011,7 +1062,7 @@ export const handleLegacyDeviceSelector = (
export const computeCreateDomains = (
selector: EntitySelector | TargetSelector
): undefined | string[] => {
let entityFilters: EntitySelectorFilter[] | undefined;
let entityFilters: EntitySelectorEntityFilter[] | undefined;
if ("target" in selector) {
entityFilters = ensureArray(selector.target?.entity);
@@ -1029,6 +1080,7 @@ export const computeCreateDomains = (
!entityFilter.integration &&
!entityFilter.device_class &&
!entityFilter.supported_features &&
!entityFilter.device &&
entityFilter.domain
? ensureArray(entityFilter.domain).filter((domain) =>
isHelperDomain(domain)
+8 -23
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "../hassio/common";
import { hassioApiResultExtractor } from "../hassio/common";
export interface SupervisorApiCallOptions {
method?: "get" | "post" | "delete";
@@ -13,23 +10,11 @@ export const supervisorApiCall = async <T>(
hass: HomeAssistant,
endpoint: string,
options?: SupervisorApiCallOptions
): Promise<T> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
// Websockets was added in 2021.2.4
return hass.callWS<T>({
type: "supervisor/api",
endpoint,
method: options?.method || "get",
timeout: options?.timeout ?? null,
data: options?.data,
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<T>>(
// @ts-ignore
(options.method || "get").toUpperCase(),
`hassio${endpoint}`,
options?.data
)
);
};
): Promise<T> =>
hass.callWS<T>({
type: "supervisor/api",
endpoint,
method: options?.method || "get",
timeout: options?.timeout ?? null,
data: options?.data,
});
+3 -23
View File
@@ -1,32 +1,12 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "../hassio/common";
export const restartCore = async (hass: HomeAssistant) => {
await hass.callService("homeassistant", "restart");
};
export const updateCore = async (hass: HomeAssistant, backup: boolean) => {
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
await hass.callWS({
type: "hassio/update/core",
backup: backup,
});
return;
}
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/core/update",
method: "post",
timeout: null,
data: { backup },
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update", {
backup,
await hass.callWS({
type: "hassio/update/core",
backup: backup,
});
};
+9 -10
View File
@@ -80,27 +80,26 @@ class StepFlowMenu extends LitElement {
return html`
${description ? html`<div class="content">${description}</div>` : nothing}
<div class="options">
${options.map(
(option) => html`
${options.map((option) => {
const optionDescription = optionDescriptions[option];
return html`
<ha-list-item
hasMeta
.step=${option}
@click=${this._handleStep}
?twoline=${optionDescriptions[option]}
?multiline-secondary=${optionDescriptions[option]}
?twoline=${optionDescription}
?multiline-secondary=${optionDescription}
>
<span>${translations[option]}</span>
${
optionDescriptions[option]
? html`<span slot="secondary">
${optionDescriptions[option]}
</span>`
optionDescription
? html`<span slot="secondary"> ${optionDescription} </span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}
`;
})}
</div>
`;
}
@@ -1,34 +1,27 @@
import { mdiArrowRight } from "@mdi/js";
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumericDuration } from "../../common/datetime/format_duration";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import type { HttpConfig } from "../../data/http";
import { promoteHttpConfig, saveHttpConfig } from "../../data/http";
import {
HTTP_CONFIG_FIELDS,
promoteHttpConfig,
saveHttpConfig,
} from "../../data/http";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../make-dialog-manager";
import type { HttpPendingConfigDialogParams } from "./show-dialog-http-pending-config";
const HTTP_FIELDS: (keyof HttpConfig)[] = [
"server_port",
"server_host",
"ssl_certificate",
"ssl_key",
"ssl_peer_certificate",
"ssl_profile",
"cors_allowed_origins",
"use_x_forwarded_for",
"trusted_proxies",
"use_x_frame_options",
"ip_ban_enabled",
"login_attempts_threshold",
];
@customElement("dialog-http-pending-config")
export class DialogHttpPendingConfig
extends LitElement
@@ -57,6 +50,10 @@ export class DialogHttpPendingConfig
this._error = undefined;
this._reverted = false;
this._startCountdown();
// The field labels live in the config panel fragment, which is not loaded
// yet when this dialog pops up on startup. Load it so the changed-field
// names resolve; the dialog re-renders once hass updates.
this.hass.loadFragmentTranslation("config");
}
public closeDialog(): boolean {
@@ -114,17 +111,44 @@ export class DialogHttpPendingConfig
return [];
}
const { stable, pending } = this._params.state;
return HTTP_FIELDS.filter(
return HTTP_CONFIG_FIELDS.filter(
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
);
}
private _formatValue(key: keyof HttpConfig, value: unknown): string {
if (value === undefined || value === null || value === "") {
return this.hass.localize("ui.dialogs.http_pending_config.not_set");
}
if (typeof value === "boolean") {
return this.hass.localize(value ? "ui.common.yes" : "ui.common.no");
}
if (Array.isArray(value)) {
return value.length
? value.join(", ")
: this.hass.localize("ui.dialogs.http_pending_config.not_set");
}
if (key === "ssl_profile") {
return (
this.hass.localize(
`ui.panel.config.network.http.ssl_profile_${value}` as any
) || String(value)
);
}
return String(value);
}
protected render() {
if (!this._params) {
return nothing;
}
const changes = this._changedFields;
const { stable, pending } = this._params.state;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-dialog
@@ -187,9 +211,25 @@ export class DialogHttpPendingConfig
${changes.map(
(key) => html`
<li>
${this.hass.localize(
`ui.panel.config.network.http.fields.${key}` as any
)}
<span class="field">
${this.hass.localize(
`ui.panel.config.network.http.fields.${key}` as any
)}
</span>
<span class="values">
<span class="old"
>${this._formatValue(key, stable[key])}</span
>
<ha-svg-icon
.path=${mdiArrowRight}
style=${styleMap({
transform: rtl ? "scaleX(-1)" : "",
})}
></ha-svg-icon>
<span class="new"
>${this._formatValue(key, pending![key])}</span
>
</span>
</li>
`
)}
@@ -320,13 +360,37 @@ export class DialogHttpPendingConfig
margin-bottom: var(--ha-space-2);
}
ul {
list-style: none;
margin: 0 0 var(--ha-space-4) 0;
padding-left: var(--ha-space-6);
color: var(--secondary-text-color);
padding: 0;
}
li {
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
li:last-child {
border-bottom: none;
}
.field {
display: block;
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-1);
}
.values {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
color: var(--secondary-text-color);
word-break: break-word;
}
.values .new {
color: var(--primary-text-color);
}
.values ha-svg-icon {
--mdc-icon-size: 18px;
flex-shrink: 0;
}
ha-alert {
display: block;
margin-top: var(--ha-space-4);
+5
View File
@@ -123,6 +123,10 @@ interface EMOutgoingMessageConnectionStatus extends EMMessage {
payload: { event: string };
}
interface EMOutgoingMessageFrontendLoaded extends EMMessage {
type: "frontend/loaded"; // Fired once the launch screen is removed (connected and essential data loaded)
}
interface EMOutgoingMessageAppConfiguration extends EMMessage {
type: "config_screen/show";
}
@@ -201,6 +205,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageBarCodeNotify
| EMOutgoingMessageBarCodeScan
| EMOutgoingMessageConnectionStatus
| EMOutgoingMessageFrontendLoaded
| EMOutgoingMessageExoplayerPlayHLS
| EMOutgoingMessageExoplayerResize
| EMOutgoingMessageExoplayerStop
+29 -11
View File
@@ -33,7 +33,12 @@ import {
TimeZone,
} from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata";
import type { HomeAssistant, Resources, ValuePart } from "../types";
import type {
HomeAssistant,
Resources,
ThemeSettings,
ValuePart,
} from "../types";
import { getLocalLanguage, getTranslation } from "../util/common-translation";
import { demoConfig } from "./demo_config";
import { demoPanels } from "./demo_panels";
@@ -83,7 +88,10 @@ export interface MockHomeAssistant extends HomeAssistant {
loader: () => Promise<unknown>
);
mockEvent(event);
mockTheme(theme: Record<string, string> | null);
mockTheme(
theme: Record<string, string> | null,
selectedTheme?: ThemeSettings
);
formatEntityState(stateObj: HassEntity, state?: string): string;
formatEntityStateToParts(stateObj: HassEntity, state?: string): ValuePart[];
formatEntityAttributeValue(
@@ -503,25 +511,35 @@ export const provideHass = (
},
mockAPI,
mockEvent(event) {
(eventListeners[event] || []).forEach((fn) => fn(event));
(eventListeners[event] || []).forEach((fn) => {
fn(event);
});
},
mockTheme(theme) {
mockTheme(theme, selectedTheme) {
invalidateThemeCache();
selectedTheme ??= {
theme: theme ? "fake-data" : "default",
dark: false,
};
const themeName = selectedTheme.theme;
const darkMode =
selectedTheme.dark ??
matchMedia("(prefers-color-scheme: dark)").matches;
hass().updateHass({
selectedTheme: { theme: theme ? "mock" : "default", dark: false },
selectedTheme,
themes: {
...hass().themes,
themes: {
mock: theme as any,
},
darkMode,
theme: themeName,
themes: theme ? { [themeName]: theme as any } : {},
},
});
const { themes, selectedTheme } = hass();
const { themes } = hass();
applyThemesOnElement(
document.documentElement,
themes,
selectedTheme!.theme,
{ dark: false },
themeName,
{ ...selectedTheme, dark: darkMode },
true
);
},
+1
View File
@@ -120,6 +120,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
this.render = this.renderHass;
this.update = super.update;
removeLaunchScreen();
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
super.update(changedProps);
}
+63 -16
View File
@@ -53,6 +53,11 @@ class HaPanelApp extends LitElement {
@state() private _iframeLoaded = false;
// Set when the addon signals (via subscribe-properties) that it handles the
// safe-area insets itself. We then stop padding the iframe and forward the
// inset values so the addon can draw into the safe area.
@state() private _handleSafeArea = false;
private _enabledKioskMode = false;
private _sessionKeepAlive?: number;
@@ -88,11 +93,13 @@ class HaPanelApp extends LitElement {
public connectedCallback() {
super.connectedCallback();
window.addEventListener("message", this._handleIframeMessage);
window.addEventListener("resize", this._handleResize);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this._handleIframeMessage);
window.removeEventListener("resize", this._handleResize);
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
@@ -135,6 +142,7 @@ class HaPanelApp extends LitElement {
class=${classMap({
loaded: this._iframeLoaded,
"kiosk-mode": this._kioskMode,
"handle-safe-area": this._handleSafeArea,
})}
title=${this._addon.name}
src=${this._addon.ingress_url!}
@@ -179,6 +187,7 @@ class HaPanelApp extends LitElement {
this._enabledKioskMode = false;
}
this._iframeSubscribeUpdates = false;
this._handleSafeArea = false;
this._autoRetryUntil = undefined;
this._fetchData(addon);
}
@@ -416,6 +425,9 @@ class HaPanelApp extends LitElement {
case "home-assistant/subscribe-properties":
this._iframeSubscribeUpdates = true;
// An addon can opt out of the container padding and take care of the
// safe area itself; we then forward the inset values below.
this._handleSafeArea = !!data.handleSafeArea;
this._sendPropertiesToIframe();
if (data.kioskMode && !this.hass.kioskMode) {
this._enabledKioskMode = true;
@@ -425,6 +437,7 @@ class HaPanelApp extends LitElement {
case "home-assistant/unsubscribe-properties":
this._iframeSubscribeUpdates = false;
this._handleSafeArea = false;
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", { enable: false });
this._enabledKioskMode = false;
@@ -433,16 +446,38 @@ class HaPanelApp extends LitElement {
}
};
// Safe-area insets can change on orientation change; keep a subscribing
// addon in sync.
private _handleResize = () => {
if (this._iframeSubscribeUpdates) {
this._sendPropertiesToIframe();
}
};
private _sendPropertiesToIframe() {
if (!this._iframeRef.value?.contentWindow) {
return;
}
const styles = getComputedStyle(this);
this._iframeRef.value.contentWindow.postMessage(
{
type: "home-assistant/properties",
narrow: this.narrow,
route: this._computeRouteTail(this.route),
// Resolved insets so an addon that handles the safe area itself can
// apply them. Vertical uses the raw insets, horizontal the content
// variables (the docked sidebar already absorbs its side).
safeAreaInsets: {
top: styles.getPropertyValue("--safe-area-inset-top").trim(),
right:
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
styles.getPropertyValue("--safe-area-inset-right").trim(),
bottom: styles.getPropertyValue("--safe-area-inset-bottom").trim(),
left:
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
styles.getPropertyValue("--safe-area-inset-left").trim(),
},
},
"*"
);
@@ -462,30 +497,38 @@ class HaPanelApp extends LitElement {
inset: 0;
}
/* Keep the addon iframe clear of the device safe areas. CSS variables don't
cross the iframe boundary, so this padding on the iframe element is the
only way to inset the embedded document. Vertical uses the raw insets;
horizontal uses the content variables, since the docked sidebar already
absorbs the inset on its side (avoids doubling it). */
iframe {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
border: 0;
background-color: var(--primary-background-color);
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease;
padding: var(--safe-area-inset-top)
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
var(--safe-area-inset-bottom)
var(--safe-area-content-inset-left, var(--safe-area-inset-left));
}
iframe.loaded {
opacity: 1;
}
/* The addon takes care of the safe area itself (it receives the insets via
postMessage), so drop the container padding to let it draw full-bleed. */
iframe.handle-safe-area {
padding: 0;
}
/* When the header is shown it already covers the top inset. */
.header + iframe {
height: calc(100% - 40px);
}
:host([narrow]) iframe {
padding-top: var(--safe-area-inset-top);
height: calc(100% - var(--safe-area-inset-top, 0px));
}
:host([narrow]) .header + iframe {
padding-top: 0;
height: calc(100% - 40px - var(--safe-area-inset-top, 0px));
}
@@ -494,8 +537,17 @@ class HaPanelApp extends LitElement {
display: flex;
align-items: center;
font-size: var(--ha-font-size-l);
height: 40px;
padding: 0 16px;
height: calc(40px + var(--safe-area-inset-top, 0px));
padding: var(--safe-area-inset-top)
calc(
16px +
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
)
0
calc(
16px +
var(--safe-area-content-inset-left, var(--safe-area-inset-left))
);
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: var(--ha-font-weight-normal);
@@ -505,11 +557,6 @@ class HaPanelApp extends LitElement {
--mdc-icon-size: 20px;
}
:host([narrow]) .header {
height: calc(40px + var(--safe-area-inset-top, 0px));
padding-top: var(--safe-area-inset-top, 0);
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-condensed);
@@ -321,11 +321,10 @@ export default class HaAutomationAddFromTarget extends LitElement {
const floorAreas = emptyFloors
? undefined
: this._floorAreas.map((floor, index) =>
index === 0 && !floor.id
? this._renderAreas(
entries[floor.id || `floor${TARGET_SEPARATOR}`].areas!
)
: this._floorAreas.map((floor, index) => {
const floorEntry = entries[floor.id || `floor${TARGET_SEPARATOR}`];
return index === 0 && !floor.id
? this._renderAreas(floorEntry.areas!)
: this._renderItem(
!floor.id
? this._i18n.localize(
@@ -335,19 +334,14 @@ export default class HaAutomationAddFromTarget extends LitElement {
floor.id || `floor${TARGET_SEPARATOR}`,
!floor.id,
!!floor.id && this._getSelectedTargetId(value) === floor.id,
!entries[floor.id || `floor${TARGET_SEPARATOR}`].open &&
!!Object.keys(
entries[floor.id || `floor${TARGET_SEPARATOR}`].areas!
).length,
entries[floor.id || `floor${TARGET_SEPARATOR}`].open,
!floorEntry.open && !!Object.keys(floorEntry.areas!).length,
floorEntry.open,
this._renderFloorIcon(floor as FloorNestedComboBoxItem),
entries[floor.id || `floor${TARGET_SEPARATOR}`].open
? this._renderAreas(
entries[floor.id || `floor${TARGET_SEPARATOR}`].areas!
)
floorEntry.open
? this._renderAreas(floorEntry.areas!)
: undefined
)
);
);
});
return html`<ha-section-title
>${this._i18n.localize(
@@ -511,81 +505,69 @@ export default class HaAutomationAddFromTarget extends LitElement {
const items: TemplateResult[] = [];
if (unassignedEntitiesLength) {
const open = entries[`device${TARGET_SEPARATOR}`].open;
const entry = entries[`device${TARGET_SEPARATOR}`];
items.push(
this._renderItem(
this._i18n.localize("ui.components.target-picker.type.entities"),
`device${TARGET_SEPARATOR}`,
true,
false,
!open,
open,
!entry.open,
entry.open,
undefined,
entries[`device${TARGET_SEPARATOR}`].open
? this._renderDomains(
entries[`device${TARGET_SEPARATOR}`].devices!,
"entity_"
)
entry.open
? this._renderDomains(entry.devices!, "entity_")
: undefined
)
);
}
if (unassignedHelpersLength) {
const open = entries[`helper${TARGET_SEPARATOR}`].open;
const entry = entries[`helper${TARGET_SEPARATOR}`];
items.push(
this._renderItem(
this._i18n.localize("ui.panel.config.automation.editor.helpers"),
`helper${TARGET_SEPARATOR}`,
true,
false,
!open,
open,
!entry.open,
entry.open,
undefined,
entries[`helper${TARGET_SEPARATOR}`].open
? this._renderDomains(
entries[`helper${TARGET_SEPARATOR}`].devices!,
"helper_"
)
entry.open
? this._renderDomains(entry.devices!, "helper_")
: undefined
)
);
}
if (unassignedDevicesLength) {
const open = entries[`area${TARGET_SEPARATOR}`].open;
const entry = entries[`area${TARGET_SEPARATOR}`];
items.push(
this._renderItem(
this._i18n.localize("ui.components.target-picker.type.devices"),
`area${TARGET_SEPARATOR}`,
true,
false,
!open,
open,
!entry.open,
entry.open,
undefined,
entries[`area${TARGET_SEPARATOR}`].open
? this._renderDevices(entries[`area${TARGET_SEPARATOR}`].devices!)
: undefined
entry.open ? this._renderDevices(entry.devices!) : undefined
)
);
}
if (unassignedServicesLength) {
const open = entries[`service${TARGET_SEPARATOR}`].open;
const entry = entries[`service${TARGET_SEPARATOR}`];
items.push(
this._renderItem(
this._i18n.localize("ui.panel.config.automation.editor.services"),
`service${TARGET_SEPARATOR}`,
true,
false,
!open,
open,
!entry.open,
entry.open,
undefined,
entries[`service${TARGET_SEPARATOR}`].open
? this._renderDevices(
entries[`service${TARGET_SEPARATOR}`].devices!
)
: undefined
entry.open ? this._renderDevices(entry.devices!) : undefined
)
);
}
@@ -386,9 +386,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._dirtyState?.setState(
{
name: this._name.trim() || null,
icon: this._icon.trim() || null,
entityId: this._entityId.trim(),
name: this._name || null,
icon: this._icon || null,
entityId: this._entityId,
areaId: this._areaId ?? null,
labels: this._labels ?? [],
deviceClass: this._deviceClass,
@@ -214,13 +214,14 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
for (const component of components) {
const componentDomain = component.split(".")[0];
const manifest = manifests[componentDomain];
if (
!entryDomains.has(componentDomain) &&
manifests[componentDomain] &&
!manifests[componentDomain].config_flow &&
(!manifests[componentDomain].integration_type ||
manifest &&
!manifest.config_flow &&
(!manifest.integration_type ||
["device", "hub", "service", "integration"].includes(
manifests[componentDomain].integration_type!
manifest.integration_type!
))
) {
domains.add(componentDomain);
@@ -3,6 +3,10 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
} from "../../../common/string/is_ip_address";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -10,7 +14,11 @@ import "../../../components/ha-card";
import "../../../components/ha-form/ha-form";
import type { HaForm } from "../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../components/ha-form/types";
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
import {
fetchHttpConfig,
HTTP_CONFIG_FIELDS,
saveHttpConfig,
} from "../../../data/http";
import type { HttpConfig } from "../../../data/http";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
@@ -24,10 +32,6 @@ const SCHEMA = memoizeOne(
required: true,
selector: { number: { min: 1, max: 65535, mode: "box" } },
},
{
name: "server_host",
selector: { text: { multiple: true } },
},
{
name: "ssl",
type: "expandable",
@@ -81,7 +85,15 @@ const SCHEMA = memoizeOne(
},
{
name: "trusted_proxies",
selector: { text: { multiple: true } },
selector: {
text: {
multiple: true,
pattern: IP_ADDRESS_OR_NETWORK_PATTERN,
validation_message: localize(
"ui.panel.config.network.http.invalid_network"
),
},
},
},
],
},
@@ -108,6 +120,18 @@ const SCHEMA = memoizeOne(
flatten: true,
title: localize("ui.panel.config.network.http.sections.advanced"),
schema: [
{
name: "server_host",
selector: {
text: {
multiple: true,
pattern: IP_ADDRESS_PATTERN,
validation_message: localize(
"ui.panel.config.network.http.invalid_host"
),
},
},
},
{
name: "cors_allowed_origins",
selector: { text: { multiple: true } },
@@ -165,6 +189,9 @@ class HaConfigHttpForm extends LitElement {
const schema = SCHEMA(this.hass.localize);
const portChanged =
!!this._stable && this._config?.server_port !== this._stable.server_port;
return html`
<ha-card
outlined
@@ -174,6 +201,17 @@ class HaConfigHttpForm extends LitElement {
<p class="description">
${this.hass.localize("ui.panel.config.network.http.description")}
</p>
${
portChanged
? html`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.http.port_warning"
)}
</ha-alert>
`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -318,15 +356,7 @@ class HaConfigHttpForm extends LitElement {
) {
return;
}
// voluptuous formats errors as "<message> @ data['<field>']".
// If a field is identified, mark it inline; otherwise show a card-level
// alert.
const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/);
if (fieldMatch) {
this._fieldErrors = { [fieldMatch[1]]: err.message };
} else {
this._error = err.message;
}
this._handleSaveError(err);
} finally {
this._saving = false;
}
@@ -340,6 +370,34 @@ class HaConfigHttpForm extends LitElement {
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
private _handleSaveError(err: any): void {
const rawMessage =
(typeof err === "string" ? err : err?.message) ||
this.hass.localize("ui.panel.config.network.http.save_error");
// Voluptuous formats validation errors as
// "<reason> @ data['config']['<field>'][<index>]. Got '<value>'"
// Strip the internal data path for display and pick the deepest known
// field name so it can also be flagged inline.
const message =
rawMessage.replace(/\s*@\s*data(\['[^']*'\]|\[\d+\])+/g, "").trim() ||
rawMessage;
const field = [...rawMessage.matchAll(/\['([^']+)'\]/g)]
.map((match) => match[1])
.reverse()
.find((name) => HTTP_CONFIG_FIELDS.includes(name as keyof HttpConfig)) as
keyof HttpConfig | undefined;
if (field) {
// Show a card-level alert too — the field may sit in a collapsed section.
this._error = `${this.hass.localize(
`ui.panel.config.network.http.fields.${field}` as any
)}: ${message}`;
this._fieldErrors = { [field]: message };
} else {
this._error = message;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
+13 -20
View File
@@ -635,16 +635,13 @@ export class HassioNetwork extends LitElement {
const value = source.value as "disabled" | "auto" | "static";
const version = (source as any).version as "ipv4" | "ipv6";
if (
!value ||
!this._interface ||
this._interface[version]!.method === value
) {
const iface = this._interface?.[version];
if (!value || !iface || iface.method === value) {
return;
}
this._dirty = true;
this._interface[version]!.method = value;
iface.method = value;
this.requestUpdate("_interface");
}
@@ -662,7 +659,8 @@ export class HassioNetwork extends LitElement {
const version = (ev.target as any).version as "ipv4" | "ipv6";
const id = source.id;
if (!value || !this._interface?.[version]) {
const iface = this._interface?.[version];
if (!value || !iface) {
source.reportValidity();
return;
}
@@ -670,31 +668,26 @@ export class HassioNetwork extends LitElement {
this._dirty = true;
if (id === "address") {
const index = (ev.target as any).index as number;
const { mask: oldMask } = parseAddress(
this._interface[version].address![index]
);
const { mask: oldMask } = parseAddress(iface.address![index]);
const { mask } = parseAddress(value);
this._interface[version].address![index] = formatAddress(
value,
mask || oldMask || ""
);
iface.address![index] = formatAddress(value, mask || oldMask || "");
this.requestUpdate("_interface");
} else if (id === "netmask") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = formatAddress(ip, value);
const { ip } = parseAddress(iface.address![index]);
iface.address![index] = formatAddress(ip, value);
this.requestUpdate("_interface");
} else if (id === "prefix") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = `${ip}/${value}`;
const { ip } = parseAddress(iface.address![index]);
iface.address![index] = `${ip}/${value}`;
this.requestUpdate("_interface");
} else if (id === "nameserver") {
const index = (ev.target as any).index as number;
this._interface[version].nameservers![index] = value;
iface.nameservers![index] = value;
this.requestUpdate("_interface");
} else {
this._interface[version][id] = value;
iface[id] = value;
}
}
@@ -326,13 +326,11 @@ class DialogSystemInformation extends LitElement {
const keys: TemplateResult[] = [];
for (const key of Object.keys(domainInfo.info)) {
const infoValue = domainInfo.info[key];
let value: unknown;
if (
domainInfo.info[key] &&
typeof domainInfo.info[key] === "object"
) {
const info = domainInfo.info[key] as SystemCheckValueObject;
if (infoValue && typeof infoValue === "object") {
const info = infoValue as SystemCheckValueObject;
if (info.type === "pending") {
value = html` <ha-spinner size="small"></ha-spinner> `;
@@ -363,7 +361,7 @@ class DialogSystemInformation extends LitElement {
);
}
} else {
value = domainInfo.info[key];
value = infoValue;
}
keys.push(html`
@@ -431,10 +429,11 @@ class DialogSystemInformation extends LitElement {
];
for (const key of Object.keys(domainInfo.info)) {
const infoValue = domainInfo.info[key];
let value: unknown;
if (domainInfo.info[key] && typeof domainInfo.info[key] === "object") {
const info = domainInfo.info[key] as SystemCheckValueObject;
if (infoValue && typeof infoValue === "object") {
const info = infoValue as SystemCheckValueObject;
if (info.type === "pending") {
value = "pending";
@@ -448,7 +447,7 @@ class DialogSystemInformation extends LitElement {
);
}
} else {
value = domainInfo.info[key];
value = infoValue;
}
if (first) {
parts.push(`${key} | ${value}\n-- | --`);
+87
View File
@@ -36,6 +36,11 @@ export class HaPanelCustom extends ReactiveElement {
private _wasDisconnected = false;
// Set for embedded-iframe panels that opt out of the container padding via
// `handle_safe_area`; we then inject the resolved insets into the (same
// origin) iframe document so the panel can consume `--safe-area-inset-*`.
private _syncSafeArea = false;
protected createRenderRoot() {
return this;
}
@@ -54,10 +59,14 @@ export class HaPanelCustom extends ReactiveElement {
});
this._setProperties = setProperties;
this.querySelector("iframe")?.classList.add("loaded");
// registerIframe also fires on the iframe's `pageshow`, so this re-applies
// the insets after an internal reload.
this._syncSafeAreaToIframe();
}
public connectedCallback() {
super.connectedCallback();
window.addEventListener("resize", this._handleResize);
// Only rebuild when reattached after a real disconnect (the 5-minute
// suspendWhenHidden timer in partial-panel-resolver). On first mount,
// update() handles creation via the panel-changed branch, so calling
@@ -70,10 +79,51 @@ export class HaPanelCustom extends ReactiveElement {
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
this._wasDisconnected = true;
this._cleanupPanel();
}
// Safe-area insets can change on orientation change; keep the embedded
// document in sync.
private _handleResize = () => {
this._syncSafeAreaToIframe();
};
private _syncSafeAreaToIframe() {
if (!this._syncSafeArea) {
return;
}
const root =
this.querySelector("iframe")?.contentWindow?.document?.documentElement;
if (!root) {
return;
}
// CSS variables don't cross the iframe boundary, so copy the resolved
// values onto the (same-origin) iframe document. Vertical uses the raw
// insets, horizontal the content variables (the sidebar already absorbs
// its side).
const styles = getComputedStyle(this);
root.style.setProperty(
"--safe-area-inset-top",
styles.getPropertyValue("--safe-area-inset-top").trim()
);
root.style.setProperty(
"--safe-area-inset-bottom",
styles.getPropertyValue("--safe-area-inset-bottom").trim()
);
root.style.setProperty(
"--safe-area-inset-left",
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
styles.getPropertyValue("--safe-area-inset-left").trim()
);
root.style.setProperty(
"--safe-area-inset-right",
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
styles.getPropertyValue("--safe-area-inset-right").trim()
);
}
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
if (changedProps.has("panel")) {
@@ -101,6 +151,7 @@ export class HaPanelCustom extends ReactiveElement {
private _cleanupPanel() {
delete window.customPanel;
this._setProperties = undefined;
this._syncSafeArea = false;
while (this.lastChild) {
this.removeChild(this.lastChild);
}
@@ -112,6 +163,30 @@ export class HaPanelCustom extends ReactiveElement {
const config = panel.config!._panel_custom;
const panelUrl = getUrl(config);
// Keep the panel content clear of the device safe areas (status bar, home
// indicator, notch). Panels rendered in light DOM inherit the
// `--safe-area-inset-*` variables but most don't consume them, so we apply
// the padding on the container as a safe baseline. The embedded-iframe
// branch below applies the equivalent padding on the iframe instead. Panels
// that manage the safe area themselves can opt out via `handle_safe_area`.
const applySafeArea = !config.handle_safe_area;
// For opted-out embedded-iframe panels we inject the insets into the iframe
// document instead of padding it (see _syncSafeAreaToIframe).
this._syncSafeArea = !applySafeArea && !!config.embed_iframe;
if (applySafeArea && !config.embed_iframe) {
this.style.display = "block";
this.style.boxSizing = "border-box";
// Vertical insets aren't absorbed by any chrome around the panel, so use
// the raw insets. Horizontal uses the (physical) content variables, since
// the sidebar already absorbs the inset on its side (avoids doubling it).
this.style.paddingTop = "var(--safe-area-inset-top)";
this.style.paddingBottom = "var(--safe-area-inset-bottom)";
this.style.paddingLeft =
"var(--safe-area-content-inset-left, var(--safe-area-inset-left))";
this.style.paddingRight =
"var(--safe-area-content-inset-right, var(--safe-area-inset-right))";
}
const tempA = document.createElement("a");
tempA.href = panelUrl.url;
@@ -168,14 +243,26 @@ export class HaPanelCustom extends ReactiveElement {
window.customPanel = this;
const titleAttr = this.panel.title ? `title="${this.panel.title}"` : "";
// Pad the iframe (not the host) with the safe-area insets so the embedded
// document stays clear of the device safe areas. CSS variables don't cross
// the iframe boundary, so this container padding is the baseline; panels
// that handle it themselves opt out via `handle_safe_area`.
const safeAreaPadding = applySafeArea
? `padding: var(--safe-area-inset-top)
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
var(--safe-area-inset-bottom)
var(--safe-area-content-inset-left, var(--safe-area-inset-left));`
: "";
this.innerHTML = `
<style>
iframe {
border: none;
box-sizing: border-box;
width: 100%;
height: 100vh;
height: 100dvh;
display: block;
${safeAreaPadding}
background-color: var(--primary-background-color);
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease;
+18 -27
View File
@@ -49,8 +49,8 @@ import { resolveEntityIDs } from "../../data/selector";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { fileDownload } from "../../util/file_download";
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
import { csvSafeString, csvDownload } from "../../util/csv";
@customElement("ha-panel-history")
class HaPanelHistory extends LitElement {
@@ -128,19 +128,13 @@ class HaPanelHistory extends LitElement {
<h1 class="page-title" slot="title">
${this.hass.localize("panel.history")}
</h1>
${
entitiesSelected
? html`
<ha-icon-button
slot="actionItems"
@click=${this._removeAll}
.disabled=${this._isLoading}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.panel.history.remove_all")}
></ha-icon-button>
`
: ""
}
<ha-icon-button
slot="actionItems"
@click=${this._removeAll}
.disabled=${this._isLoading || !entitiesSelected}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.panel.history.remove_all")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
@@ -449,8 +443,8 @@ class HaPanelHistory extends LitElement {
return;
}
const csv: string[] = [""]; // headers will be replaced later.
const headers = ["entity_id", "state", "last_changed"];
const csv: string[][] = [[]]; // headers will be replaced later.
const processedDomainAttributes = new Set<string>();
const domainAttributes: Record<string, Record<string, number>> = {
climate: {
@@ -492,7 +486,7 @@ class HaPanelHistory extends LitElement {
if (entity.statistics) {
for (const s of entity.statistics) {
csv.push(`${entityId},${s.state},${formatDate(s.last_changed)}\n`);
csv.push([entityId, s.state, formatDate(s.last_changed)]);
}
}
@@ -509,25 +503,22 @@ class HaPanelHistory extends LitElement {
}
}
csv.push(data.join(",") + "\n");
csv.push(data);
}
}
}
for (const timeline of this._mungedStateHistory.timeline) {
const entityId = timeline.entity_id;
for (const s of timeline.data) {
const safeState = /,|"/.test(s.state)
? `"${s.state.replaceAll('"', '""')}"`
: s.state;
csv.push(`${entityId},${safeState},${formatDate(s.last_changed)}\n`);
csv.push([
entityId,
csvSafeString(s.state),
formatDate(s.last_changed),
]);
}
}
csv[0] = headers.join(",") + "\n";
const blob = new Blob(csv, {
type: "text/csv",
});
const url = window.URL.createObjectURL(blob);
fileDownload(url, "history.csv");
csv[0] = headers;
csvDownload(csv, "history.csv");
}
private _suggestCard() {
+4 -1
View File
@@ -49,10 +49,13 @@ class HaPanelIframe extends LitElement {
}
static styles = css`
/* Fill hass-subpage's content box, which already excludes the safe-area
insets (see hass-subpage .content), instead of positioning absolutely
and spilling into the bottom/side insets. */
iframe {
border: 0;
display: block;
width: 100%;
position: absolute;
height: 100%;
background-color: var(--primary-background-color);
}
+4
View File
@@ -100,6 +100,10 @@ export class HaLogbook extends LitElement {
private _readyListenerAttached = false;
public getEntries(): LogbookEntry[] {
return this._logbookEntries || [];
}
protected render() {
if (!isComponentLoaded(this.hass.config, "logbook")) {
return nothing;
+130 -12
View File
@@ -1,9 +1,15 @@
import { mdiRefresh } from "@mdi/js";
import {
mdiDotsVertical,
mdiDownload,
mdiFilterRemove,
mdiRefresh,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fromUnixTime } from "date-fns";
import { storage } from "../../common/decorators/storage";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -16,7 +22,11 @@ import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
@@ -26,6 +36,13 @@ import { resolveEntityIDs } from "../../data/selector";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { csvDownload, csvSafeString } from "../../util/csv";
interface LogbookState {
time: { range: [Date, Date] };
targetPickerValue: HassServiceTarget;
}
@customElement("ha-panel-logbook")
export class HaPanelLogbook extends LitElement {
@@ -54,14 +71,7 @@ export class HaPanelLogbook extends LitElement {
public constructor() {
super();
const start = new Date();
start.setHours(start.getHours() - 1, 0, 0, 0);
const end = new Date();
end.setHours(end.getHours() + 2, 0, 0, 0);
this._time = { range: [start, end] };
this._time = this._defaultState.time;
}
protected render() {
@@ -73,11 +83,30 @@ export class HaPanelLogbook extends LitElement {
<div slot="title">${this.hass.localize("panel.logbook")}</div>
<ha-icon-button
slot="actionItems"
@click=${this._refreshLogbook}
.path=${mdiRefresh}
.label=${this.hass!.localize("ui.common.refresh")}
@click=${this._resetLogbook}
.disabled=${this._isDefaultState()}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.common.reset")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="refresh">
${this.hass.localize("ui.common.refresh")}
<ha-svg-icon slot="icon" .path=${mdiRefresh}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="download">
${this.hass.localize("ui.panel.logbook.download_data")}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<div class="filters">
<ha-date-range-picker
@@ -230,10 +259,99 @@ export class HaPanelLogbook extends LitElement {
);
}
private get _defaultState(): LogbookState {
const start = new Date();
start.setHours(start.getHours() - 1, 0, 0, 0);
const end = new Date();
end.setHours(end.getHours() + 2, 0, 0, 0);
return {
time: { range: [start, end] },
targetPickerValue: {},
};
}
private _isDefaultState(): boolean {
return deepEqual(
{ time: this._time, targetPickerValue: this._targetPickerValue },
this._defaultState
);
}
private _resetLogbook() {
const defaultState = this._defaultState;
this._time = defaultState.time;
this._targetPickerValue = defaultState.targetPickerValue;
this._storedTargetPickerValue = undefined;
navigate("/logbook", { replace: true });
}
private _refreshLogbook() {
this.shadowRoot!.querySelector("ha-logbook")?.refresh();
}
private async _handleMenuAction(ev: HaDropdownSelectEvent) {
const action = ev.detail.item.value;
switch (action) {
case "download":
this._downloadData();
break;
case "refresh":
this._refreshLogbook();
break;
}
}
private _downloadData() {
const data =
this.shadowRoot!.querySelector("ha-logbook")?.getEntries() || [];
if (data.length === 0) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.logbook.download_data_error"),
text: this.hass.localize("ui.panel.logbook.error_no_data"),
warning: true,
});
return;
}
const headers = [
"time",
"entity_id",
"state",
"event_type",
"context_id",
"context_user_id",
"context_event_type",
"context_domain",
"context_service",
"context_entity_id",
"context_state",
"context_source",
];
const csv: string[][] = [headers];
for (const d of data) {
const time = fromUnixTime(d.when).toISOString();
csv.push([
time,
d.entity_id || "",
csvSafeString(d.state),
csvSafeString(d.attributes?.event_type),
d.context_id || "",
d.context_user_id || "",
csvSafeString(d.context_event_type),
d.context_domain || "",
d.context_service || "",
d.context_entity_id || "",
csvSafeString(d.context_state),
d.context_source || "",
]);
}
csvDownload(csv, "activity.csv");
}
static get styles() {
return [
haStyle,
@@ -0,0 +1,243 @@
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig, ClockCardDatePart } from "../types";
type ClockCardSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
type ClockCardValuePart = Exclude<ClockCardDatePart, ClockCardSeparatorPart>;
/**
* Normalized date configuration used by clock card renderers.
*/
interface ClockCardDateConfig {
parts: ClockCardDatePart[];
}
/**
* Resolves the locale and time zone for a clock card from `hass` and the
* card's configuration. Applies the optional `time_format` override to the
* locale and falls back to the user's preferred time zone.
*/
export const resolveClockCardLocale = (
hass: HomeAssistant,
config: Pick<ClockCardConfig, "time_format" | "time_zone">
): { locale: HomeAssistant["locale"]; timeZone: string } => {
const locale = config.time_format
? { ...hass.locale, time_format: config.time_format }
: hass.locale;
const timeZone =
config.time_zone ||
resolveTimeZone(locale.time_zone, hass.config?.time_zone);
return { locale, timeZone };
};
/**
* All selectable date tokens exposed by the clock card editor.
*/
export const CLOCK_CARD_DATE_PARTS: readonly ClockCardDatePart[] = [
"weekday-short",
"weekday-long",
"day-numeric",
"day-2-digit",
"month-short",
"month-long",
"month-numeric",
"month-2-digit",
"year-2-digit",
"year-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
];
const DATE_PART_OPTIONS: Record<
ClockCardValuePart,
Pick<Intl.DateTimeFormatOptions, "weekday" | "day" | "month" | "year">
> = {
"weekday-short": { weekday: "short" },
"weekday-long": { weekday: "long" },
"day-numeric": { day: "numeric" },
"day-2-digit": { day: "2-digit" },
"month-short": { month: "short" },
"month-long": { month: "long" },
"month-numeric": { month: "numeric" },
"month-2-digit": { month: "2-digit" },
"year-2-digit": { year: "2-digit" },
"year-numeric": { year: "numeric" },
};
const DATE_SEPARATORS: Record<ClockCardSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "\n",
};
const DATE_SEPARATOR_PARTS = new Set<ClockCardSeparatorPart>([
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
]);
const DATE_PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
const isClockCardDatePart = (value: string): value is ClockCardDatePart =>
CLOCK_CARD_DATE_PARTS.includes(value as ClockCardDatePart);
const isDateSeparatorPart = (
part: ClockCardDatePart
): part is ClockCardSeparatorPart =>
DATE_SEPARATOR_PARTS.has(part as ClockCardSeparatorPart);
/**
* Returns a reusable formatter for a specific date token.
*/
const getDatePartFormatter = (
part: ClockCardValuePart,
language: string,
timeZone?: string
): Intl.DateTimeFormat => {
const cacheKey = `${language}|${timeZone || ""}|${part}`;
const cached = DATE_PART_FORMATTERS.get(cacheKey);
if (cached) {
return cached;
}
const formatter = new Intl.DateTimeFormat(language, {
...DATE_PART_OPTIONS[part],
...(timeZone ? { timeZone } : {}),
});
DATE_PART_FORMATTERS.set(cacheKey, formatter);
return formatter;
};
const formatDatePart = (
part: ClockCardValuePart,
date: Date,
language: string,
timeZone?: string
) => getDatePartFormatter(part, language, timeZone).format(date);
/**
* Applies a single date token to Intl.DateTimeFormat options.
*/
const applyDatePartOption = (
options: Intl.DateTimeFormatOptions,
part: ClockCardDatePart
) => {
if (isDateSeparatorPart(part)) {
return;
}
const partOptions = DATE_PART_OPTIONS[part];
if (partOptions.weekday) {
options.weekday = partOptions.weekday;
}
if (partOptions.day) {
options.day = partOptions.day;
}
if (partOptions.month) {
options.month = partOptions.month;
}
if (partOptions.year) {
options.year = partOptions.year;
}
};
/**
* Sanitizes configured date tokens while preserving their literal order.
*/
const normalizeDateParts = (
parts: ClockCardConfig["date_format"]
): ClockCardDatePart[] =>
parts?.filter((part): part is ClockCardDatePart =>
isClockCardDatePart(part)
) || [];
/**
* Returns a normalized date config from a card configuration object.
*/
export const getClockCardDateConfig = (
config?: Pick<ClockCardConfig, "date_format">
): ClockCardDateConfig => ({
parts: normalizeDateParts(config?.date_format),
});
/**
* Checks whether the clock configuration resolves to any visible date output.
*/
export const hasClockCardDate = (
config?: Pick<ClockCardConfig, "date_format">
): boolean => getClockCardDateConfig(config).parts.length > 0;
/**
* Converts normalized date tokens into Intl.DateTimeFormat options.
*
* Separator tokens are ignored. If multiple tokens target the same Intl field,
* the last one wins.
*/
export const getClockCardDateTimeFormatOptions = (
dateConfig: ClockCardDateConfig
): Intl.DateTimeFormatOptions => {
const options: Intl.DateTimeFormatOptions = {};
dateConfig.parts.forEach((part) => {
applyDatePartOption(options, part);
});
return options;
};
/**
* Builds the final date string from literal date tokens.
*
* Value tokens are localized through Intl.DateTimeFormat. Separator tokens are
* always rendered literally. A default space is only inserted between adjacent
* value tokens.
*/
export const formatClockCardDate = (
date: Date,
dateConfig: ClockCardDateConfig,
language: string,
timeZone?: string
): string => {
let result = "";
let previousRenderedPartWasValue = false;
dateConfig.parts.forEach((part) => {
if (isDateSeparatorPart(part)) {
result += DATE_SEPARATORS[part];
previousRenderedPartWasValue = false;
return;
}
const value = formatDatePart(part, date, language, timeZone);
if (!value) {
return;
}
if (previousRenderedPartWasValue) {
result += " ";
}
result += value;
previousRenderedPartWasValue = true;
});
return result;
};
@@ -2,9 +2,17 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import "../../../../components/ha-marquee-text";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig } from "../types";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
resolveClockCardLocale,
} from "./clock-date-format";
function romanize12HourClock(num: number) {
const numerals = [
@@ -26,6 +34,11 @@ function romanize12HourClock(num: number) {
return numerals[num];
}
const DATE_UPDATE_INTERVAL = 60_000;
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
@customElement("hui-clock-card-analog")
export class HuiClockCardAnalog extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -40,42 +53,18 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
private _initDate() {
if (!this.config || !this.hass) {
return;
}
@state() private _date?: string;
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
private _dateInterval?: number;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
private _timeZone?: string;
this._computeOffsets();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
private _language?: string;
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
this._initDate();
}
public disconnectedCallback() {
@@ -84,18 +73,80 @@ export class HuiClockCardAnalog extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
this._stopDateTick();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
this._updateDate();
}
};
private _initDate() {
if (!this.config || !this.hass) {
this._stopDateTick();
this._date = undefined;
return;
}
const { timeZone } = resolveClockCardLocale(this.hass, this.config);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone,
});
this._computeOffsets();
this._updateDate();
if (this.isConnected && hasClockCardDate(this.config)) {
this._startDateTick();
} else {
this._stopDateTick();
}
}
private _startDateTick() {
this._stopDateTick();
this._dateInterval = window.setInterval(
() => this._updateDate(),
DATE_UPDATE_INTERVAL
);
}
private _stopDateTick() {
if (this._dateInterval) {
clearInterval(this._dateInterval);
this._dateInterval = undefined;
}
}
private _computeOffsets() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
const hourStr = parts.find((p) => p.type === "hour")?.value;
const minuteStr = parts.find((p) => p.type === "minute")?.value;
const secondStr = parts.find((p) => p.type === "second")?.value;
@@ -103,7 +154,7 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = new Date().getMilliseconds();
const ms = date.getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -113,16 +164,45 @@ export class HuiClockCardAnalog extends LitElement {
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
}
private _updateDate() {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
new Date(),
dateConfig,
this._language,
this._timeZone
);
}
private _computeClock = memoizeOne((config: ClockCardConfig) => {
const faceParts = config.face_style?.split("_");
const dateConfig = getClockCardDateConfig(config);
const showDate = hasClockCardDate(config);
const isLongDate =
dateConfig.parts.includes("month-long") ||
dateConfig.parts.includes("weekday-long");
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
showDate,
isLongDate,
};
});
render() {
if (!this.config) return nothing;
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const isNumbers = this.config?.face_style?.startsWith("numbers");
const isRoman = this.config?.face_style?.startsWith("roman");
const isUpright = this.config?.face_style?.endsWith("upright");
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate, showDate } =
this._computeClock(this.config);
const dateLines = this._date?.split("\n") ?? [];
const indicator = (number?: number) => html`
<div
@@ -168,14 +248,14 @@ export class HuiClockCardAnalog extends LitElement {
>
${
this.config.ticks === "quarter"
? Array.from({ length: 4 }, (_, i) => i).map(
? QUARTER_TICKS.map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 90}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -183,28 +263,30 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? Array.from({ length: 12 }, (_, i) => i).map(
? HOUR_TICKS.map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 30}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? Array.from({ length: 60 }, (_, i) => i).map(
? MINUTE_TICKS.map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${`--tick-rotation: ${i * 6}deg;`}
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
>
${
i % 5 === 0
@@ -216,14 +298,43 @@ export class HuiClockCardAnalog extends LitElement {
)
: nothing
}
${
showDate
? html`<div
class=${classMap({
date: true,
[sizeClass]: true,
"long-date": isLongDate,
})}
>
${dateLines.map(
(line) => html`
<ha-marquee-text
class="date-line"
speed="5"
pause-duration="1500"
pause-on-hover
>
${line}
</ha-marquee-text>
`
)}
</div>`
: nothing
}
<div class="center-dot"></div>
<div
class="hand hour"
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
></div>
<div
class="hand minute"
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
></div>
${
this.config.show_seconds
@@ -233,11 +344,13 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
style=${styleMap({
"animation-delay": `-${
this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)
}s`,
})}
></div>`
: nothing
}
@@ -417,6 +530,42 @@ export class HuiClockCardAnalog extends LitElement {
transform: translate(-50%, 0) rotate(360deg);
}
}
.date {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-align: center;
opacity: 0.8;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.date-line {
width: 100%;
}
.date.long-date:not(.size-medium):not(.size-large) {
top: 66%;
font-size: var(--ha-font-size-xs);
line-height: 1;
width: 45%;
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-xl);
}
`;
}
@@ -1,10 +1,16 @@
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-marquee-text";
import type { ClockCardConfig } from "../types";
import type { HomeAssistant } from "../../../../types";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
resolveClockCardLocale,
} from "./clock-date-format";
const INTERVAL = 1000;
@@ -24,37 +30,50 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _lastDateMinute?: string;
private _timeZone?: string;
private _language?: string;
private _initDate() {
if (!this.config || !this.hass) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
let locale = this.hass?.locale;
if (this.config?.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
const { locale, timeZone } = resolveClockCardLocale(this.hass, this.config);
const h12 = useAmPm(locale);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: h12 ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: h12 ? "h12" : "h23",
timeZone:
this.config?.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
timeZone,
});
this._lastDateMinute = undefined;
this._tick();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("hass")) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass");
if (!oldHass || oldHass.locale !== this.hass?.locale) {
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
@@ -71,6 +90,7 @@ export class HuiClockCardDigital extends LitElement {
}
private _startTick() {
this._stopTick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
this._tick();
}
@@ -85,7 +105,8 @@ export class HuiClockCardDigital extends LitElement {
private _tick() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
this._timeHour = parts.find((part) => part.type === "hour")?.value;
this._timeMinute = parts.find((part) => part.type === "minute")?.value;
@@ -93,6 +114,33 @@ export class HuiClockCardDigital extends LitElement {
? parts.find((part) => part.type === "second")?.value
: undefined;
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
this._updateDate(date);
}
private _updateDate(date: Date) {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
if (
this._timeMinute !== undefined &&
this._timeMinute === this._lastDateMinute &&
this._date !== undefined
) {
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
date,
dateConfig,
this._language,
this._timeZone
);
this._lastDateMinute = this._timeMinute;
}
render() {
@@ -101,28 +149,65 @@ export class HuiClockCardDigital extends LitElement {
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const showDate = hasClockCardDate(this.config);
return html`
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${
this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing
}
${
this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing
}
<div class="clock-container">
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${
this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing
}
${
this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing
}
</div>
</div>
${
showDate
? html`<div class="date-container">
<div class="date ${sizeClass}">
${this._date
?.split("\n")
.map(
(line) => html`
<ha-marquee-text
class="date-line"
speed="10"
pause-duration="1500"
pause-on-hover
>
${line}
</ha-marquee-text>
`
)}
</div>
</div>`
: nothing
}
`;
}
static styles = css`
:host {
display: block;
width: 100%;
}
.clock-container {
width: 100%;
display: flex;
justify-content: center;
}
.date-container {
width: 100%;
margin-top: var(--ha-space-1);
}
.time-parts {
@@ -192,6 +277,29 @@ export class HuiClockCardDigital extends LitElement {
content: ":";
margin: 0 2px;
}
.date {
margin-inline: auto;
text-align: center;
opacity: 0.8;
font-size: var(--ha-font-size-s);
line-height: 1.1;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.date-line {
width: 100%;
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-2xl);
}
`;
}
@@ -226,21 +226,24 @@ export function generatePowerSourcesGraphData(
// Draw in reverse order so 0 value lines are overwritten
["solar", "battery", "grid"].forEach((key, i) => {
if (seriesData[key]) {
pushSeries(key, seriesData[key].positive, "positive", 3 - i);
const series = seriesData[key];
if (series) {
pushSeries(key, series.positive, "positive", 3 - i);
}
});
// Draw in reverse order but above positive series
["battery", "grid"].forEach((key, i) => {
if (seriesData[key]) {
pushSeries(key, seriesData[key].negative, "negative", 4 - i);
const series = seriesData[key];
if (series) {
pushSeries(key, series.negative, "negative", 4 - i);
}
});
Object.keys(statIds).forEach((key) => {
if (seriesData[key]) {
const { colorHex, rgb } = seriesData[key];
const series = seriesData[key];
if (series) {
const { colorHex, rgb } = series;
legendData!.push({
id: key,
+2 -26
View File
@@ -23,6 +23,7 @@ import type {
LovelaceGridOptions,
LovelaceHeaderFooter,
} from "../types";
import { migrateEntitiesCardConfig } from "./migrate-card-config";
import type { EntitiesCardConfig } from "./types";
import { haStyleScrollbar } from "../../../resources/styles";
@@ -49,32 +50,7 @@ export const computeShowHeaderToggle = <
return !!config.show_header_toggle;
};
export const migrateEntitiesCardConfig = (
config: EntitiesCardConfig
): EntitiesCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: (rest as EntityConfig).time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (LovelaceRowConfig | string)[],
};
};
export { migrateEntitiesCardConfig };
@customElement("hui-entities-card")
class HuiEntitiesCard extends LitElement implements LovelaceCard {
+2 -26
View File
@@ -31,35 +31,11 @@ import "../components/hui-timestamp-display";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import "../components/hui-warning-element";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import { migrateGlanceCardConfig } from "./migrate-card-config";
import type { GlanceCardConfig, GlanceConfigEntity } from "./types";
import { TIMESTAMP_STATE_DOMAINS } from "../../../common/const";
export const migrateGlanceCardConfig = (
config: GlanceCardConfig
): GlanceCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: rest.time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (GlanceConfigEntity | string)[],
};
};
export { migrateGlanceCardConfig };
@customElement("hui-glance-card")
export class HuiGlanceCard extends LitElement implements LovelaceCard {
@@ -0,0 +1,66 @@
import type { EntityConfig, LovelaceRowConfig } from "../entity-rows/types";
import type {
EntitiesCardConfig,
GlanceCardConfig,
GlanceConfigEntity,
} from "./types";
export const migrateEntitiesCardConfig = (
config: EntitiesCardConfig
): EntitiesCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
// Custom rows own their config schema and may use `format` with a
// different meaning (e.g. custom:multiple-entity-row), so leave it
// untouched.
if (e.type?.startsWith("custom:")) {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: (rest as EntityConfig).time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (LovelaceRowConfig | string)[],
};
};
export const migrateGlanceCardConfig = (
config: GlanceCardConfig
): GlanceCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: rest.time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (GlanceConfigEntity | string)[],
};
};
+17
View File
@@ -445,12 +445,29 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date_format?: ClockCardDatePart[];
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";
face_style?: "markers" | "numbers_upright" | "roman";
}
export type ClockCardDatePart =
| "weekday-short"
| "weekday-long"
| "day-numeric"
| "day-2-digit"
| "month-short"
| "month-long"
| "month-numeric"
| "month-2-digit"
| "year-2-digit"
| "year-numeric"
| "separator-dash"
| "separator-slash"
| "separator-dot"
| "separator-new-line";
export interface MediaControlCardConfig extends LovelaceCardConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
@@ -250,18 +250,14 @@ export class HuiBadgePicker extends LitElement {
const usedEntities = computeUsedEntities(this.lovelace);
const unusedEntities = calcUnusedEntities(this.hass, usedEntities);
this._usedEntities = [...usedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
this._unusedEntities = [...unusedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
const isAvailable = (eid: string) => {
const stateObj = this.hass!.states[eid];
return (
stateObj && stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
};
this._usedEntities = [...usedEntities].filter(isAvailable);
this._unusedEntities = [...unusedEntities].filter(isAvailable);
this._loadBages();
}
@@ -274,18 +274,14 @@ export class HuiCardPicker extends LitElement {
const usedEntities = computeUsedEntities(this.lovelace);
const unusedEntities = calcUnusedEntities(this.hass, usedEntities);
this._usedEntities = [...usedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
this._unusedEntities = [...unusedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
const isAvailable = (eid: string) => {
const stateObj = this.hass!.states[eid];
return (
stateObj && stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
};
this._usedEntities = [...usedEntities].filter(isAvailable);
this._unusedEntities = [...unusedEntities].filter(isAvailable);
this._loadCards();
}
@@ -2,16 +2,15 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
assign,
boolean,
defaulted,
enums,
literal,
object,
optional,
string,
union,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
@@ -19,58 +18,47 @@ import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { ClockCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { TimeFormat } from "../../../../data/translation";
import { getTimezoneOptions } from "../../../../components/ha-timezone-picker";
import {
CLOCK_CARD_DATE_PARTS,
getClockCardDateConfig,
} from "../../cards/clock/clock-date-format";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
title: optional(string()),
clock_style: optional(union([literal("digital"), literal("analog")])),
clock_size: optional(
union([literal("small"), literal("medium"), literal("large")])
),
clock_style: optional(enums(["digital", "analog"])),
clock_size: optional(enums(["small", "medium", "large"])),
time_format: optional(enums(Object.values(TimeFormat))),
time_zone: optional(enums(getTimezoneOptions().map((option) => option.id))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
date_format: optional(defaulted(array(enums(CLOCK_CARD_DATE_PARTS)), [])),
// Analog clock options
border: optional(defaulted(boolean(), false)),
ticks: optional(
defaulted(
union([
literal("none"),
literal("quarter"),
literal("hour"),
literal("minute"),
]),
literal("hour")
)
defaulted(enums(["none", "quarter", "hour", "minute"]), "hour")
),
seconds_motion: optional(
defaulted(
union([literal("continuous"), literal("tick")]),
literal("continuous")
)
defaulted(enums(["continuous", "tick"]), "continuous")
),
face_style: optional(
defaulted(
union([
literal("markers"),
literal("numbers_upright"),
literal("roman"),
]),
literal("markers")
)
defaulted(enums(["markers", "numbers_upright", "roman"]), "markers")
),
})
);
type ClockCardFormData = Omit<ClockCardConfig, "time_format"> & {
time_format?: ClockCardConfig["time_format"] | "auto";
};
@customElement("hui-clock-card-editor")
export class HuiClockCardEditor
extends LitElement
@@ -93,7 +81,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "dropdown",
mode: "box",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -119,6 +107,13 @@ export class HuiClockCardEditor
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
{
name: "date_format",
required: false,
selector: {
ui_clock_date_format: {},
},
},
...(clockStyle === "digital"
? ([
{
@@ -241,18 +236,28 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config) => ({
clock_style: "digital",
clock_size: "small",
time_format: "auto",
show_seconds: false,
no_background: false,
// Analog clock options
border: false,
ticks: "hour",
face_style: "markers",
...config,
}));
private _data = memoizeOne((config: ClockCardConfig): ClockCardFormData => {
const dateConfig = getClockCardDateConfig(config);
const data: ClockCardFormData = {
...config,
clock_style: config.clock_style ?? "digital",
clock_size: config.clock_size ?? "small",
time_format: config.time_format ?? "auto",
show_seconds: config.show_seconds ?? false,
no_background: config.no_background ?? false,
// Analog clock options
border: config.border ?? false,
ticks: config.ticks ?? "hour",
face_style: config.face_style ?? "markers",
};
if (config.date_format === undefined) {
data.date_format = dateConfig.parts;
}
return data;
});
public setConfig(config: ClockCardConfig): void {
assert(config, cardConfigStruct);
@@ -281,35 +286,40 @@ export class HuiClockCardEditor
`;
}
private _valueChanged(ev: CustomEvent): void {
if (ev.detail.value.time_format === "auto") {
delete ev.detail.value.time_format;
private _valueChanged(ev: ValueChangedEvent<ClockCardFormData>): void {
const config = ev.detail.value;
if (!config.date_format || config.date_format.length === 0) {
delete config.date_format;
}
if (ev.detail.value.clock_style === "analog") {
ev.detail.value.border = ev.detail.value.border ?? false;
ev.detail.value.ticks = ev.detail.value.ticks ?? "hour";
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (ev.detail.value.show_seconds) {
ev.detail.value.seconds_motion =
ev.detail.value.seconds_motion ?? "continuous";
if (config.time_format === "auto") {
delete config.time_format;
}
if (config.clock_style === "analog") {
config.border = config.border ?? false;
config.ticks = config.ticks ?? "hour";
config.face_style = config.face_style ?? "markers";
if (config.show_seconds) {
config.seconds_motion = config.seconds_motion ?? "continuous";
} else {
delete ev.detail.value.seconds_motion;
delete config.seconds_motion;
}
} else {
delete ev.detail.value.border;
delete ev.detail.value.ticks;
delete ev.detail.value.face_style;
delete ev.detail.value.seconds_motion;
delete config.border;
delete config.ticks;
delete config.face_style;
delete config.seconds_motion;
}
if (ev.detail.value.ticks !== "none") {
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (config.ticks !== "none") {
config.face_style = config.face_style ?? "markers";
} else {
delete ev.detail.value.face_style;
delete config.face_style;
}
fireEvent(this, "config-changed", { config: ev.detail.value });
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
@@ -344,6 +354,10 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.label`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.label`
@@ -369,6 +383,10 @@ export class HuiClockCardEditor
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.description`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.description`
@@ -1,3 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -53,6 +54,65 @@ const cardConfigStruct = assign(
})
);
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 0, step: "any", mode: "box" } },
},
{
name: "show_names",
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
],
},
{
name: "",
type: "grid",
schema: [
{
name: "min_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
{
name: "max_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
],
},
{
name: "fit_y_data",
required: false,
hidden: {
condition: "and",
conditions: [
{ field: "min_y_axis", operator: "not_exists" },
{ field: "max_y_axis", operator: "not_exists" },
],
},
selector: { boolean: {} },
},
] as const satisfies readonly HaFormSchema[];
@customElement("hui-history-graph-card-editor")
export class HuiHistoryGraphCardEditor
extends LitElement
@@ -69,65 +129,6 @@ export class HuiHistoryGraphCardEditor
this._config = config;
}
private _schema = memoizeOne(
(showFitOption: boolean) =>
[
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 0, step: "any", mode: "box" } },
},
{
name: "show_names",
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
],
},
{
name: "",
type: "grid",
schema: [
{
name: "min_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
{
name: "max_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
],
},
...(showFitOption
? [
{
name: "fit_y_data",
required: false,
selector: { boolean: {} },
},
]
: []),
] as const
);
private _subForm = memoizeOne((localize: LocalizeFunc, entityId: string) => ({
schema: [
{ name: "entity", selector: { entity: {} }, required: true },
@@ -175,11 +176,6 @@ export class HuiHistoryGraphCardEditor
`;
}
const schema = this._schema(
this._config!.min_y_axis !== undefined ||
this._config!.max_y_axis !== undefined
);
const configEntities = this._config.entities
? (processEditorEntities(this._config.entities) as GraphEntityConfig[])
: [];
@@ -187,7 +183,7 @@ export class HuiHistoryGraphCardEditor
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
@@ -260,9 +256,10 @@ export class HuiHistoryGraphCardEditor
const domain = computeDomain(entityId);
const isNumberDomain =
domain === "counter" || domain === "number" || domain === "input_number";
const stateObj = this.hass!.states[entityId];
const stateObj = this.hass!.states[entityId] as HassEntity | undefined;
const attributes = stateObj?.attributes;
return !isNumericFromAttributes(attributes) && !isNumberDomain;
const isNumeric = attributes ? isNumericFromAttributes(attributes) : false;
return !isNumeric && !isNumberDomain;
};
// remove "color" option when needed
@@ -281,9 +278,7 @@ export class HuiHistoryGraphCardEditor
) as HistoryGraphCardConfig;
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "show_names":
case "logarithmic_scale":
@@ -92,7 +92,6 @@ export class HuiTileCardEditor
(
localize: LocalizeFunc,
entityId: string | undefined,
hideState: boolean,
showTimeFormat: boolean
) =>
[
@@ -144,31 +143,25 @@ export class HuiTileCardEditor
},
],
},
...(!hideState
? ([
{
name: "state_content",
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
] as const satisfies readonly HaFormSchema[])
: []),
...(showTimeFormat
? ([
{
name: "time_format",
selector: {
ui_time_format: {},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "state_content",
hidden: { field: "hide_state", value: true },
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
{
name: "time_format",
hidden: !showTimeFormat,
selector: {
ui_time_format: {},
},
},
{
name: "content_layout",
required: true,
@@ -293,12 +286,7 @@ export class HuiTileCardEditor
this._config.state_content
);
const schema = this._schema(
this.hass.localize,
entityId,
this._config.hide_state ?? false,
showTimeFormat
);
const schema = this._schema(this.hass.localize, entityId, showTimeFormat);
const vertical = this._config.vertical ?? false;
+12 -9
View File
@@ -608,22 +608,24 @@ class HaPanelMy extends LitElement {
}
const resultParams = {};
for (const [key, type] of Object.entries(this._redirect!.params || {})) {
if (!params[key] && type.endsWith("?")) {
const value = params[key];
if (!value && type.endsWith("?")) {
continue;
}
if (!params[key] || !this._checkParamType(type, params[key])) {
if (!value || !this._checkParamType(type, value)) {
throw Error();
}
resultParams[key] = params[key];
resultParams[key] = value;
}
for (const [key, type] of Object.entries(
this._redirect!.optional_params || {}
)) {
if (params[key]) {
if (!this._checkParamType(type, params[key])) {
const value = params[key];
if (value) {
if (!this._checkParamType(type, value)) {
throw Error();
}
resultParams[key] = params[key];
resultParams[key] = value;
}
}
return Object.keys(resultParams).length
@@ -637,11 +639,12 @@ class HaPanelMy extends LitElement {
}
const resultParams = {};
for (const [key, type] of Object.entries(this._redirect!.optional_params)) {
if (params[key]) {
if (!this._checkParamType(type, params[key])) {
const value = params[key];
if (value) {
if (!this._checkParamType(type, value)) {
throw Error();
}
resultParams[key] = params[key];
resultParams[key] = value;
}
}
return Object.keys(resultParams).length
+38 -1
View File
@@ -1486,6 +1486,7 @@
"auto_revert": "Settings will automatically revert in {time}.",
"reverted": "Home Assistant reverted the HTTP server settings.",
"changes_label": "Changed settings:",
"not_set": "Not set",
"confirm": "Confirm",
"revert": "Revert",
"close": "Close",
@@ -8679,6 +8680,10 @@
"description": "Configure how Home Assistant serves its web interface. Saving restarts Home Assistant.",
"save": "Save",
"save_no_changes": "Nothing changed — no restart needed.",
"save_error": "Could not save the HTTP configuration.",
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
"invalid_host": "Enter a valid IP address.",
"invalid_network": "Enter a valid IP address or network.",
"save_confirm": {
"title": "Restart required",
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
@@ -8690,7 +8695,7 @@
"ssl": "SSL/TLS",
"reverse_proxy": "Reverse proxy",
"ip_banning": "IP banning",
"advanced": "Advanced"
"advanced": "More options"
},
"fields": {
"server_port": "Server port",
@@ -10140,6 +10145,33 @@
"large": "Large"
},
"show_seconds": "Display seconds",
"date": {
"label": "Date",
"description": "Select and order the date parts. Add a separator to control punctuation.",
"sections": {
"weekday": "Weekday",
"day": "Day",
"month": "Month",
"year": "Year",
"separator": "Separator"
},
"parts": {
"weekday-short": "Short",
"weekday-long": "Long",
"day-numeric": "Numeric",
"day-2-digit": "2-digit",
"month-short": "Short",
"month-long": "Long",
"month-numeric": "Numeric",
"month-2-digit": "2-digit",
"year-2-digit": "2-digit",
"year-numeric": "Full",
"separator-dash": "Dash",
"separator-slash": "Slash",
"separator-dot": "Dot",
"separator-new-line": "New line"
}
},
"time_format": "[%key:ui::panel::profile::time_format::dropdown_label%]",
"time_formats": {
"auto": "Use user settings",
@@ -11539,6 +11571,11 @@
"add_card": "Add current view as card",
"add_card_error": "Unable to add card",
"error_no_data": "You need to select some data sources first."
},
"logbook": {
"download_data": "[%key:ui::panel::history::download_data%]",
"download_data_error": "[%key:ui::panel::history::download_data_error%]",
"error_no_data": "No activity for the selected target in the selected time range."
}
},
"tips": {
+15
View File
@@ -0,0 +1,15 @@
import { fileDownload } from "./file_download";
export function csvSafeString(s: string | undefined): string {
if (!s) return "";
return /,|"/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
}
export function csvDownload(data: string[][], filename: string) {
const csv = data.map((row) => row.join(",").concat("\n"));
const blob = new Blob(csv, {
type: "text/csv",
});
const url = window.URL.createObjectURL(blob);
fileDownload(url, filename);
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
isIPAddress,
isIPAddressV4OrV6,
isIPNetwork,
isIPv6Address,
} from "../../../src/common/string/is_ip_address";
describe("isIPAddress (IPv4)", () => {
it("accepts valid IPv4 addresses", () => {
expect(isIPAddress("192.168.1.10")).toBe(true);
expect(isIPAddress("0.0.0.0")).toBe(true);
expect(isIPAddress("255.255.255.255")).toBe(true);
});
it("rejects invalid IPv4 addresses", () => {
expect(isIPAddress("256.1.1.1")).toBe(false);
expect(isIPAddress("192.168.1")).toBe(false);
expect(isIPAddress("192.168.1.10/24")).toBe(false);
expect(isIPAddress("fe80::1")).toBe(false);
expect(isIPAddress("")).toBe(false);
});
});
describe("isIPv6Address", () => {
it("accepts valid IPv6 addresses", () => {
expect(isIPv6Address("fe80::85d:e82c:9446:7995")).toBe(true);
expect(isIPv6Address("::1")).toBe(true);
expect(isIPv6Address("1050:0000:0000:0000:0005:0600:300c:326b")).toBe(true);
expect(isIPv6Address("::ffff:192.168.1.1")).toBe(true);
});
it("rejects invalid IPv6 addresses", () => {
expect(isIPv6Address("192.168.1.10")).toBe(false);
expect(isIPv6Address("fe80::85d::7995")).toBe(false);
expect(isIPv6Address("gggg::1")).toBe(false);
expect(isIPv6Address("")).toBe(false);
});
});
describe("isIPAddressV4OrV6", () => {
it("accepts both families", () => {
expect(isIPAddressV4OrV6("192.168.1.10")).toBe(true);
expect(isIPAddressV4OrV6("fe80::1")).toBe(true);
});
it("rejects networks and garbage", () => {
expect(isIPAddressV4OrV6("192.168.1.0/24")).toBe(false);
expect(isIPAddressV4OrV6("not-an-ip")).toBe(false);
});
});
describe("isIPNetwork (CIDR)", () => {
it("accepts valid networks", () => {
expect(isIPNetwork("192.168.1.0/24")).toBe(true);
expect(isIPNetwork("10.0.0.0/8")).toBe(true);
expect(isIPNetwork("172.16.0.0/12")).toBe(true);
expect(isIPNetwork("0.0.0.0/0")).toBe(true);
expect(isIPNetwork("fd00::/8")).toBe(true);
expect(isIPNetwork("fe80::/128")).toBe(true);
});
it("rejects invalid networks", () => {
// Prefix out of range — the reported production error.
expect(isIPNetwork("172.30.33.0/24444444")).toBe(false);
expect(isIPNetwork("192.168.1.0/33")).toBe(false);
expect(isIPNetwork("fd00::/129")).toBe(false);
expect(isIPNetwork("192.168.1.0")).toBe(false);
expect(isIPNetwork("192.168.1.0/24/24")).toBe(false);
expect(isIPNetwork("not-a-network/24")).toBe(false);
});
});
// The HTML `pattern` attribute is anchored by the browser as `^(?:…)$`.
const matchesPattern = (pattern: string, value: string): boolean =>
new RegExp(`^(?:${pattern})$`).test(value);
describe("IP_ADDRESS_PATTERN", () => {
it("accepts IPv4 and IPv6 addresses", () => {
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.10")).toBe(true);
expect(matchesPattern(IP_ADDRESS_PATTERN, "fe80::1")).toBe(true);
});
it("rejects networks and garbage", () => {
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.0/24")).toBe(false);
expect(matchesPattern(IP_ADDRESS_PATTERN, "not-an-ip")).toBe(false);
expect(matchesPattern(IP_ADDRESS_PATTERN, "256.1.1.1")).toBe(false);
});
});
describe("IP_ADDRESS_OR_NETWORK_PATTERN", () => {
it("accepts addresses and networks", () => {
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.10")).toBe(
true
);
expect(
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/24")
).toBe(true);
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "fd00::/8")).toBe(
true
);
});
it("rejects out-of-range prefixes and garbage", () => {
// The reported production error.
expect(
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "172.30.33.0/24444444")
).toBe(false);
expect(
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/33")
).toBe(false);
expect(
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "1.1.1.1/233444")
).toBe(false);
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "not-an-ip")).toBe(
false
);
});
});
@@ -0,0 +1,110 @@
import { describe, it, expect } from "vitest";
import { getAvailableClockDatePartSections } from "../../src/components/ha-clock-date-format-picker";
type TestSection = "weekday" | "day" | "month" | "year" | "separator";
const section = (id: TestSection, itemIds: string[]) => ({
id,
title: id,
items: itemIds.map((itemId) => ({ id: itemId, primary: itemId })),
});
const ALL_SECTIONS = [
section("day", ["day-numeric", "day-2-digit"]),
section("month", ["month-numeric", "month-short", "month-long"]),
section("year", ["year-numeric", "year-2-digit"]),
section("weekday", ["weekday-short", "weekday-long"]),
section("separator", [
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
]),
];
describe("getAvailableClockDatePartSections", () => {
it("returns every section when no value is set", () => {
const result = getAvailableClockDatePartSections(ALL_SECTIONS, []);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"month",
"year",
"weekday",
"separator",
]);
});
it("hides a group's section once a value from that group is used", () => {
const result = getAvailableClockDatePartSections(ALL_SECTIONS, [
"day-numeric",
]);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"month",
"year",
"weekday",
"separator",
]);
});
it("keeps the edited item's own group visible via excludeIndex", () => {
const value = ["day-numeric", "month-short"];
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value, 0);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"year",
"weekday",
"separator",
]);
});
it("keeps the edited item's group visible even when a duplicate of that group exists elsewhere", () => {
const value = ["day-numeric", "day-2-digit", "month-long"];
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value, 0);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"year",
"weekday",
"separator",
]);
});
it("does not crash and keeps normal filtering when excludeIndex is out of range", () => {
const value = ["day-numeric", "month-long"];
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value, 5);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"year",
"weekday",
"separator",
]);
});
it("never hides the separator section, even with multiple separators used", () => {
const value = [
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
];
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"month",
"year",
"weekday",
"separator",
]);
});
it("treats an unrecognized token as a separator and does not hide any group", () => {
const result = getAvailableClockDatePartSections(ALL_SECTIONS, [
"not-a-real-part",
]);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"month",
"year",
"weekday",
"separator",
]);
});
});
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { isFieldHidden } from "../../../src/components/ha-form/conditions";
import type { HaFormSchema } from "../../../src/components/ha-form/types";
const field = (hidden: HaFormSchema["hidden"]): HaFormSchema =>
({ name: "field", selector: { text: {} }, hidden }) as HaFormSchema;
describe("isFieldHidden", () => {
it("shows a field without a hidden condition", () => {
expect(isFieldHidden(field(undefined), { a: 1 })).toBe(false);
});
it("honors a boolean hidden", () => {
expect(isFieldHidden(field(true), {})).toBe(true);
expect(isFieldHidden(field(false), {})).toBe(false);
});
describe("operators", () => {
it("eq (default) matches equal values", () => {
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 1 })).toBe(
true
);
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 2 })).toBe(
false
);
});
it("not_eq matches different values", () => {
const schema = field({ field: "a", operator: "not_eq", value: 1 });
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
});
it("in matches membership", () => {
const schema = field({ field: "a", operator: "in", value: ["x", "y"] });
expect(isFieldHidden(schema, { a: "y" })).toBe(true);
expect(isFieldHidden(schema, { a: "z" })).toBe(false);
});
it("not_in matches non-membership", () => {
const schema = field({
field: "a",
operator: "not_in",
value: ["x", "y"],
});
expect(isFieldHidden(schema, { a: "z" })).toBe(true);
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
});
it("exists matches a defined non-empty value", () => {
const schema = field({ field: "a", operator: "exists" });
expect(isFieldHidden(schema, { a: "x" })).toBe(true);
expect(isFieldHidden(schema, { a: "" })).toBe(false);
expect(isFieldHidden(schema, {})).toBe(false);
});
it("not_exists matches a missing or empty value", () => {
const schema = field({ field: "a", operator: "not_exists" });
expect(isFieldHidden(schema, {})).toBe(true);
expect(isFieldHidden(schema, { a: null } as any)).toBe(true);
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
});
});
describe("combinators", () => {
it("and requires every condition", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
});
it("or requires any condition", () => {
const schema = field({
condition: "or",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldHidden(schema, { a: 9, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 9, b: 9 })).toBe(false);
});
it("not negates its conditions", () => {
const schema = field({
condition: "not",
conditions: [{ field: "a", value: 1 }],
});
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
});
it("nests combinators", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{
condition: "or",
conditions: [
{ field: "b", value: 2 },
{ field: "c", value: 3 },
],
},
],
});
expect(isFieldHidden(schema, { a: 1, b: 9, c: 3 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9, c: 9 })).toBe(false);
expect(isFieldHidden(schema, { a: 9, b: 2, c: 3 })).toBe(false);
});
});
it("treats an array of conditions as AND", () => {
const schema = field([
{ field: "a", value: 1 },
{ field: "b", value: 2 },
]);
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
});
it("handles missing data", () => {
expect(isFieldHidden(field({ field: "a", value: 1 }), undefined)).toBe(
false
);
});
});
+134
View File
@@ -0,0 +1,134 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import { filterSelectorEntities } from "../../src/data/selector";
import type { HomeAssistant } from "../../src/types";
const entity = {
entity_id: "light.living_room",
state: "on",
attributes: {},
} as HassEntity;
const entityRegistry = {
"light.living_room": { device_id: "device_1" },
} as unknown as HomeAssistant["entities"];
const devices = {
device_1: {
id: "device_1",
manufacturer: "Signify",
model: "Hue Bulb",
model_id: "LCT015",
} as DeviceRegistryEntry,
} as unknown as HomeAssistant["devices"];
describe("filterSelectorEntities device filter", () => {
it("matches when the nested device manufacturer matches", () => {
expect(
filterSelectorEntities(
{ device: { manufacturer: "Signify" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(true);
});
it("does not match when the nested device manufacturer differs", () => {
expect(
filterSelectorEntities(
{ device: { manufacturer: "Sonos" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(false);
});
it("matches when model and model_id both match", () => {
expect(
filterSelectorEntities(
{ device: { model: "Hue Bulb", model_id: "LCT015" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(true);
});
it("does not match when one of model or model_id differs", () => {
expect(
filterSelectorEntities(
{ device: { model: "Hue Bulb", model_id: "OTHER" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(false);
});
it("matches the device integration via the lookup", () => {
expect(
filterSelectorEntities(
{ device: { integration: "hue" } },
entity,
undefined,
entityRegistry,
devices,
{ device_1: new Set(["hue"]) }
)
).toBe(true);
});
it("does not match a device integration that is absent from the lookup", () => {
expect(
filterSelectorEntities(
{ device: { integration: "zha" } },
entity,
undefined,
entityRegistry,
devices,
{ device_1: new Set(["hue"]) }
)
).toBe(false);
});
it("does not match when the entity has no underlying device", () => {
expect(
filterSelectorEntities(
{ device: { manufacturer: "Signify" } },
entity,
undefined,
{} as HomeAssistant["entities"],
devices
)
).toBe(false);
});
it("combines device conditions with other entity conditions (AND)", () => {
expect(
filterSelectorEntities(
{ domain: "light", device: { manufacturer: "Signify" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(true);
expect(
filterSelectorEntities(
{ domain: "switch", device: { manufacturer: "Signify" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(false);
});
});
+2
View File
@@ -483,6 +483,7 @@ test.describe("Theming", () => {
() =>
(document.querySelector("ha-test") as any)?.hass?.themes?.darkMode ===
true,
undefined,
{ timeout: QUICK_TIMEOUT }
);
});
@@ -499,6 +500,7 @@ test.describe("Theming", () => {
getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color")
.trim() !== "",
undefined,
{ timeout: QUICK_TIMEOUT }
);
+6 -9
View File
@@ -1,20 +1,19 @@
import { assert, describe, it } from "vitest";
import { createHassioSession } from "../../src/data/hassio/ingress";
import type { HomeAssistant } from "../../src/types";
describe("Create hassio session", () => {
const hass = {
config: { version: "1.0.0" },
callApi: async () => ({
data: { session: "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh" },
callWS: async () => ({
session: "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh",
}),
};
} as unknown as Pick<HomeAssistant, "callWS">;
it("Test create session without HTTPS", async () => {
// @ts-ignore
global.document = {};
// @ts-ignore
global.location = {};
// @ts-ignore
await createHassioSession(hass);
assert.strictEqual(
// @ts-ignore
@@ -27,7 +26,6 @@ describe("Create hassio session", () => {
global.document = {};
// @ts-ignore
global.location = { protocol: "https:" };
// @ts-ignore
await createHassioSession(hass);
assert.strictEqual(
// @ts-ignore
@@ -43,9 +41,8 @@ describe("Create hassio session", () => {
});
it("Test fail to create", async () => {
const createSessionPromise = createHassioSession({
// @ts-ignore
callApi: async () => {
// noop
callWS: async () => {
throw new Error("Failed to create session");
},
}).then(
() => true,
@@ -0,0 +1,261 @@
import { assert, describe, it } from "vitest";
import type { ClockCardDatePart } from "../../../../src/panels/lovelace/cards/types";
import {
formatClockCardDate,
getClockCardDateConfig,
getClockCardDateTimeFormatOptions,
hasClockCardDate,
} from "../../../../src/panels/lovelace/cards/clock/clock-date-format";
describe("clock-date-format", () => {
const date = new Date("2024-11-08T10:20:30.000Z");
it("returns an empty config when date format is missing", () => {
assert.deepEqual(getClockCardDateConfig(), { parts: [] });
});
it("preserves literal token order", () => {
const config = getClockCardDateConfig({
date_format: [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
],
});
assert.deepEqual(config.parts, [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
]);
});
it("filters invalid date tokens", () => {
const config = getClockCardDateConfig({
date_format: [
"month-short",
"invalid",
"year-2-digit",
] as unknown as ClockCardDatePart[],
});
assert.deepEqual(config.parts, ["month-short", "year-2-digit"]);
});
it("builds Intl options from selected date tokens", () => {
const options = getClockCardDateTimeFormatOptions({
parts: [
"weekday-short",
"separator-slash",
"day-2-digit",
"month-long",
"month-numeric",
"year-2-digit",
],
});
assert.deepEqual(options, {
weekday: "short",
day: "2-digit",
month: "numeric",
year: "2-digit",
});
});
it("reports whether any date part is configured", () => {
assert.equal(hasClockCardDate(), false);
assert.equal(hasClockCardDate({ date_format: [] }), false);
assert.equal(hasClockCardDate({ date_format: ["separator-dot"] }), true);
assert.equal(
hasClockCardDate({ date_format: ["separator-new-line"] }),
true
);
assert.equal(hasClockCardDate({ date_format: ["weekday-short"] }), true);
});
it("formats output in configured part order with literal separators", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-dash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08-24");
});
it("uses separator only for the next gap", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dot",
"month-numeric",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8.11 2024");
});
it("supports using the same separator style multiple times", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-slash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08/24");
});
it("renders separators even when no value follows", () => {
const result = formatClockCardDate(
date,
{
parts: ["day-numeric", "separator-dash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "8-.");
});
it("renders separators even when no value precedes", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-slash", "separator-dot", "day-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "/.8");
});
it("renders all consecutive separators between values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8-/.11");
});
it("renders repeated separators without deduplication", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-dash",
"separator-dash",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8---11");
});
it("renders separator-only configurations", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-dash", "separator-slash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "-/.");
});
it("supports inserting a new line between date values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-new-line",
"day-2-digit",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "11\n08 2024");
});
it("allows multiple variants for the same date part", () => {
const result = formatClockCardDate(
date,
{
parts: ["month-short", "month-long", "year-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "Nov November 2024");
});
it("filters invalid tokens when formatting", () => {
const config = getClockCardDateConfig({
date_format: [
"month-numeric",
"invalid",
"year-numeric",
] as unknown as ClockCardDatePart[],
});
const result = formatClockCardDate(date, config, "en", "UTC");
assert.equal(result, "11 2024");
});
});
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
migrateEntitiesCardConfig,
migrateGlanceCardConfig,
} from "../../../../src/panels/lovelace/cards/migrate-card-config";
import type {
EntitiesCardConfig,
GlanceCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
describe("migrateEntitiesCardConfig", () => {
it("migrates the legacy `format` option to `time_format` on native rows", () => {
const config = {
type: "entities",
entities: [{ entity: "sensor.last_changed", format: "relative" }],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result.entities).toEqual([
{ entity: "sensor.last_changed", time_format: "relative" },
]);
// `format` is dropped after migration
expect(result.entities[0]).not.toHaveProperty("format");
});
it("leaves custom rows untouched", () => {
const customRow = {
type: "custom:multiple-entity-row",
entity: "sensor.power",
format: "precision1",
};
const config = {
type: "entities",
entities: [customRow],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
// Nothing changed, so the same config reference is returned and the
// custom row keeps its own `format` semantics.
expect(result).toBe(config);
expect(result.entities[0]).toEqual(customRow);
});
it("returns the same config reference when there is nothing to migrate", () => {
const config = {
type: "entities",
entities: [{ entity: "sensor.temperature" }, { entity: "light.kitchen" }],
} as EntitiesCardConfig;
expect(migrateEntitiesCardConfig(config)).toBe(config);
});
it("passes string rows through untouched", () => {
const config = {
type: "entities",
entities: ["sensor.temperature", { entity: "sensor.x", format: "time" }],
} as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result.entities[0]).toBe("sensor.temperature");
expect(result.entities[1]).toEqual({
entity: "sensor.x",
time_format: "time",
});
});
it("keeps a pre-existing `time_format` over the legacy `format`", () => {
const config = {
type: "entities",
entities: [
{ entity: "sensor.x", format: "relative", time_format: "datetime" },
],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result.entities[0]).toEqual({
entity: "sensor.x",
time_format: "datetime",
});
});
});
describe("migrateGlanceCardConfig", () => {
it("migrates the legacy `format` option to `time_format`", () => {
const config = {
type: "glance",
entities: [{ entity: "sensor.last_changed", format: "relative" }],
} as unknown as GlanceCardConfig;
const result = migrateGlanceCardConfig(config);
expect(result.entities).toEqual([
{ entity: "sensor.last_changed", time_format: "relative" },
]);
expect(result.entities[0]).not.toHaveProperty("format");
});
it("returns the same config reference when there is nothing to migrate", () => {
const config = {
type: "glance",
entities: ["sensor.temperature", { entity: "light.kitchen" }],
} as GlanceCardConfig;
expect(migrateGlanceCardConfig(config)).toBe(config);
});
it("keeps a pre-existing `time_format` over the legacy `format`", () => {
const config = {
type: "glance",
entities: [
{ entity: "sensor.x", format: "relative", time_format: "datetime" },
],
} as unknown as GlanceCardConfig;
const result = migrateGlanceCardConfig(config);
expect(result.entities[0]).toEqual({
entity: "sensor.x",
time_format: "datetime",
});
});
});
+1054 -815
View File
File diff suppressed because it is too large Load Diff