Compare commits

..

21 Commits

Author SHA1 Message Date
Paul Bottein 967211faff Improve tests 2026-07-16 16:15:10 +02:00
Paul Bottein 5b98151265 Migrate entities and glance card state_color to color 2026-07-16 15:57:14 +02: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
75 changed files with 3170 additions and 1283 deletions
+1 -18
View File
@@ -8,33 +8,16 @@ inputs:
cache:
description: Enable the yarn cache in setup-node
default: "true"
node-modules-cache:
description: Restore the exact shared node_modules cache before installing
default: "false"
runs:
using: composite
steps:
- name: Restore complete dependency tree
id: dependency-cache
if: inputs.node-modules-cache == 'true'
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: ${{ inputs.cache == 'true' && (inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && 'yarn' || '' }}
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
- name: Install dependencies
if: inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true'
shell: bash
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
+3 -50
View File
@@ -1,7 +1,6 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- dev
@@ -22,56 +21,16 @@ permissions:
contents: read
jobs:
prepare-dependencies:
name: Prepare dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for complete dependency tree
id: dependencies
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
lookup-only: true
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: ./.github/actions/setup
with:
cache: false
- name: Save complete dependency tree
if: steps.dependencies.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
lint:
name: Lint and check format
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node with shared dependencies
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
run: yarn dedupe --check
- name: Build resources
@@ -104,17 +63,14 @@ jobs:
run: yarn run lint:licenses
test:
name: Run tests
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node with shared dependencies
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
env:
@@ -124,7 +80,6 @@ jobs:
build:
name: Build frontend
needs:
- prepare-dependencies
- lint
- test
runs-on: ubuntu-latest
@@ -133,10 +88,8 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node with shared dependencies
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build Application
uses: ./.github/actions/build
with:
+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 () => {};
+15 -14
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",
@@ -89,7 +89,7 @@
"@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",
@@ -107,7 +107,7 @@
"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",
@@ -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,7 +207,7 @@
"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",
+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];
}
+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;
};
/**
+24 -11
View File
@@ -6,8 +6,10 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../common/color/compute-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { stateActive } from "../../common/entity/state_active";
import {
stateColorBrightness,
stateColorCss,
@@ -27,10 +29,15 @@ export class StateBadge extends LitElement {
@property({ attribute: false }) public overrideImage?: string;
// Cannot be a boolean attribute because undefined is treated different than
// false. When it is undefined, state is still colored for light entities.
/**
* Cannot be a boolean attribute because undefined is treated different than
* false. When it is undefined, state is still colored for light entities.
* @deprecated use `color` instead
*/
@property({ attribute: false }) public stateColor?: boolean;
// "state", "none" or a color (theme color name or CSS color). Custom colors
// apply when the entity is active. Takes precedence over stateColor.
@property() public color?: string;
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@@ -67,11 +74,17 @@ export class StateBadge extends LitElement {
}
}
private get _stateColor() {
private get _color(): string | undefined {
if (this.color) {
return this.color;
}
if (this.stateColor !== undefined) {
return this.stateColor ? "state" : "none";
}
const domain = this.stateObj
? computeStateDomain(this.stateObj)
: undefined;
return this.stateColor ?? domain === "light";
return domain === "light" ? "state" : undefined;
}
protected render() {
@@ -131,6 +144,7 @@ export class StateBadge extends LitElement {
if (stateObj) {
const domain = computeDomain(stateObj.entity_id);
const color = this._color;
if (this.overrideImage === undefined) {
// hide icon if we have entity picture
if (
@@ -147,13 +161,10 @@ export class StateBadge extends LitElement {
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
} else if (this.color) {
// Externally provided overriding color wins over state color
iconStyle.color = this.color;
} else if (this._stateColor) {
const color = stateColorCss(stateObj);
if (color) {
iconStyle.color = color;
} else if (color === "state") {
const stateColor = stateColorCss(stateObj);
if (stateColor) {
iconStyle.color = stateColor;
}
if (stateObj.attributes.rgb_color) {
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
@@ -180,6 +191,8 @@ export class StateBadge extends LitElement {
delete iconStyle.color;
}
}
} else if (color && color !== "none" && stateActive(stateObj)) {
iconStyle.color = computeCssColor(color);
}
} else if (this.overrideImage) {
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
+4 -2
View File
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../../types";
import "../ha-relative-time";
import "../ha-tooltip";
@@ -23,10 +24,11 @@ class StateInfo extends LitElement {
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
// Inline style because the state-badge color API only colors active entities
return html`<state-badge
.stateObj=${this.stateObj}
.stateColor=${true}
.color=${this.color}
.stateColor=${!this.color}
style=${styleMap({ color: this.color })}
></state-badge>
<div class="info">
<div class="name ${this.inDialog ? "in-dialog" : ""}" .title=${name}>
@@ -0,0 +1,540 @@
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[];
}
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;
private _editIndex?: number;
protected render() {
const value = this._value;
const valueItems = this._getValueItems(value);
const sections = this._buildSections();
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(sections)}
.getItems=${this._getItems(sections)}
@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;
}
}
@@ -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;
@@ -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"),
+7 -15
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);
});
}
+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,
-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";
+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<
+5
View File
@@ -80,6 +80,7 @@ export type Selector =
| TTSVoiceSelector
| SerialPortSelector
| UiActionSelector
| UiClockDateFormatSelector
| UiColorSelector
| UiStateContentSelector
| UiTimeFormatSelector
@@ -577,6 +578,10 @@ export interface UiActionSelector {
} | null;
}
export interface UiClockDateFormatSelector {
ui_clock_date_format: {} | null;
}
export interface UiColorExtraOption {
value: string;
label: string;
+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>
`;
}
@@ -322,6 +322,8 @@ export class DialogHttpPendingConfig
ul {
margin: 0 0 var(--ha-space-4) 0;
padding-left: var(--ha-space-6);
padding-inline-start: var(--ha-space-6);
padding-inline-end: initial;
color: var(--secondary-text-color);
}
li {
+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);
+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;
+7 -13
View File
@@ -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"
+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);
}
+43 -9
View File
@@ -1,4 +1,4 @@
import { mdiRefresh } from "@mdi/js";
import { mdiFilterRemove, mdiRefresh } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
@@ -16,6 +16,7 @@ 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-icon-button";
import "../../components/ha-target-picker";
@@ -27,6 +28,11 @@ import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook";
interface LogbookState {
time: { range: [Date, Date] };
targetPickerValue: HassServiceTarget;
}
@customElement("ha-panel-logbook")
export class HaPanelLogbook extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -54,14 +60,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() {
@@ -71,6 +70,13 @@ export class HaPanelLogbook extends LitElement {
.backButton=${!!this._showBack}
>
<div slot="title">${this.hass.localize("panel.logbook")}</div>
<ha-icon-button
slot="actionItems"
@click=${this._resetLogbook}
.disabled=${this._isDefaultState()}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.common.reset")}
></ha-icon-button>
<ha-icon-button
slot="actionItems"
@click=${this._refreshLogbook}
@@ -230,6 +236,34 @@ 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();
}
@@ -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,
+16 -39
View File
@@ -8,6 +8,7 @@ import "../../../components/ha-card";
import type { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size";
import { findEntities } from "../common/find-entities";
import { applyDefaultColor } from "../common/entity-color-config";
import { processConfigEntities } from "../common/process-config-entities";
import "../components/hui-entities-toggle";
import { createHeaderFooterElement } from "../create-element/create-header-footer-element";
@@ -23,7 +24,8 @@ import type {
LovelaceGridOptions,
LovelaceHeaderFooter,
} from "../types";
import type { EntitiesCardConfig } from "./types";
import { migrateEntitiesCardConfig } from "./migrate-card-config";
import type { EntitiesCardConfig, EntitiesCardEntityConfig } from "./types";
import { haStyleScrollbar } from "../../../resources/styles";
export const computeShowHeaderToggle = <
@@ -49,32 +51,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 {
@@ -183,7 +160,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
const migratedConfig = migrateEntitiesCardConfig(config);
const entities = processConfigEntities(migratedConfig.entities);
this._config = migratedConfig;
this._config = { color: "state", ...migratedConfig };
this._configEntities = entities;
this._showHeaderToggle = computeShowHeaderToggle(migratedConfig, entities);
if (this._config.header) {
@@ -367,18 +344,18 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
`,
];
private _renderEntity(entityConf: LovelaceRowConfig): TemplateResult {
const element = createRowElement(
(!("type" in entityConf) || entityConf.type === "conditional") &&
"state_color" in this._config!
? ({
state_color: this._config.state_color,
...(entityConf as EntityConfig),
} as EntityConfig)
: entityConf.type === "perform-action"
? { ...entityConf, type: "call-service" }
: entityConf
private _rowConfig(entityConf: LovelaceRowConfig): LovelaceRowConfig {
if (entityConf.type === "perform-action") {
return { ...entityConf, type: "call-service" };
}
return applyDefaultColor(
entityConf as EntitiesCardEntityConfig,
this._config!.color
);
}
private _renderEntity(entityConf: LovelaceRowConfig): TemplateResult {
const element = createRowElement(this._rowConfig(entityConf));
if (this._hass) {
element.hass = this._hass;
}
+14 -34
View File
@@ -26,40 +26,17 @@ import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction, hasAnyAction } from "../common/has-action";
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { applyDefaultColor } from "../common/entity-color-config";
import { processConfigEntities } from "../common/process-config-entities";
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 {
@@ -111,14 +88,19 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
show_name: true,
show_state: true,
show_icon: true,
state_color: true,
color: "state",
...migratedConfig,
};
const cardColor = this._config.color;
const entities = processConfigEntities(migratedConfig.entities).map(
(entityConf) => ({
hold_action: { action: "more-info" } as MoreInfoActionConfig,
...entityConf,
})
(entityConf) =>
applyDefaultColor(
{
hold_action: { action: "more-info" } as MoreInfoActionConfig,
...entityConf,
},
cardColor
)
);
for (const entity of entities) {
@@ -318,9 +300,7 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
.stateObj=${stateObj}
.overrideIcon=${entityConf.icon}
.overrideImage=${entityConf.image}
.stateColor=${
entityConf.state_color ?? this._config!.state_color
}
.color=${entityConf.color}
></state-badge>
`
: ""
@@ -0,0 +1,81 @@
import { migrateStateColorConfig } from "../common/entity-color-config";
import { migrateTimeFormatConfig } from "../common/entity-time-format-config";
import type {
ConditionalRowConfig,
LovelaceRowConfig,
} from "../entity-rows/types";
import type {
EntitiesCardConfig,
EntitiesCardEntityConfig,
GlanceCardConfig,
GlanceConfigEntity,
} from "./types";
const migrateEntitiesRowConfig = (
rowConf: LovelaceRowConfig | string
): LovelaceRowConfig | string => {
if (typeof rowConf !== "object") {
return rowConf;
}
let newConf: LovelaceRowConfig = rowConf;
newConf = migrateTimeFormatConfig(newConf as EntitiesCardEntityConfig);
newConf = migrateStateColorConfig(newConf as EntitiesCardEntityConfig);
if (newConf.type === "conditional") {
const row = (newConf as ConditionalRowConfig).row;
if (row && typeof row === "object") {
let newRow = migrateTimeFormatConfig(row as EntitiesCardEntityConfig);
newRow = migrateStateColorConfig(newRow);
if (newRow !== row) {
newConf = { ...newConf, row: newRow } as ConditionalRowConfig;
}
}
}
return newConf;
};
export const migrateEntitiesCardConfig = (
config: EntitiesCardConfig
): EntitiesCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
const newConf = migrateEntitiesRowConfig(e);
if (newConf !== e) {
changed = true;
}
return newConf;
});
const newConfig = migrateStateColorConfig(config);
if (!changed) {
return newConfig;
}
return {
...newConfig,
entities: newEntities,
};
};
export const migrateGlanceCardConfig = (
config: GlanceCardConfig
): GlanceCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
let newConf = e;
newConf = migrateTimeFormatConfig(newConf);
newConf = migrateStateColorConfig(newConf);
if (newConf !== e) {
changed = true;
}
return newConf;
});
const newConfig = migrateStateColorConfig(config);
if (!changed) {
return newConfig;
}
return {
...newConfig,
entities: newEntities as (GlanceConfigEntity | string)[],
};
};
+25
View File
@@ -112,7 +112,9 @@ export interface EntitiesCardEntityConfig extends EntityConfig {
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
show_name?: boolean;
show_icon?: boolean;
}
@@ -126,7 +128,9 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
icon?: string;
header?: LovelaceHeaderFooterConfig;
footer?: LovelaceHeaderFooterConfig;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
}
export type AreaCardDisplayType = "compact" | "icon" | "picture" | "camera";
@@ -330,7 +334,9 @@ export interface GlanceConfigEntity extends ConfigEntity {
show_last_changed?: boolean;
image?: string;
show_state?: boolean;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
time_format?: TimestampRenderingFormat;
}
@@ -342,7 +348,9 @@ export interface GlanceCardConfig extends LovelaceCardConfig {
theme?: string;
entities: (string | GlanceConfigEntity)[];
columns?: number;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
}
export interface HumidifierCardConfig extends LovelaceCardConfig {
@@ -445,12 +453,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[];
@@ -0,0 +1,34 @@
import { isCustomType } from "../../../data/lovelace_custom_cards";
interface LegacyStateColorConfig {
type?: string;
color?: string;
state_color?: boolean;
}
export const migrateStateColorConfig = <T extends LegacyStateColorConfig>(
config: T
): T => {
// Custom elements own their config schema, leave them untouched
if (config.type !== undefined && isCustomType(config.type)) {
return config;
}
if (config.state_color === undefined) {
return config;
}
const { state_color, ...rest } = config;
return {
color: state_color ? "state" : "none",
...rest,
} as T;
};
export const applyDefaultColor = <T extends { type?: string; color?: string }>(
config: T,
color: string | undefined
): T =>
color === undefined ||
config.color !== undefined ||
(config.type !== undefined && isCustomType(config.type))
? config
: { ...config, color };
@@ -0,0 +1,27 @@
import { isCustomType } from "../../../data/lovelace_custom_cards";
import type { TimestampRenderingFormat } from "../components/types";
interface LegacyTimeFormatConfig {
type?: string;
time_format?: TimestampRenderingFormat;
/** @deprecated use `time_format` instead */
format?: TimestampRenderingFormat;
}
export const migrateTimeFormatConfig = <T extends LegacyTimeFormatConfig>(
config: T
): T => {
// Custom elements own their config schema and may use the same option with
// a different meaning (e.g. custom:multiple-entity-row), leave them untouched
if (config.type !== undefined && isCustomType(config.type)) {
return config;
}
if (config.format === undefined) {
return config;
}
const { format, ...rest } = config;
return {
time_format: format,
...rest,
} as T;
};
@@ -86,6 +86,7 @@ export class HuiGenericEntityRow extends LitElement {
.overrideIcon=${this.config.icon}
.overrideImage=${this.config.image}
.stateColor=${this.config.state_color}
.color=${this.config.color}
></state-badge>
${
!this.hideName
@@ -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`
@@ -22,11 +22,9 @@ import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { customType } from "../../../../common/structs/is-custom-type";
import "../../../../components/ha-card";
import "../../../../components/ha-formfield";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-icon";
import "../../../../components/ha-switch";
import "../../../../components/ha-theme-picker";
import "../../../../components/input/ha-input";
import { isCustomType } from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types";
import {
@@ -90,6 +88,7 @@ const conditionalEntitiesRowConfigStruct = object({
type: literal("conditional"),
row: any(),
conditions: array(any()),
color: optional(string()),
});
const dividerEntitiesRowConfigStruct = object({
@@ -181,6 +180,22 @@ const entitiesRowConfigStruct = dynamic<any>((value) => {
return entitiesConfigStruct;
});
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{ name: "theme", selector: { theme: {} } },
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
include_none: true,
},
},
},
{ name: "show_header_toggle", selector: { boolean: {} } },
] as const;
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
@@ -189,7 +204,7 @@ const cardConfigStruct = assign(
theme: optional(string()),
icon: optional(string()),
show_header_toggle: optional(boolean()),
state_color: optional(boolean()),
color: optional(string()),
entities: array(entitiesRowConfigStruct),
header: optional(headerFooterConfigStructs),
footer: optional(headerFooterConfigStructs),
@@ -226,14 +241,6 @@ export class HuiEntitiesCardEditor
);
});
get _title(): string {
return this._config!.title || "";
}
get _theme(): string {
return this._config!.theme || "";
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
@@ -251,52 +258,20 @@ export class HuiEntitiesCardEditor
`;
}
const data = {
...this._config,
show_header_toggle: this._showHeaderToggle(this._config),
};
return html`
<div class="card-config">
<ha-input
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.title"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value=${this._title}
.configValue=${"title"}
@input=${this._valueChanged}
></ha-input>
<ha-theme-picker
.value=${this._theme}
.label=${`${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`}
.configValue=${"theme"}
@value-changed=${this._valueChanged}
></ha-theme-picker>
<div class="side-by-side">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.lovelace.editor.card.entities.show_header_toggle"
)}
>
<ha-switch
.checked=${this._showHeaderToggle(this._config)}
.configValue=${"show_header_toggle"}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.state_color"
)}
>
<ha-switch
.checked=${this._config!.state_color}
.configValue=${"state_color"}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
</div>
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._formChanged}
></ha-form>
<hui-header-footer-editor
.hass=${this.hass}
.configValue=${"header"}
@@ -321,6 +296,43 @@ export class HuiEntitiesCardEditor
`;
}
private _formChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config) {
return;
}
const config = { ...ev.detail.value } as EntitiesCardConfig;
if (
this._config.show_header_toggle === undefined &&
config.show_header_toggle === this._showHeaderToggle(this._config)
) {
delete config.show_header_toggle;
}
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "title":
case "theme":
return `${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`;
case "show_header_toggle":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.entities.show_header_toggle"
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
@@ -330,17 +342,7 @@ export class HuiEntitiesCardEditor
const target = ev.target! as EditorTarget;
const configValue =
target.configValue || this._subElementEditorConfig?.type;
const value =
target.checked !== undefined
? target.checked
: target.value || ev.detail.config || ev.detail.value;
if (
(configValue === "title" && target.value === this._title) ||
(configValue === "theme" && target.value === this._theme)
) {
return;
}
const value = ev.detail.config ?? ev.detail.value;
if (configValue === "row" || (ev.detail && ev.detail.entities)) {
const newConfigEntities =
@@ -359,7 +361,7 @@ export class HuiEntitiesCardEditor
this._config = { ...this._config!, entities: newConfigEntities };
this._configEntities = processEditorEntities(this._config!.entities);
} else if (configValue) {
if (value === "") {
if (value === "" || value === undefined) {
this._config = { ...this._config };
delete this._config[configValue!];
} else {
@@ -435,10 +437,6 @@ export class HuiEntitiesCardEditor
hui-header-footer-editor {
padding-top: var(--ha-space-1);
}
ha-input {
--ha-input-padding-bottom: var(--ha-space-4);
}
`,
];
}
@@ -59,13 +59,28 @@ export class HuiGenericEntityRowEditor
context: { entity: "entity" },
},
{
name: "icon",
selector: {
icon: {},
},
context: {
icon_entity: "entity",
},
name: "",
type: "grid",
schema: [
{
name: "icon",
selector: {
icon: {},
},
context: {
icon_entity: "entity",
},
},
{
name: "color",
selector: {
ui_color: {
include_state: true,
include_none: true,
},
},
},
],
},
{
name: "secondary_info",
@@ -44,7 +44,7 @@ const cardConfigStruct = assign(
show_name: optional(boolean()),
show_state: optional(boolean()),
show_icon: optional(boolean()),
state_color: optional(boolean()),
color: optional(string()),
entities: array(entitiesConfigStruct),
})
);
@@ -69,7 +69,16 @@ const SCHEMA = [
{ name: "show_state", selector: { boolean: {} } },
],
},
{ name: "state_color", selector: { boolean: {} } },
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
include_none: true,
},
},
},
] as const;
@customElement("hui-glance-card-editor")
@@ -115,6 +124,15 @@ export class HuiGlanceCardEditor
icon_entity: "entity",
},
},
{
name: "color",
selector: {
ui_color: {
include_state: true,
include_none: true,
},
},
},
{ name: "show_last_changed", selector: { boolean: {} } },
{ name: "show_state", selector: { boolean: {} }, default: true },
],
@@ -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";
@@ -260,9 +261,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
@@ -14,7 +14,7 @@ export const entitiesConfigStruct = union([
image: optional(string()),
secondary_info: optional(string()),
time_format: optional(timeFormatConfigStruct),
state_color: optional(boolean()),
color: optional(string()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
@@ -1,12 +1,9 @@
import { customElement } from "lit/decorators";
import type { EntityCardConfig } from "../cards/types";
import type { EntitiesCardEntityConfig } from "../cards/types";
import { applyDefaultColor } from "../common/entity-color-config";
import { HuiConditionalBase } from "../components/hui-conditional-base";
import { createRowElement } from "../create-element/create-row-element";
import type {
ConditionalRowConfig,
EntityConfig,
LovelaceRow,
} from "../entity-rows/types";
import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
import { fireEvent } from "../../../common/dom/fire_event";
declare global {
@@ -23,13 +20,10 @@ class HuiConditionalRow extends HuiConditionalBase implements LovelaceRow {
throw new Error("No row configured");
}
const { color } = config as EntitiesCardEntityConfig;
this._element = createRowElement(
(config as EntityCardConfig).state_color
? ({
state_color: true,
...(config.row as EntityConfig),
} as EntityConfig)
: config.row
applyDefaultColor(config.row as EntitiesCardEntityConfig, color)
) as LovelaceRow;
}
+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
+27
View File
@@ -10140,6 +10140,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",
+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,225 @@
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",
});
});
it("migrates card and entity level state_color to color", () => {
expect(
migrateEntitiesCardConfig({
type: "entities",
state_color: true,
entities: [
"light.bed_light",
{ entity: "switch.ac", state_color: false },
{
entity: "sensor.humidity",
type: "simple-entity",
state_color: true,
},
],
} as unknown as EntitiesCardConfig)
).toEqual({
type: "entities",
color: "state",
entities: [
"light.bed_light",
{ entity: "switch.ac", color: "none" },
{ entity: "sensor.humidity", type: "simple-entity", color: "state" },
],
});
});
it("keeps an explicit color over the legacy state_color", () => {
expect(
migrateEntitiesCardConfig({
type: "entities",
entities: [{ entity: "switch.ac", state_color: true, color: "red" }],
} as unknown as EntitiesCardConfig)
).toEqual({
type: "entities",
entities: [{ entity: "switch.ac", color: "red" }],
});
});
it("keeps state_color on custom rows untouched", () => {
const customRow = {
entity: "sensor.power",
type: "custom:multiple-entity-row",
state_color: true,
};
const config = {
type: "entities",
entities: [customRow],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result).toBe(config);
expect(result.entities[0]).toEqual(customRow);
});
it("migrates conditional rows and their inner rows", () => {
expect(
migrateEntitiesCardConfig({
type: "entities",
entities: [
{
type: "conditional",
state_color: false,
conditions: [],
row: { entity: "light.bed_light", state_color: true },
},
],
} as unknown as EntitiesCardConfig)
).toEqual({
type: "entities",
entities: [
{
type: "conditional",
color: "none",
conditions: [],
row: { entity: "light.bed_light", color: "state" },
},
],
});
});
});
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",
});
});
it("migrates card and entity level state_color to color", () => {
expect(
migrateGlanceCardConfig({
type: "glance",
state_color: false,
entities: [
"light.bed_light",
{ entity: "switch.ac", state_color: true },
],
} as unknown as GlanceCardConfig)
).toEqual({
type: "glance",
color: "none",
entities: ["light.bed_light", { entity: "switch.ac", color: "state" }],
});
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { applyDefaultColor } from "../../../../src/panels/lovelace/common/entity-color-config";
describe("applyDefaultColor", () => {
interface TestEntityConfig {
entity: string;
type?: string;
color?: string;
}
it("applies the default color when none is set", () => {
const config: TestEntityConfig = { entity: "light.a" };
expect(applyDefaultColor(config, "state")).toEqual({
entity: "light.a",
color: "state",
});
});
it("keeps an explicit color", () => {
const config: TestEntityConfig = { entity: "light.a", color: "red" };
expect(applyDefaultColor(config, "state")).toBe(config);
});
it("returns the same object without a default color", () => {
const config: TestEntityConfig = { entity: "light.a" };
expect(applyDefaultColor(config, undefined)).toBe(config);
});
it("keeps custom elements untouched", () => {
const config: TestEntityConfig = { entity: "light.a", type: "custom:foo" };
expect(applyDefaultColor(config, "state")).toBe(config);
});
});
+329 -117
View File
@@ -2745,135 +2745,135 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/bigdecimal@npm:0.2.6":
version: 0.2.6
resolution: "@formatjs/bigdecimal@npm:0.2.6"
checksum: 10/5ef248f0feadeb1bceb9fa65ba36ccd1768a41ea6165bc58ae794564ebb09a67621d894fc94ab8b328cc7bba60e4c181847562c92b7cec5cd1208a4191fbbe67
"@formatjs/bigdecimal@npm:0.2.7":
version: 0.2.7
resolution: "@formatjs/bigdecimal@npm:0.2.7"
checksum: 10/e78fe804ed6805708b849fbd49006e981cfa736677bac29556f84bd6ca5271f848076f2f35de087197a559d434435bbfaecca2776563a5b46ecd6246bde7406d
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:3.1.6":
version: 3.1.6
resolution: "@formatjs/fast-memoize@npm:3.1.6"
checksum: 10/7dec3e82586d4c4889671c6081d7b0d87b2c229ba551d8328f96ae8ab58b2cd6056fc5d360c0b0c9688b7164f12ef517428016fbce15b950987a77005e481824
"@formatjs/fast-memoize@npm:3.1.7":
version: 3.1.7
resolution: "@formatjs/fast-memoize@npm:3.1.7"
checksum: 10/2d7ead48684539764ecb8a52178719169186595e830d9d6941eff12bec1ae31e20642a9cfd197007c48a8cc7e965bf61445ffcccc0fd77281341271d00e7b8fb
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.13":
version: 3.5.13
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.13"
"@formatjs/icu-messageformat-parser@npm:3.5.14":
version: 3.5.14
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.14"
dependencies:
"@formatjs/icu-skeleton-parser": "npm:2.1.10"
checksum: 10/b6451febdcfbe32571af5ae1c94cc560f6b3d815401321849c9dba229c460edf9ee4eca4c2d4da6dedefea416f2e3b10b4aa5507383e12b67042f941157e8045
"@formatjs/icu-skeleton-parser": "npm:2.1.11"
checksum: 10/e842d8ec0c17c174da0eb562e161f5216b31cb83546bf94fd3dd6e4b6493f8c0c404b9fb47b6b8106a607a83d305386bc1fb44abbc70453cf467d261e39d09c1
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:2.1.10":
version: 2.1.10
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.10"
checksum: 10/ec30d106ce38de80f4128d0cdfac15699628652807695843254bf0d31650bd0dc4b57e48691d164556234494d59b2816e710fa12321234c85b803c5cda32bedf
"@formatjs/icu-skeleton-parser@npm:2.1.11":
version: 2.1.11
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.11"
checksum: 10/492f42bd4ad72b2cdb4fd75a07e3874aa78e88f346027058f74c58e3828378092784176c9f7c77f4b5baf58ffaa1a4e8f5efcb53143552def2990510d0ac3c16
languageName: node
linkType: hard
"@formatjs/intl-datetimeformat@npm:7.4.10":
version: 7.4.10
resolution: "@formatjs/intl-datetimeformat@npm:7.4.10"
"@formatjs/intl-datetimeformat@npm:7.5.0":
version: 7.5.0
resolution: "@formatjs/intl-datetimeformat@npm:7.5.0"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/67fd55857555fe2651bc8ed5544fb036807d926542c92ccc07e475c14493d93a87d2cc805d04c812c6df68c9fdaeb4d64b399ac019f8c27940e2b872c9837618
"@formatjs/bigdecimal": "npm:0.2.7"
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/8f5bbcd2768609e466e5d03cd42880e62fa5c584ef57d716023a0f89ffff12036da41a248956cc1b3369b1af0155459464ac05d5f8fdfb42c0aa066d77ede815
languageName: node
linkType: hard
"@formatjs/intl-displaynames@npm:7.3.11":
version: 7.3.11
resolution: "@formatjs/intl-displaynames@npm:7.3.11"
"@formatjs/intl-displaynames@npm:7.3.12":
version: 7.3.12
resolution: "@formatjs/intl-displaynames@npm:7.3.12"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/3278e430d7f2bbb0656f5cf0a76a1a539a572c58f0d2473e2a175bc927de7e3a4a01823c3c98a38dc2878fd05ca3a6ebe83afa94dbd254733fe9195377064347
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/d802f9d44831fcb567efd7399b2f20da7872bf6cdf3cc8c5b38b188111fc8c38af704a79f55902caadd50edb8ebf2f830c473aecc5a93573ddbe1bcfba275793
languageName: node
linkType: hard
"@formatjs/intl-durationformat@npm:0.10.16":
version: 0.10.16
resolution: "@formatjs/intl-durationformat@npm:0.10.16"
"@formatjs/intl-durationformat@npm:0.10.17":
version: 0.10.17
resolution: "@formatjs/intl-durationformat@npm:0.10.17"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/8d0f5bbf24528c6d8e65636502de64b13e12c71854935db8bf2ce6bcc5b6e0e19533d0e0178c1079b1dd52214b025369c98e297b0e8484fa8ce29c9f6408c953
"@formatjs/bigdecimal": "npm:0.2.7"
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/ee3210da7b946a80995b36609a360a55d63f4626e3f40223215d782082f1f8254c49af6145f397ea6324b370038095145c6ff51fda8712e7b611f8609e1ac2b3
languageName: node
linkType: hard
"@formatjs/intl-getcanonicallocales@npm:3.2.10":
version: 3.2.10
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.10"
checksum: 10/dbf704d141bd4efc4e2687bd745d1a847a7b94955c23d2f06fe26add8e5ab8ad6096168babad72f2b4568f1fbee32c1528082269273b750bed4bdd1dc5b5d396
"@formatjs/intl-getcanonicallocales@npm:3.2.11":
version: 3.2.11
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.11"
checksum: 10/3132dba96c7cfe63787e126a91e620211a32fa6a623cb763f96102ff153845fb12486f378aa1d761736a7641adb19f5cc307156c8b7a40eedd00223b87c8d2a9
languageName: node
linkType: hard
"@formatjs/intl-listformat@npm:8.3.11":
version: 8.3.11
resolution: "@formatjs/intl-listformat@npm:8.3.11"
"@formatjs/intl-listformat@npm:8.3.12":
version: 8.3.12
resolution: "@formatjs/intl-listformat@npm:8.3.12"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/a08a74c22bb01871944cad91fcb87342e30f3f6792375af3028737794a56b2dec6b7506c8e2b9d0495f4eff50f4d63542fcf86116cd4fdf1d4d8b6838a54ac5c
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/3ae640ab968b3051fd1b2d150d892a087d5a4feecb8bdf4a6a61726888996ee0b054de43e35548374d0a61d8577b5a48f23abfb9506c1560f88d64c0809691f1
languageName: node
linkType: hard
"@formatjs/intl-locale@npm:5.3.9":
version: 5.3.9
resolution: "@formatjs/intl-locale@npm:5.3.9"
"@formatjs/intl-locale@npm:5.3.10":
version: 5.3.10
resolution: "@formatjs/intl-locale@npm:5.3.10"
dependencies:
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
"@formatjs/intl-supportedvaluesof": "npm:2.3.8"
checksum: 10/e2af858ec3ff75611d5412a1e39abce882eab2cf13eb278e4cd1647481321823cc2303c74e86c38b95662e668f6900f9d666136debb2377231d5ddf0bdba3218
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
"@formatjs/intl-supportedvaluesof": "npm:2.3.9"
checksum: 10/f7d8be5ea145089b9daa56e24158f835ed0f3d67d3230b42c3a8a32f3dbdd98534236e9262d86f628288d09968fec32d1c2f5906ec1d5f38e692d436a4052255
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.8.11":
version: 0.8.11
resolution: "@formatjs/intl-localematcher@npm:0.8.11"
"@formatjs/intl-localematcher@npm:0.8.12":
version: 0.8.12
resolution: "@formatjs/intl-localematcher@npm:0.8.12"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.6"
checksum: 10/482b819100997439b2c7295c8112b43c81f64083644cde7124ef1afecaa8fc663bc7b9820b6386d02b45872db7a943c3f1f43a4f78780fe52ed4873ef422e7dc
"@formatjs/fast-memoize": "npm:3.1.7"
checksum: 10/4f9ecb936ac76cb978460edacbb2704e9760f65203e2c17f96b8db605a3c344ecc7da2ac78c897316f519b033c623b39062ba720e23fe0c2162af7c761f765e6
languageName: node
linkType: hard
"@formatjs/intl-numberformat@npm:9.3.12":
version: 9.3.12
resolution: "@formatjs/intl-numberformat@npm:9.3.12"
"@formatjs/intl-numberformat@npm:9.3.13":
version: 9.3.13
resolution: "@formatjs/intl-numberformat@npm:9.3.13"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/6a9f3115136ce9f74fab520d5eeddb6a3f23d32492b004c9c6f108a19dd18e9ccaec04582569238f089b3028cc68783a2b75328b3ce38b13bf30d6267cb33b20
"@formatjs/bigdecimal": "npm:0.2.7"
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/59530d3aec0411a2dae8f2113829b969dceec3ec4a59032cc985ef1b9cebec6d406fbe4dd44ac69e905b4f3b3edb333d61f728a5610fc1e1e132e4bdcb2436aa
languageName: node
linkType: hard
"@formatjs/intl-pluralrules@npm:6.3.11":
version: 6.3.11
resolution: "@formatjs/intl-pluralrules@npm:6.3.11"
"@formatjs/intl-pluralrules@npm:6.3.12":
version: 6.3.12
resolution: "@formatjs/intl-pluralrules@npm:6.3.12"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/c5506c1a20f1aa7fdca24b6800243aec5cbacd5aa0de96d01b497fa9485f6affa7abf6b0d2ea04ebff0ec8a2d22a41913f4353b9fd9c6054899a9f168ef2ebd0
"@formatjs/bigdecimal": "npm:0.2.7"
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/23833b17822a648fc4e87dfe581733f303a8d1c4721a47e875994bcb56c5985c83ea01f0ab176557886fc6b2ef158ce50f9cafa1e1441badefddec0ea79c5303
languageName: node
linkType: hard
"@formatjs/intl-relativetimeformat@npm:12.3.11":
version: 12.3.11
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.11"
"@formatjs/intl-relativetimeformat@npm:12.3.12":
version: 12.3.12
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.12"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/ead23de113c9c28334ff2f8696e0f5ff0567f2b929def10d06e67dba2ca502967671abb1bb6ad06b200329e0215b469edd3c3ad9ed4259b313e97f2badfdab53
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/c667ed62a13cbc8cd5d07f5dbc57a4d1b7e23c31f2aac0ef9e5f509feec014c5a2dfd77ac41a2c34fe537d6f1f6d8ebc8391a479a4294867bb4e980c7211f822
languageName: node
linkType: hard
"@formatjs/intl-supportedvaluesof@npm:2.3.8":
version: 2.3.8
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.8"
"@formatjs/intl-supportedvaluesof@npm:2.3.9":
version: 2.3.9
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.9"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.6"
checksum: 10/d9d4e4d5dda1c26c771d0f2746e1031f9695dee663fb2d2ef9f83794c1e8683de88caf6cdb9718022c592cc879c5c1afa7f72af4c77da9ce5e1d98b54267dfff
"@formatjs/fast-memoize": "npm:3.1.7"
checksum: 10/36254064dd8aab3cb2cbe31db88d751cb0c1d2b0739563ddd1736ed7bd8ec02881de385a69cc4ab7b691f670a3f11ec61a41ba678baa3d0967d87dc0b395cc7b
languageName: node
linkType: hard
@@ -5876,6 +5876,217 @@ __metadata:
languageName: node
linkType: hard
"@typescript/native@npm:typescript@7.0.2":
version: 7.0.2
resolution: "typescript@npm:7.0.2"
dependencies:
"@typescript/typescript-aix-ppc64": "npm:7.0.2"
"@typescript/typescript-darwin-arm64": "npm:7.0.2"
"@typescript/typescript-darwin-x64": "npm:7.0.2"
"@typescript/typescript-freebsd-arm64": "npm:7.0.2"
"@typescript/typescript-freebsd-x64": "npm:7.0.2"
"@typescript/typescript-linux-arm": "npm:7.0.2"
"@typescript/typescript-linux-arm64": "npm:7.0.2"
"@typescript/typescript-linux-loong64": "npm:7.0.2"
"@typescript/typescript-linux-mips64el": "npm:7.0.2"
"@typescript/typescript-linux-ppc64": "npm:7.0.2"
"@typescript/typescript-linux-riscv64": "npm:7.0.2"
"@typescript/typescript-linux-s390x": "npm:7.0.2"
"@typescript/typescript-linux-x64": "npm:7.0.2"
"@typescript/typescript-netbsd-arm64": "npm:7.0.2"
"@typescript/typescript-netbsd-x64": "npm:7.0.2"
"@typescript/typescript-openbsd-arm64": "npm:7.0.2"
"@typescript/typescript-openbsd-x64": "npm:7.0.2"
"@typescript/typescript-sunos-x64": "npm:7.0.2"
"@typescript/typescript-win32-arm64": "npm:7.0.2"
"@typescript/typescript-win32-x64": "npm:7.0.2"
dependenciesMeta:
"@typescript/typescript-aix-ppc64":
optional: true
"@typescript/typescript-darwin-arm64":
optional: true
"@typescript/typescript-darwin-x64":
optional: true
"@typescript/typescript-freebsd-arm64":
optional: true
"@typescript/typescript-freebsd-x64":
optional: true
"@typescript/typescript-linux-arm":
optional: true
"@typescript/typescript-linux-arm64":
optional: true
"@typescript/typescript-linux-loong64":
optional: true
"@typescript/typescript-linux-mips64el":
optional: true
"@typescript/typescript-linux-ppc64":
optional: true
"@typescript/typescript-linux-riscv64":
optional: true
"@typescript/typescript-linux-s390x":
optional: true
"@typescript/typescript-linux-x64":
optional: true
"@typescript/typescript-netbsd-arm64":
optional: true
"@typescript/typescript-netbsd-x64":
optional: true
"@typescript/typescript-openbsd-arm64":
optional: true
"@typescript/typescript-openbsd-x64":
optional: true
"@typescript/typescript-sunos-x64":
optional: true
"@typescript/typescript-win32-arm64":
optional: true
"@typescript/typescript-win32-x64":
optional: true
bin:
tsc: bin/tsc
checksum: 10/b0179005349b43dc1febb35c4e79162cdf89b84eae60f6deac142429109041e07582e69a6aacf5a897e085869686d4512ea444a10acf5b5aa2b60910f82a19ca
languageName: node
linkType: hard
"@typescript/typescript-aix-ppc64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-aix-ppc64@npm:7.0.2"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
"@typescript/typescript-darwin-arm64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-darwin-arm64@npm:7.0.2"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@typescript/typescript-darwin-x64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-darwin-x64@npm:7.0.2"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@typescript/typescript-freebsd-arm64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-freebsd-arm64@npm:7.0.2"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@typescript/typescript-freebsd-x64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-freebsd-x64@npm:7.0.2"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@typescript/typescript-linux-arm64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-linux-arm64@npm:7.0.2"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"@typescript/typescript-linux-arm@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-linux-arm@npm:7.0.2"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@typescript/typescript-linux-loong64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-linux-loong64@npm:7.0.2"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
"@typescript/typescript-linux-mips64el@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-linux-mips64el@npm:7.0.2"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
"@typescript/typescript-linux-ppc64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-linux-ppc64@npm:7.0.2"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
"@typescript/typescript-linux-riscv64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-linux-riscv64@npm:7.0.2"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
"@typescript/typescript-linux-s390x@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-linux-s390x@npm:7.0.2"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
"@typescript/typescript-linux-x64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-linux-x64@npm:7.0.2"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@typescript/typescript-netbsd-arm64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-netbsd-arm64@npm:7.0.2"
conditions: os=netbsd & cpu=arm64
languageName: node
linkType: hard
"@typescript/typescript-netbsd-x64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-netbsd-x64@npm:7.0.2"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
"@typescript/typescript-openbsd-arm64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-openbsd-arm64@npm:7.0.2"
conditions: os=openbsd & cpu=arm64
languageName: node
linkType: hard
"@typescript/typescript-openbsd-x64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-openbsd-x64@npm:7.0.2"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
"@typescript/typescript-sunos-x64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-sunos-x64@npm:7.0.2"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
"@typescript/typescript-win32-arm64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-win32-arm64@npm:7.0.2"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@typescript/typescript-win32-x64@npm:7.0.2":
version: 7.0.2
resolution: "@typescript/typescript-win32-x64@npm:7.0.2"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@unrs/resolver-binding-android-arm-eabi@npm:1.12.2":
version: 1.12.2
resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.12.2"
@@ -6860,12 +7071,12 @@ __metadata:
languageName: node
linkType: hard
"barcode-detector@npm:3.2.0":
version: 3.2.0
resolution: "barcode-detector@npm:3.2.0"
"barcode-detector@npm:3.2.1":
version: 3.2.1
resolution: "barcode-detector@npm:3.2.1"
dependencies:
zxing-wasm: "npm:3.1.0"
checksum: 10/f52eb18ddae2af3d4c9c76b47e7b639d0834cd32f558901d8a23cc00349047e53a6da5c3958653fa524dcb912ed9178f3e0d37939b9be00f9607772a84d90ccf
zxing-wasm: "npm:3.1.1"
checksum: 10/17ba87cea89d3068794bb106851b09db0744fc4de1ae2998835564ac662876d8d9437ed89f7984c6765c941f2b366b0d33a4f5e8cc8d1c5a05f073a2b628cfce
languageName: node
linkType: hard
@@ -8681,9 +8892,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.6.0":
version: 10.6.0
resolution: "eslint@npm:10.6.0"
"eslint@npm:10.7.0":
version: 10.7.0
resolution: "eslint@npm:10.7.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -8722,7 +8933,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/36d02cdbe37668e601f255917c605d10a5d06d278648dc72cec21fe23b7b60a53453241b32c382ba8107533cacba70aeec97581cc4f4bb591544b1ada2516335
checksum: 10/51cb70fbdd0de6586f0cb12625388faab18eb679cbdb523ecb631cae9f47fd9171d9ff02f570dc4d2e5700017908a81477511025ccb2a6603c5928fbfddcaebf
languageName: node
linkType: hard
@@ -9736,15 +9947,15 @@ __metadata:
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
"@eslint/js": "npm:10.0.1"
"@formatjs/intl-datetimeformat": "npm:7.4.10"
"@formatjs/intl-displaynames": "npm:7.3.11"
"@formatjs/intl-durationformat": "npm:0.10.16"
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
"@formatjs/intl-listformat": "npm:8.3.11"
"@formatjs/intl-locale": "npm:5.3.9"
"@formatjs/intl-numberformat": "npm:9.3.12"
"@formatjs/intl-pluralrules": "npm:6.3.11"
"@formatjs/intl-relativetimeformat": "npm:12.3.11"
"@formatjs/intl-datetimeformat": "npm:7.5.0"
"@formatjs/intl-displaynames": "npm:7.3.12"
"@formatjs/intl-durationformat": "npm:0.10.17"
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
"@formatjs/intl-listformat": "npm:8.3.12"
"@formatjs/intl-locale": "npm:5.3.10"
"@formatjs/intl-numberformat": "npm:9.3.13"
"@formatjs/intl-pluralrules": "npm:6.3.12"
"@formatjs/intl-relativetimeformat": "npm:12.3.12"
"@fullcalendar/core": "npm:6.1.21"
"@fullcalendar/daygrid": "npm:6.1.21"
"@fullcalendar/interaction": "npm:6.1.21"
@@ -9792,6 +10003,7 @@ __metadata:
"@types/qrcode": "npm:1.5.6"
"@types/sortablejs": "npm:1.15.9"
"@types/tar": "npm:7.0.87"
"@typescript/native": "npm:typescript@7.0.2"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.10"
"@vvo/tzdb": "npm:6.198.0"
@@ -9799,7 +10011,7 @@ __metadata:
"@webcomponents/webcomponentsjs": "npm:2.8.0"
babel-loader: "npm:10.1.1"
babel-plugin-polyfill-corejs3: "npm:1.0.0"
barcode-detector: "npm:3.2.0"
barcode-detector: "npm:3.2.1"
browserslist-useragent-regexp: "npm:4.1.4"
cally: "npm:0.9.2"
color-name: "npm:2.1.0"
@@ -9814,7 +10026,7 @@ __metadata:
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.6.0"
eslint: "npm:10.7.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"
@@ -9838,7 +10050,7 @@ __metadata:
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.10"
intl-messageformat: "npm:11.2.11"
js-yaml: "npm:5.2.1"
jsdom: "npm:29.1.1"
jszip: "npm:3.10.1"
@@ -9872,7 +10084,7 @@ __metadata:
sortablejs: "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch"
stacktrace-js: "npm:2.0.2"
superstruct: "npm:2.0.2"
tar: "npm:7.5.19"
tar: "npm:7.5.20"
terser-webpack-plugin: "npm:5.6.1"
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
@@ -10186,13 +10398,13 @@ __metadata:
languageName: node
linkType: hard
"intl-messageformat@npm:11.2.10":
version: 11.2.10
resolution: "intl-messageformat@npm:11.2.10"
"intl-messageformat@npm:11.2.11":
version: 11.2.11
resolution: "intl-messageformat@npm:11.2.11"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.6"
"@formatjs/icu-messageformat-parser": "npm:3.5.13"
checksum: 10/d3f751ebfe2015cf4a011afe5679398dfa7156c0bc2ccd76ba8c5aa4e82946bd663e750538c97a8b4c946a6d5f5d19fbb56b2e159d422c416e51bba9b10b2746
"@formatjs/fast-memoize": "npm:3.1.7"
"@formatjs/icu-messageformat-parser": "npm:3.5.14"
checksum: 10/5074494588d6c16fb2730234b3c8eb80799189c9c21485323b5c119c22d3a7a8b385868b46479b81c09ca350d82ea0eeaf82b5e9799911b9a1b8bddbb2c3ad1c
languageName: node
linkType: hard
@@ -14427,16 +14639,16 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:*, tar@npm:7.5.19, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.19
resolution: "tar@npm:7.5.19"
"tar@npm:*, tar@npm:7.5.20, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.20
resolution: "tar@npm:7.5.20"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10/0b06a0917fe68a4dff361e147db30fd67ae2ee85ab2863d62046a6ccef46f0d1906eed20f92277a436300eaaa0e3cd31d8763d7f02fa389f41d7966e58388db8
checksum: 10/90a0fe423ac921197ad5eefc5e5f7ad7f42b06e80444c8c347c1e4112384cbe9cb53ade3ef71748eed3684888756869c2aeef6b6303c766101f3217e254d4be9
languageName: node
linkType: hard
@@ -14814,12 +15026,12 @@ __metadata:
languageName: node
linkType: hard
"type-fest@npm:^5.7.0":
version: 5.7.0
resolution: "type-fest@npm:5.7.0"
"type-fest@npm:^5.8.0":
version: 5.8.0
resolution: "type-fest@npm:5.8.0"
dependencies:
tagged-tag: "npm:^1.0.0"
checksum: 10/4867626aa489968df98e09ecdefbc45dfbb191ae5fb8924b3bd45da9cd940879b387086226366dce028570983a3fbe80adc53ad105a169bbbd27621c496bd6f0
checksum: 10/4dbe4c78ac6933d3d165b01f9d552991a84d63e5352110e01bebb5c46499fb1f44d199fc88835b18ee5662ee44c73926621f59525e46f7faa031086ac00b2686
languageName: node
linkType: hard
@@ -16302,14 +16514,14 @@ __metadata:
languageName: node
linkType: hard
"zxing-wasm@npm:3.1.0":
version: 3.1.0
resolution: "zxing-wasm@npm:3.1.0"
"zxing-wasm@npm:3.1.1":
version: 3.1.1
resolution: "zxing-wasm@npm:3.1.1"
dependencies:
"@types/emscripten": "npm:^1.41.5"
type-fest: "npm:^5.7.0"
type-fest: "npm:^5.8.0"
peerDependencies:
"@types/emscripten": ">=1.39.6"
checksum: 10/ea68d0cfbe31d8dabcd9b942dcfdb703866c1f76ee0d804fb75f1e49f092a26771575705dd48d69927bc6525f146fd9e35030a5a72341222716d7f62e5f6c788
checksum: 10/1a714b50156ca7dce5eecb1c1b1c843925c75b354cd030d743bab905e16eb20c9c65646260574aeb21b03639f17d790412b945b9d32890ffdba3ec9d7643fca3
languageName: node
linkType: hard