Compare commits

...

28 Commits

Author SHA1 Message Date
Aidan Timson ef22539175 Remove hass from clock card editor 2026-07-17 14:22:42 +01:00
Aidan Timson 7346e9408a Use conditional visibility in clock card editor 2026-07-17 14:18:09 +01:00
Paul Bottein 6b0f543fdb Add conditional field visibility to ha-form (#53165) 2026-07-17 14:06:04 +01:00
Bram Kragten bbd26ce5a9 Improve messaging and error handling http config (#53126)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-07-17 14:38:25 +02:00
pcan08 d3c23dd704 Limit clock card date picker to one value per group (#53160) 2026-07-17 08:53:28 +01:00
Paulus Schoutsen 7d4e5dc03f Use /store/reload instead of legacy /addons/reload alias when checking for add-on updates (#53162)
Use /store/reload instead of legacy /addons/reload alias

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


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

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

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

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

Fixes #52935

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

* Extract entities/glance format migration into a testable module

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

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

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

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

---------

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

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

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

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

* Update create session test for WebSocket-only path

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

* Fix ts-ignore placement in create session test

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:23:32 +02:00
Aidan Timson 4327871c30 Add clear/reset action to Activity panel (#53137) 2026-07-15 11:56:24 +01:00
Bruno Pantaleão Gonçalves 88381e0b11 Revert "Add App settings entry to dashboard more menu" (#53135) 2026-07-15 08:11:33 +00:00
Bruno Pantaleão Gonçalves f44590a2aa Add App settings entry to dashboard more menu (#53130) 2026-07-15 08:39:52 +01:00
Bram Kragten 1c03daac41 Add safe area handling and opt-out to custom panels and apps (#53127) 2026-07-15 08:35:54 +01:00
Yosi Levy a2216c6bf5 Minor RTL fix (#53132) 2026-07-15 08:33:08 +01:00
renovate[bot] 4a755e00ac Update dependency tar to v7.5.20 (#53133) 2026-07-15 08:28:09 +01:00
79 changed files with 3899 additions and 1397 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
if: github.event_name != 'push' || github.ref_name != 'master'
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.netlify_url }}
url: ${{ steps.deploy.outputs.unique_deploy_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+1 -1
View File
@@ -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 () => {};
+7 -6
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",
@@ -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",
@@ -102,7 +102,7 @@
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.2",
"fuse.js": "7.5.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
@@ -146,7 +146,7 @@
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.0.0",
"@lokalise/node-api": "16.1.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
@@ -168,6 +168,7 @@
"@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",
@@ -206,11 +207,11 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.19",
"tar": "7.5.20",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.63.0",
"typescript-eslint": "8.64.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
+3 -2
View File
@@ -51,8 +51,9 @@ class StorageClass {
storageKey: string,
callback: Callback
): UnsubscribeFunc {
if (this._listeners[storageKey]) {
this._listeners[storageKey].push(callback);
const listeners = this._listeners[storageKey];
if (listeners) {
listeners.push(callback);
} else {
this._listeners[storageKey] = [callback];
}
+52 -3
View File
@@ -1,4 +1,53 @@
const regexp =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// Unanchored regex fragments, shared as the single source of truth for both
// the boolean validators below and the HTML `pattern` attribute (the browser
// anchors a pattern as `^(?:…)$`).
const IPV4 =
"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
export const isIPAddress = (input: string): boolean => regexp.test(input);
const IPV6 =
"(?:([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
// CIDR prefix lengths: 0-32 for IPv4, 0-128 for IPv6.
const IPV4_PREFIX = "(?:3[0-2]|[12]?[0-9])";
const IPV6_PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])";
// IPv4 or IPv6 address.
export const IP_ADDRESS_PATTERN = `${IPV4}|${IPV6}`;
// IPv4/IPv6 address, optionally with a CIDR prefix (network).
export const IP_ADDRESS_OR_NETWORK_PATTERN = `${IPV4}(?:/${IPV4_PREFIX})?|${IPV6}(?:/${IPV6_PREFIX})?`;
const anchored = (pattern: string): RegExp => new RegExp(`^(?:${pattern})$`);
const ipv4Regexp = anchored(IPV4);
const ipv6Regexp = anchored(IPV6);
// IPv4 address, e.g. 192.168.1.10
export const isIPAddress = (input: string): boolean => ipv4Regexp.test(input);
// IPv6 address, e.g. fe80::85d:e82c:9446:7995
export const isIPv6Address = (input: string): boolean => ipv6Regexp.test(input);
// IPv4 or IPv6 address
export const isIPAddressV4OrV6 = (input: string): boolean =>
isIPAddress(input) || isIPv6Address(input);
// IP network in CIDR notation, e.g. 192.168.1.0/24 or fd00::/8
export const isIPNetwork = (input: string): boolean => {
const parts = input.split("/");
if (parts.length !== 2) {
return false;
}
const [address, prefix] = parts;
if (!/^\d{1,3}$/.test(prefix)) {
return false;
}
const prefixLength = Number(prefix);
if (isIPAddress(address)) {
return prefixLength >= 0 && prefixLength <= 32;
}
if (isIPv6Address(address)) {
return prefixLength >= 0 && prefixLength <= 128;
}
return false;
};
+4 -5
View File
@@ -33,13 +33,12 @@ const extractVarFromBase = (
varName: string,
baseVars: Record<string, string>
): string | undefined => {
if (baseVars[varName] && baseVars[varName].startsWith("var(")) {
const baseVarName = baseVars[varName]
.substring(6, baseVars[varName].length - 1)
.trim();
const value = baseVars[varName];
if (value && value.startsWith("var(")) {
const baseVarName = value.substring(6, value.length - 1).trim();
return extractVarFromBase(baseVarName, baseVars);
}
return baseVars[varName];
return value;
};
/**
@@ -0,0 +1,579 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import { configContext, internationalizationContext } from "../data/context";
import {
CLOCK_CARD_DATE_PARTS,
formatClockCardDate,
} from "../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-assist-chip";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-input-helper-text";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
"month",
"year",
"weekday",
"separator",
];
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "",
};
const getClockDatePartSection = (
part: ClockCardDatePart
): ClockDatePartSection => {
if (part.startsWith("weekday-")) {
return "weekday";
}
if (part.startsWith("day-")) {
return "day";
}
if (part.startsWith("month-")) {
return "month";
}
if (part.startsWith("year-")) {
return "year";
}
return "separator";
};
interface ClockDatePartSectionData {
id: ClockDatePartSection;
title: string;
items: PickerComboBoxItem[];
}
/**
* Filters out sections whose group already has a value in `value`, so only
* one non-separator value per group can be selected in the picker. The whole
* group of the item at `excludeIndex` (the one being edited) stays
* selectable, even if that group has more than one value in `value` (e.g. a
* pre-existing config authored outside the picker via YAML).
* The separator group is always kept.
*/
export const getAvailableClockDatePartSections = (
sections: ClockDatePartSectionData[],
value: string[],
excludeIndex?: number
): ClockDatePartSectionData[] => {
const editedItem = excludeIndex != null ? value[excludeIndex] : undefined;
const editedSection = editedItem
? getClockDatePartSection(editedItem as ClockCardDatePart)
: undefined;
const usedSections = new Set<ClockDatePartSection>();
value.forEach((item) => {
const section = getClockDatePartSection(item as ClockCardDatePart);
if (section !== "separator" && section !== editedSection) {
usedSections.add(section);
}
});
return sections.filter(
(sectionData) =>
sectionData.id === "separator" || !usedSections.has(sectionData.id)
);
};
interface ClockDatePartValueItem {
key: string;
item: string;
idx: number;
}
@customElement("ha-clock-date-format-picker")
export class HaClockDateFormatPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public label?: string;
@property() public value?: string[] | string;
@property() public helper?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
@state() private _editIndex?: number;
protected render() {
const value = this._value;
const valueItems = this._getValueItems(value);
const sections = this._buildSections();
const pickerSections = getAvailableClockDatePartSections(
sections,
value,
this._editIndex
);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
.sections=${this._getSectionHeaders(pickerSections)}
.getItems=${this._getItems(pickerSections)}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
valueItems,
(entry: ClockDatePartValueItem) => entry.key,
({ item, idx }) => this._renderValueChip(item, idx, sections)
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._i18n.localize("ui.common.add")}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private _getValueItems = memoizeOne(
(value: string[]): ClockDatePartValueItem[] => {
const occurrences = new Map<string, number>();
return value.map((item, idx) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);
return {
key: `${item}:${occurrence}`,
item,
idx,
};
});
}
);
private _renderValueChip(
item: string,
idx: number,
sections: ClockDatePartSectionData[]
) {
const label = this._getItemLabel(item, sections);
const isValid = !!label;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label ?? item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private get _value() {
return !this.value ? [] : ensureArray(this.value);
}
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
value.length === 0 ? undefined : value
);
private _buildSections(): ClockDatePartSectionData[] {
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
weekday: [],
day: [],
month: [],
year: [],
separator: [],
};
const previewDate = new Date();
const previewTimeZone = resolveTimeZone(
this._i18n.locale.time_zone,
this._hassConfig.config.time_zone
);
CLOCK_CARD_DATE_PARTS.forEach((part) => {
const section = getClockDatePartSection(part);
const label =
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
) ?? part;
const secondary =
section === "separator"
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
: formatClockCardDate(
previewDate,
{ parts: [part] },
this._i18n.locale.language,
previewTimeZone
);
itemsBySection[section].push({
id: part,
primary: label,
secondary,
sorting_label: label,
});
});
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
id: section,
title:
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
) ?? section,
items: itemsBySection[section],
})).filter((section) => section.items.length > 0);
}
private _getSectionHeaders(
sections: ClockDatePartSectionData[]
): { id: string; label: string }[] {
return sections.map((section) => ({
id: section.id,
label: section.title,
}));
}
private _getItems = memoizeOne(
(sections: ClockDatePartSectionData[]) =>
(
searchString?: string,
section?: string
): (PickerComboBoxItem | string)[] => {
const normalizedSearch = searchString?.trim().toLowerCase();
const filteredSections = sections
.map((sectionData) => {
if (!normalizedSearch) {
return sectionData;
}
return {
...sectionData,
items: sectionData.items.filter(
(item) =>
item.primary.toLowerCase().includes(normalizedSearch) ||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
item.id.toLowerCase().includes(normalizedSearch)
),
};
})
.filter((sectionData) => sectionData.items.length > 0);
if (section) {
return (
filteredSections.find((candidate) => candidate.id === section)
?.items || []
);
}
const groupedItems: (PickerComboBoxItem | string)[] = [];
filteredSections.forEach((sectionData) => {
groupedItems.push(sectionData.title, ...sectionData.items);
});
return groupedItems;
}
);
private _getItemLabel(
value: string,
sections: ClockDatePartSectionData[]
): string | undefined {
for (const section of sections) {
const item = section.items.find((candidate) => candidate.id === value);
if (item) {
if (section.id === "separator") {
if (value === "separator-new-line") {
return item.primary;
}
return item.secondary ?? item.primary;
}
return `${item.secondary} [${item.primary} ${section.title}]`;
}
}
return undefined;
}
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
return this._value[this._editIndex];
}
return undefined;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.preventDefault();
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
if (Number.isNaN(idx)) {
return;
}
const value = [...this._value];
value.splice(idx, 1);
if (this._editIndex !== undefined) {
if (this._editIndex === idx) {
this._editIndex = undefined;
} else if (this._editIndex > idx) {
this._editIndex -= 1;
}
}
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const newValue = [...this._value];
if (this._editIndex != null) {
newValue[this._editIndex] = value;
this._editIndex = undefined;
} else {
newValue.push(value);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: string[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transition:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-clock-date-format-picker": HaClockDateFormatPicker;
}
}
+73
View File
@@ -0,0 +1,73 @@
import type {
HaFormBaseSchema,
HaFormCondition,
HaFormDataContainer,
HaFormFieldCondition,
HaFormSchema,
} from "./types";
const isEmpty = (value: unknown): boolean =>
value === undefined || value === null || value === "";
const matchFieldCondition = (
condition: HaFormFieldCondition,
data: HaFormDataContainer | undefined
): boolean => {
const actual = data?.[condition.field];
switch (condition.operator ?? "eq") {
case "eq":
return actual === condition.value;
case "not_eq":
return actual !== condition.value;
case "in":
return (
Array.isArray(condition.value) &&
condition.value.includes(actual as any)
);
case "not_in":
return (
Array.isArray(condition.value) &&
!condition.value.includes(actual as any)
);
case "exists":
return !isEmpty(actual);
case "not_exists":
return isEmpty(actual);
default:
return false;
}
};
export const evaluateCondition = (
condition: HaFormCondition,
data: HaFormDataContainer | undefined
): boolean => {
if ("condition" in condition) {
switch (condition.condition) {
case "and":
return condition.conditions.every((c) => evaluateCondition(c, data));
case "or":
return condition.conditions.some((c) => evaluateCondition(c, data));
case "not":
return !condition.conditions.some((c) => evaluateCondition(c, data));
default:
return false;
}
}
return matchFieldCondition(condition, data);
};
export const isFieldHidden = (
schema: HaFormSchema,
data: HaFormDataContainer | undefined
): boolean => {
const { hidden } = schema as HaFormBaseSchema;
if (!hidden) {
return false;
}
if (hidden === true) {
return true;
}
const conditions = Array.isArray(hidden) ? hidden : [hidden];
return conditions.every((condition) => evaluateCondition(condition, data));
};
+16 -13
View File
@@ -2,6 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import { isFieldHidden } from "./conditions";
import "./ha-form";
import type { HaForm } from "./ha-form";
import type {
@@ -68,19 +69,21 @@ export class HaFormGrid extends LitElement implements HaFormElement {
protected render(): TemplateResult {
return html`
${this.schema.schema.map(
(item) => html`
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${[item]}
.disabled=${this.disabled}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
`
)}
${this.schema.schema
.filter((item) => !isFieldHidden(item, this.data))
.map(
(item) => html`
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${[item]}
.disabled=${this.disabled}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
`
)}
`;
}
+11 -2
View File
@@ -1,11 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-selector/ha-selector";
import { isFieldHidden } from "./conditions";
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
const LOAD_ELEMENTS = {
@@ -98,7 +99,11 @@ export class HaForm extends LitElement implements HaFormElement {
let isValid = true;
let firstInvalidElement: HTMLElement | undefined;
this.schema.forEach((item, index) => {
const visibleSchema = this.schema.filter(
(item) => !isFieldHidden(item, this.data)
);
visibleSchema.forEach((item, index) => {
const element = elements[index];
if (!element) {
return;
@@ -164,6 +169,10 @@ export class HaForm extends LitElement implements HaFormElement {
: ""
}
${this.schema.map((item) => {
if (isFieldHidden(item, this.data)) {
return nothing;
}
const error = getError(this.error, item);
const warning = getWarning(this.warning, item);
+33
View File
@@ -22,6 +22,9 @@ export interface HaFormBaseSchema {
default?: HaFormData;
required?: boolean;
disabled?: boolean;
// Field is hidden while the condition holds. Serializable so it can be
// shared with the backend and other renderers.
hidden?: boolean | HaFormCondition | HaFormCondition[];
description?: {
suffix?: string;
// This value will be set initially when form is loaded
@@ -30,6 +33,36 @@ export interface HaFormBaseSchema {
context?: Record<string, string>;
}
export type HaFormConditionOperator =
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
export interface HaFormFieldCondition {
field: string;
operator?: HaFormConditionOperator;
value?: HaFormData | readonly HaFormData[];
}
export interface HaFormAndCondition {
condition: "and";
conditions: readonly HaFormCondition[];
}
export interface HaFormOrCondition {
condition: "or";
conditions: readonly HaFormCondition[];
}
export interface HaFormNotCondition {
condition: "not";
conditions: readonly HaFormCondition[];
}
export type HaFormCondition =
| HaFormFieldCondition
| HaFormAndCondition
| HaFormOrCondition
| HaFormNotCondition;
export interface HaFormGridSchema extends HaFormBaseSchema {
type: "grid";
flatten?: boolean;
@@ -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;
+10 -1
View File
@@ -28,6 +28,10 @@ export class HaTextSelector extends LitElement {
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
@query("ha-input-multi") private _inputMulti?: {
reportValidity: () => boolean;
};
public async focus() {
await this.updateComplete;
this._input?.focus();
@@ -35,7 +39,7 @@ export class HaTextSelector extends LitElement {
public reportValidity(): boolean {
if (this.selector.text?.multiple) {
return true;
return this._inputMulti?.reportValidity() ?? true;
}
return this._input?.reportValidity() ?? true;
}
@@ -52,6 +56,8 @@ export class HaTextSelector extends LitElement {
.inputPrefix=${this.selector.text?.prefix}
.helper=${this.helper}
.autocomplete=${this.selector.text?.autocomplete}
.pattern=${this.selector.text?.pattern}
.validationMessage=${this.selector.text?.validation_message}
@value-changed=${this._handleChange}
>
</ha-input-multi>
@@ -80,6 +86,9 @@ export class HaTextSelector extends LitElement {
.hint=${this.helper}
.disabled=${this.disabled}
.type=${this.selector.text?.type}
.pattern=${this.selector.text?.pattern}
.validationMessage=${this.selector.text?.validation_message}
.autoValidate=${this.selector.text?.pattern !== undefined}
@input=${this._handleChange}
@change=${this._handleChange}
.label=${this.label || ""}
@@ -0,0 +1,41 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { UiClockDateFormatSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-clock-date-format-picker";
@customElement("ha-selector-ui_clock_date_format")
export class HaSelectorUiClockDateFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-clock-date-format-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-clock-date-format-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
}
}
@@ -65,6 +65,7 @@ const LOAD_ELEMENTS = {
location: () => import("./ha-selector-location"),
color_temp: () => import("./ha-selector-color-temp"),
ui_action: () => import("./ha-selector-ui-action"),
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
+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);
});
}
+29 -1
View File
@@ -2,7 +2,13 @@ import { consume, type ContextType } from "@lit/context";
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { uid } from "../../common/util/uid";
@@ -50,6 +56,13 @@ class HaInputMulti extends LitElement {
@property() public autocomplete?: string;
/** Regular expression each entry is validated against (HTML `pattern`). */
@property() public pattern?: string;
/** Message shown on an entry when it fails `pattern` validation. */
@property({ attribute: "validation-message" })
public validationMessage?: string;
@property({ attribute: "add-label" }) public addLabel?: string;
@property({ attribute: "remove-label" }) public removeLabel?: string;
@@ -70,6 +83,8 @@ class HaInputMulti extends LitElement {
@query("ha-input[data-last]") private _lastInput?: HaInput;
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
// Stable key per row, kept in sync with `value`. Because items are plain
// strings we cannot use a WeakMap (as the object-based sortable lists do),
// so we track keys in a parallel array. Keys stay fixed while a row is
@@ -89,6 +104,16 @@ class HaInputMulti extends LitElement {
}
}
public reportValidity(): boolean {
let valid = true;
this._inputs?.forEach((input) => {
if (!input.reportValidity()) {
valid = false;
}
});
return valid;
}
protected render() {
return html`
<ha-sortable
@@ -109,6 +134,9 @@ class HaInputMulti extends LitElement {
.type=${this.inputType}
.autocomplete=${this.autocomplete}
.disabled=${this.disabled}
.pattern=${this.pattern}
.validationMessage=${this.validationMessage}
.autoValidate=${this.pattern !== undefined}
dialogInitialFocus=${index}
.index=${index}
class="flex-auto"
+11 -10
View File
@@ -213,12 +213,14 @@ export const findBatteryEntity = <T extends { entity_id: string }>(
entities: T[]
): T | undefined => {
const batteryEntities = entities
.filter(
(entity) =>
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery" &&
.filter((entity) => {
const state = states[entity.entity_id];
return (
state &&
state.attributes.device_class === "battery" &&
batteryPriorities.includes(computeDomain(entity.entity_id))
)
);
})
.sort(
(a, b) =>
batteryPriorities.indexOf(computeDomain(a.entity_id)) -
@@ -235,11 +237,10 @@ export const findBatteryChargingEntity = <T extends { entity_id: string }>(
states: HomeAssistant["states"],
entities: T[]
): T | undefined =>
entities.find(
(entity) =>
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery_charging"
);
entities.find((entity) => {
const state = states[entity.entity_id];
return state && state.attributes.device_class === "battery_charging";
});
export const computeEntityRegistryName = (
hass: HomeAssistant,
+1 -1
View File
@@ -140,7 +140,7 @@ export interface HassioAddonSetOptionParams {
export const reloadHassioAddons = async (hass: HomeAssistant) => {
await hass.callWS({
type: "supervisor/api",
endpoint: "/addons/reload",
endpoint: "/store/reload",
method: "post",
});
};
-3
View File
@@ -17,9 +17,6 @@ export interface HassioStats {
network_tx: number;
}
export const hassioApiResultExtractor = <T>(response: HassioResponse<T>) =>
response.data;
export const extractApiErrorMessage = (error: any): string =>
typeof error === "object"
? typeof error.body === "object"
+17 -47
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
type HassioDockerRegistries = Record<
string,
@@ -10,59 +7,32 @@ type HassioDockerRegistries = Record<
export const fetchHassioDockerRegistries = async (
hass: HomeAssistant
): Promise<HassioDockerRegistries> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"GET",
"hassio/docker/registries"
)
);
};
): Promise<HassioDockerRegistries> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "get",
});
export const addHassioDockerRegistry = async (
hass: HomeAssistant,
data: HassioDockerRegistries
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"POST",
"hassio/docker/registries",
data
);
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "post",
data,
});
};
export const removeHassioDockerRegistry = async (
hass: HomeAssistant,
registry: string
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries/${registry}`,
method: "delete",
});
return;
}
await hass.callApi<HassioResponse<void>>(
"DELETE",
`hassio/docker/registries/${registry}`
);
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries/${registry}`,
method: "delete",
});
};
+12 -35
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHardwareAudioDevice {
device?: string | null;
@@ -30,38 +27,18 @@ export interface HassioHardwareInfo {
export const fetchHassioHardwareAudio = async (
hass: HomeAssistant
): Promise<HassioHardwareAudioList> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/audio`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareAudioList>>(
"GET",
"hassio/hardware/audio"
)
);
};
): Promise<HassioHardwareAudioList> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/audio`,
method: "get",
});
export const fetchHassioHardwareInfo = async (
hass: HomeAssistant
): Promise<HassioHardwareInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/info`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareInfo>>(
"GET",
"hassio/hardware/info"
)
);
};
): Promise<HassioHardwareInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/info`,
method: "get",
});
+70 -143
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHostInfo {
agent_version: string;
@@ -54,156 +51,86 @@ export interface HostDisksUsage {
export const fetchHassioHostInfo = async (
hass: HomeAssistant
): Promise<HassioHostInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/info",
method: "get",
});
}
const response = await hass.callApi<HassioResponse<HassioHostInfo>>(
"GET",
"hassio/host/info"
);
return hassioApiResultExtractor(response);
};
): Promise<HassioHostInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/info",
method: "get",
});
export const fetchHassioHassOsInfo = async (
hass: HomeAssistant
): Promise<HassioHassOSInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/info",
method: "get",
});
}
): Promise<HassioHassOSInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/info",
method: "get",
});
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHassOSInfo>>(
"GET",
"hassio/os/info"
)
);
};
export const rebootHost = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/reboot",
method: "post",
timeout: null,
});
export const rebootHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/reboot",
method: "post",
timeout: null,
});
}
export const shutdownHost = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/shutdown",
method: "post",
timeout: null,
});
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/reboot");
};
export const updateOS = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/update",
method: "post",
timeout: null,
});
export const shutdownHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/shutdown",
method: "post",
timeout: null,
});
}
export const configSyncOS = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/config/sync",
method: "post",
timeout: null,
});
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/shutdown");
};
export const changeHostOptions = async (hass: HomeAssistant, options: any) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/options",
method: "post",
data: options,
});
export const updateOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/update",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/update");
};
export const configSyncOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/config/sync",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/config/sync");
};
export const changeHostOptions = async (hass: HomeAssistant, options: any) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/options",
method: "post",
data: options,
});
}
return hass.callApi<HassioResponse<void>>(
"POST",
"hassio/host/options",
options
);
};
export const moveDatadisk = async (hass: HomeAssistant, device: string) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/datadisk/move",
method: "post",
timeout: null,
data: { device },
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/datadisk/move");
};
export const moveDatadisk = async (hass: HomeAssistant, device: string) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/datadisk/move",
method: "post",
timeout: null,
data: { device },
});
export const listDatadisks = async (
hass: HomeAssistant
): Promise<DatadiskList> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS<DatadiskList>({
type: "supervisor/api",
endpoint: "/os/datadisk/list",
method: "get",
timeout: null,
});
}
): Promise<DatadiskList> =>
hass.callWS<DatadiskList>({
type: "supervisor/api",
endpoint: "/os/datadisk/list",
method: "get",
timeout: null,
});
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<DatadiskList>>("GET", "/os/datadisk/list")
);
};
export const fetchHostDisksUsage = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HostDisksUsage>>(
"GET",
"hassio/host/disks/default/usage"
)
);
};
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
});
+14 -33
View File
@@ -1,9 +1,6 @@
import { getCollection, type Connection } from "home-assistant-js-websocket";
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import { supervisorApiWsRequest } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import type { CreateSessionResponse } from "./supervisor";
function setIngressCookie(session: string): string {
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
@@ -13,21 +10,14 @@ function setIngressCookie(session: string): string {
}
export const createHassioSession = async (
hass: HomeAssistant
hass: Pick<HomeAssistant, "callWS">
): Promise<string> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
const wsResponse: { session: string } = await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/session",
method: "post",
});
return setIngressCookie(wsResponse.session);
}
const restResponse: { data: { session: string } } = await hass.callApi<
HassioResponse<CreateSessionResponse>
>("POST", "hassio/ingress/session");
return setIngressCookie(restResponse.data.session);
const wsResponse: { session: string } = await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/session",
method: "post",
});
return setIngressCookie(wsResponse.session);
};
export interface IngressPanelInfo {
@@ -50,22 +40,13 @@ export const getIngressPanelInfoCollection = (conn: Connection) =>
);
export const validateHassioSession = async (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
session: string
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/validate_session",
method: "post",
data: { session },
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
"hassio/ingress/validate_session",
{ session }
);
await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/validate_session",
method: "post",
data: { session },
});
};
+20 -52
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
interface IpConfiguration {
address: string[];
@@ -55,66 +52,37 @@ export interface NetworkInfo {
export const fetchNetworkInfo = async (
hass: HomeAssistant
): Promise<NetworkInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/network/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<NetworkInfo>>(
"GET",
"hassio/network/info"
)
);
};
): Promise<NetworkInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/network/info",
method: "get",
});
export const updateNetworkInterface = async (
hass: HomeAssistant,
network_interface: string,
options: Partial<NetworkInterface>
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/update`,
method: "post",
data: options,
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<NetworkInfo>>(
"POST",
`hassio/network/interface/${network_interface}/update`,
options
);
await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/update`,
method: "post",
data: options,
timeout: null,
});
};
export const accesspointScan = async (
hass: HomeAssistant,
network_interface: string
): Promise<AccessPoints> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/accesspoints`,
method: "get",
timeout: null,
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<AccessPoints>>(
"GET",
`hassio/network/interface/${network_interface}/accesspoints`
)
);
};
): Promise<AccessPoints> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/accesspoints`,
method: "get",
timeout: null,
});
export const parseAddress = (address: string) => {
const [ip, cidr] = address.split("/");
+6 -19
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant, TranslationDict } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioResolution {
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
@@ -12,19 +9,9 @@ export interface HassioResolution {
export const fetchHassioResolution = async (
hass: HomeAssistant
): Promise<HassioResolution> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/resolution/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioResolution>>(
"GET",
"hassio/resolution/info"
)
);
};
): Promise<HassioResolution> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/resolution/info",
method: "get",
});
+41 -98
View File
@@ -1,8 +1,6 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHomeAssistantInfo {
arch: SupervisorArch;
@@ -77,10 +75,6 @@ export type HassioPanelInfo = PanelInfo<
}
>;
export interface CreateSessionResponse {
session: string;
}
export interface SupervisorOptions {
channel?: "beta" | "dev" | "stable";
diagnostics?: boolean;
@@ -88,99 +82,57 @@ export interface SupervisorOptions {
}
export const reloadSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/reload",
method: "post",
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/reload`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/reload",
method: "post",
});
};
export const restartSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/restart",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/restart`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/restart",
method: "post",
timeout: null,
});
};
export const updateSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/update",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/update`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/update",
method: "post",
timeout: null,
});
};
export const fetchHassioHomeAssistantInfo = async (
hass: HomeAssistant
): Promise<HassioHomeAssistantInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/core/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
"GET",
"hassio/core/info"
)
);
};
): Promise<HassioHomeAssistantInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/core/info",
method: "get",
});
export const fetchHassioSupervisorInfo = async (
hass: HomeAssistant
): Promise<HassioSupervisorInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioSupervisorInfo>>(
"GET",
"hassio/supervisor/info"
)
);
};
): Promise<HassioSupervisorInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/info",
method: "get",
});
export const fetchHassioInfo = async (
hass: HomeAssistant
): Promise<HassioInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info")
);
};
): Promise<HassioInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/info",
method: "get",
});
export const fetchHassioBoots = async (hass: HomeAssistant) =>
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
@@ -263,21 +215,12 @@ export const setSupervisorOption = async (
hass: HomeAssistant,
data: SupervisorOptions
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/options",
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
"hassio/supervisor/options",
data
);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/options",
method: "post",
data,
});
};
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
+15
View File
@@ -21,6 +21,21 @@ export interface HttpConfigState {
revert_at: string | null;
}
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
"server_port",
"server_host",
"ssl_certificate",
"ssl_key",
"ssl_peer_certificate",
"ssl_profile",
"cors_allowed_origins",
"use_x_forwarded_for",
"trusted_proxies",
"use_x_frame_options",
"ip_ban_enabled",
"login_attempts_threshold",
];
export interface SaveHttpConfigResult {
restart: boolean;
}
+14 -15
View File
@@ -325,35 +325,34 @@ export const getCategoryIcons = async <
domain?: string,
force = false
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
const categoryResources = resources[category];
if (!domain) {
if (!force && resources[category].all) {
return resources[category].all as Promise<
Record<string, CategoryType[T]>
>;
if (!force && categoryResources.all) {
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
}
resources[category].all = getHassIcons(connection, category).then((res) => {
resources[category].domains = res.resources as any;
categoryResources.all = getHassIcons(connection, category).then((res) => {
categoryResources.domains = res.resources as any;
return res?.resources as Record<string, CategoryType[T]>;
}) as any;
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
}
if (!force && domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
if (!force && domain in categoryResources.domains) {
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
}
if (resources[category].all && !force) {
await resources[category].all;
if (domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
if (categoryResources.all && !force) {
await categoryResources.all;
if (domain in categoryResources.domains) {
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
}
}
if (!isComponentLoaded(hassConfig, domain)) {
return undefined;
}
const result = getHassIcons(connection, category, domain);
resources[category].domains[domain] = result.then(
categoryResources.domains[domain] = result.then(
(res) => res?.resources[domain]
) as any;
return resources[category].domains[domain] as Promise<CategoryType[T]>;
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
};
export const getServiceIcons = async (
+5
View File
@@ -7,6 +7,11 @@ export interface CustomPanelConfig {
js_url?: string;
module_url?: string;
html_url?: string;
// When true, the panel takes care of the safe-area insets itself (e.g. it
// consumes the `--safe-area-inset-*` variables or draws into the safe area on
// purpose). Home Assistant then skips adding its own safe-area padding around
// the panel to avoid doubling the insets.
handle_safe_area?: boolean;
}
export type CustomPanelInfo<T = Record<string, unknown>> = PanelInfo<
+9
View File
@@ -80,6 +80,7 @@ export type Selector =
| TTSVoiceSelector
| SerialPortSelector
| UiActionSelector
| UiClockDateFormatSelector
| UiColorSelector
| UiStateContentSelector
| UiTimeFormatSelector
@@ -530,6 +531,10 @@ export interface StringSelector {
placeholder?: string;
autocomplete?: string;
multiple?: true;
// Regular expression the value must match (HTML `pattern`); with `multiple`
// every entry is validated. `validation_message` is shown when it fails.
pattern?: string;
validation_message?: string;
} | null;
}
@@ -577,6 +582,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>
`;
}
@@ -1,34 +1,27 @@
import { mdiArrowRight } from "@mdi/js";
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumericDuration } from "../../common/datetime/format_duration";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import type { HttpConfig } from "../../data/http";
import { promoteHttpConfig, saveHttpConfig } from "../../data/http";
import {
HTTP_CONFIG_FIELDS,
promoteHttpConfig,
saveHttpConfig,
} from "../../data/http";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../make-dialog-manager";
import type { HttpPendingConfigDialogParams } from "./show-dialog-http-pending-config";
const HTTP_FIELDS: (keyof HttpConfig)[] = [
"server_port",
"server_host",
"ssl_certificate",
"ssl_key",
"ssl_peer_certificate",
"ssl_profile",
"cors_allowed_origins",
"use_x_forwarded_for",
"trusted_proxies",
"use_x_frame_options",
"ip_ban_enabled",
"login_attempts_threshold",
];
@customElement("dialog-http-pending-config")
export class DialogHttpPendingConfig
extends LitElement
@@ -57,6 +50,10 @@ export class DialogHttpPendingConfig
this._error = undefined;
this._reverted = false;
this._startCountdown();
// The field labels live in the config panel fragment, which is not loaded
// yet when this dialog pops up on startup. Load it so the changed-field
// names resolve; the dialog re-renders once hass updates.
this.hass.loadFragmentTranslation("config");
}
public closeDialog(): boolean {
@@ -114,17 +111,44 @@ export class DialogHttpPendingConfig
return [];
}
const { stable, pending } = this._params.state;
return HTTP_FIELDS.filter(
return HTTP_CONFIG_FIELDS.filter(
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
);
}
private _formatValue(key: keyof HttpConfig, value: unknown): string {
if (value === undefined || value === null || value === "") {
return this.hass.localize("ui.dialogs.http_pending_config.not_set");
}
if (typeof value === "boolean") {
return this.hass.localize(value ? "ui.common.yes" : "ui.common.no");
}
if (Array.isArray(value)) {
return value.length
? value.join(", ")
: this.hass.localize("ui.dialogs.http_pending_config.not_set");
}
if (key === "ssl_profile") {
return (
this.hass.localize(
`ui.panel.config.network.http.ssl_profile_${value}` as any
) || String(value)
);
}
return String(value);
}
protected render() {
if (!this._params) {
return nothing;
}
const changes = this._changedFields;
const { stable, pending } = this._params.state;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-dialog
@@ -187,9 +211,25 @@ export class DialogHttpPendingConfig
${changes.map(
(key) => html`
<li>
${this.hass.localize(
`ui.panel.config.network.http.fields.${key}` as any
)}
<span class="field">
${this.hass.localize(
`ui.panel.config.network.http.fields.${key}` as any
)}
</span>
<span class="values">
<span class="old"
>${this._formatValue(key, stable[key])}</span
>
<ha-svg-icon
.path=${mdiArrowRight}
style=${styleMap({
transform: rtl ? "scaleX(-1)" : "",
})}
></ha-svg-icon>
<span class="new"
>${this._formatValue(key, pending![key])}</span
>
</span>
</li>
`
)}
@@ -320,13 +360,37 @@ export class DialogHttpPendingConfig
margin-bottom: var(--ha-space-2);
}
ul {
list-style: none;
margin: 0 0 var(--ha-space-4) 0;
padding-left: var(--ha-space-6);
color: var(--secondary-text-color);
padding: 0;
}
li {
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
li:last-child {
border-bottom: none;
}
.field {
display: block;
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-1);
}
.values {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
color: var(--secondary-text-color);
word-break: break-word;
}
.values .new {
color: var(--primary-text-color);
}
.values ha-svg-icon {
--mdc-icon-size: 18px;
flex-shrink: 0;
}
ha-alert {
display: block;
margin-top: var(--ha-space-4);
+5
View File
@@ -123,6 +123,10 @@ interface EMOutgoingMessageConnectionStatus extends EMMessage {
payload: { event: string };
}
interface EMOutgoingMessageFrontendLoaded extends EMMessage {
type: "frontend/loaded"; // Fired once the launch screen is removed (connected and essential data loaded)
}
interface EMOutgoingMessageAppConfiguration extends EMMessage {
type: "config_screen/show";
}
@@ -201,6 +205,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageBarCodeNotify
| EMOutgoingMessageBarCodeScan
| EMOutgoingMessageConnectionStatus
| EMOutgoingMessageFrontendLoaded
| EMOutgoingMessageExoplayerPlayHLS
| EMOutgoingMessageExoplayerResize
| EMOutgoingMessageExoplayerStop
+29 -11
View File
@@ -33,7 +33,12 @@ import {
TimeZone,
} from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata";
import type { HomeAssistant, Resources, ValuePart } from "../types";
import type {
HomeAssistant,
Resources,
ThemeSettings,
ValuePart,
} from "../types";
import { getLocalLanguage, getTranslation } from "../util/common-translation";
import { demoConfig } from "./demo_config";
import { demoPanels } from "./demo_panels";
@@ -83,7 +88,10 @@ export interface MockHomeAssistant extends HomeAssistant {
loader: () => Promise<unknown>
);
mockEvent(event);
mockTheme(theme: Record<string, string> | null);
mockTheme(
theme: Record<string, string> | null,
selectedTheme?: ThemeSettings
);
formatEntityState(stateObj: HassEntity, state?: string): string;
formatEntityStateToParts(stateObj: HassEntity, state?: string): ValuePart[];
formatEntityAttributeValue(
@@ -503,25 +511,35 @@ export const provideHass = (
},
mockAPI,
mockEvent(event) {
(eventListeners[event] || []).forEach((fn) => fn(event));
(eventListeners[event] || []).forEach((fn) => {
fn(event);
});
},
mockTheme(theme) {
mockTheme(theme, selectedTheme) {
invalidateThemeCache();
selectedTheme ??= {
theme: theme ? "fake-data" : "default",
dark: false,
};
const themeName = selectedTheme.theme;
const darkMode =
selectedTheme.dark ??
matchMedia("(prefers-color-scheme: dark)").matches;
hass().updateHass({
selectedTheme: { theme: theme ? "mock" : "default", dark: false },
selectedTheme,
themes: {
...hass().themes,
themes: {
mock: theme as any,
},
darkMode,
theme: themeName,
themes: theme ? { [themeName]: theme as any } : {},
},
});
const { themes, selectedTheme } = hass();
const { themes } = hass();
applyThemesOnElement(
document.documentElement,
themes,
selectedTheme!.theme,
{ dark: false },
themeName,
{ ...selectedTheme, dark: darkMode },
true
);
},
+1
View File
@@ -120,6 +120,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
this.render = this.renderHass;
this.update = super.update;
removeLaunchScreen();
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
super.update(changedProps);
}
+63 -16
View File
@@ -53,6 +53,11 @@ class HaPanelApp extends LitElement {
@state() private _iframeLoaded = false;
// Set when the addon signals (via subscribe-properties) that it handles the
// safe-area insets itself. We then stop padding the iframe and forward the
// inset values so the addon can draw into the safe area.
@state() private _handleSafeArea = false;
private _enabledKioskMode = false;
private _sessionKeepAlive?: number;
@@ -88,11 +93,13 @@ class HaPanelApp extends LitElement {
public connectedCallback() {
super.connectedCallback();
window.addEventListener("message", this._handleIframeMessage);
window.addEventListener("resize", this._handleResize);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this._handleIframeMessage);
window.removeEventListener("resize", this._handleResize);
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
@@ -135,6 +142,7 @@ class HaPanelApp extends LitElement {
class=${classMap({
loaded: this._iframeLoaded,
"kiosk-mode": this._kioskMode,
"handle-safe-area": this._handleSafeArea,
})}
title=${this._addon.name}
src=${this._addon.ingress_url!}
@@ -179,6 +187,7 @@ class HaPanelApp extends LitElement {
this._enabledKioskMode = false;
}
this._iframeSubscribeUpdates = false;
this._handleSafeArea = false;
this._autoRetryUntil = undefined;
this._fetchData(addon);
}
@@ -416,6 +425,9 @@ class HaPanelApp extends LitElement {
case "home-assistant/subscribe-properties":
this._iframeSubscribeUpdates = true;
// An addon can opt out of the container padding and take care of the
// safe area itself; we then forward the inset values below.
this._handleSafeArea = !!data.handleSafeArea;
this._sendPropertiesToIframe();
if (data.kioskMode && !this.hass.kioskMode) {
this._enabledKioskMode = true;
@@ -425,6 +437,7 @@ class HaPanelApp extends LitElement {
case "home-assistant/unsubscribe-properties":
this._iframeSubscribeUpdates = false;
this._handleSafeArea = false;
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", { enable: false });
this._enabledKioskMode = false;
@@ -433,16 +446,38 @@ class HaPanelApp extends LitElement {
}
};
// Safe-area insets can change on orientation change; keep a subscribing
// addon in sync.
private _handleResize = () => {
if (this._iframeSubscribeUpdates) {
this._sendPropertiesToIframe();
}
};
private _sendPropertiesToIframe() {
if (!this._iframeRef.value?.contentWindow) {
return;
}
const styles = getComputedStyle(this);
this._iframeRef.value.contentWindow.postMessage(
{
type: "home-assistant/properties",
narrow: this.narrow,
route: this._computeRouteTail(this.route),
// Resolved insets so an addon that handles the safe area itself can
// apply them. Vertical uses the raw insets, horizontal the content
// variables (the docked sidebar already absorbs its side).
safeAreaInsets: {
top: styles.getPropertyValue("--safe-area-inset-top").trim(),
right:
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
styles.getPropertyValue("--safe-area-inset-right").trim(),
bottom: styles.getPropertyValue("--safe-area-inset-bottom").trim(),
left:
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
styles.getPropertyValue("--safe-area-inset-left").trim(),
},
},
"*"
);
@@ -462,30 +497,38 @@ class HaPanelApp extends LitElement {
inset: 0;
}
/* Keep the addon iframe clear of the device safe areas. CSS variables don't
cross the iframe boundary, so this padding on the iframe element is the
only way to inset the embedded document. Vertical uses the raw insets;
horizontal uses the content variables, since the docked sidebar already
absorbs the inset on its side (avoids doubling it). */
iframe {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
border: 0;
background-color: var(--primary-background-color);
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease;
padding: var(--safe-area-inset-top)
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
var(--safe-area-inset-bottom)
var(--safe-area-content-inset-left, var(--safe-area-inset-left));
}
iframe.loaded {
opacity: 1;
}
/* The addon takes care of the safe area itself (it receives the insets via
postMessage), so drop the container padding to let it draw full-bleed. */
iframe.handle-safe-area {
padding: 0;
}
/* When the header is shown it already covers the top inset. */
.header + iframe {
height: calc(100% - 40px);
}
:host([narrow]) iframe {
padding-top: var(--safe-area-inset-top);
height: calc(100% - var(--safe-area-inset-top, 0px));
}
:host([narrow]) .header + iframe {
padding-top: 0;
height: calc(100% - 40px - var(--safe-area-inset-top, 0px));
}
@@ -494,8 +537,17 @@ class HaPanelApp extends LitElement {
display: flex;
align-items: center;
font-size: var(--ha-font-size-l);
height: 40px;
padding: 0 16px;
height: calc(40px + var(--safe-area-inset-top, 0px));
padding: var(--safe-area-inset-top)
calc(
16px +
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
)
0
calc(
16px +
var(--safe-area-content-inset-left, var(--safe-area-inset-left))
);
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: var(--ha-font-weight-normal);
@@ -505,11 +557,6 @@ class HaPanelApp extends LitElement {
--mdc-icon-size: 20px;
}
:host([narrow]) .header {
height: calc(40px + var(--safe-area-inset-top, 0px));
padding-top: var(--safe-area-inset-top, 0);
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-condensed);
@@ -321,11 +321,10 @@ export default class HaAutomationAddFromTarget extends LitElement {
const floorAreas = emptyFloors
? undefined
: this._floorAreas.map((floor, index) =>
index === 0 && !floor.id
? this._renderAreas(
entries[floor.id || `floor${TARGET_SEPARATOR}`].areas!
)
: this._floorAreas.map((floor, index) => {
const floorEntry = entries[floor.id || `floor${TARGET_SEPARATOR}`];
return index === 0 && !floor.id
? this._renderAreas(floorEntry.areas!)
: this._renderItem(
!floor.id
? this._i18n.localize(
@@ -335,19 +334,14 @@ export default class HaAutomationAddFromTarget extends LitElement {
floor.id || `floor${TARGET_SEPARATOR}`,
!floor.id,
!!floor.id && this._getSelectedTargetId(value) === floor.id,
!entries[floor.id || `floor${TARGET_SEPARATOR}`].open &&
!!Object.keys(
entries[floor.id || `floor${TARGET_SEPARATOR}`].areas!
).length,
entries[floor.id || `floor${TARGET_SEPARATOR}`].open,
!floorEntry.open && !!Object.keys(floorEntry.areas!).length,
floorEntry.open,
this._renderFloorIcon(floor as FloorNestedComboBoxItem),
entries[floor.id || `floor${TARGET_SEPARATOR}`].open
? this._renderAreas(
entries[floor.id || `floor${TARGET_SEPARATOR}`].areas!
)
floorEntry.open
? this._renderAreas(floorEntry.areas!)
: undefined
)
);
);
});
return html`<ha-section-title
>${this._i18n.localize(
@@ -511,81 +505,69 @@ export default class HaAutomationAddFromTarget extends LitElement {
const items: TemplateResult[] = [];
if (unassignedEntitiesLength) {
const open = entries[`device${TARGET_SEPARATOR}`].open;
const entry = entries[`device${TARGET_SEPARATOR}`];
items.push(
this._renderItem(
this._i18n.localize("ui.components.target-picker.type.entities"),
`device${TARGET_SEPARATOR}`,
true,
false,
!open,
open,
!entry.open,
entry.open,
undefined,
entries[`device${TARGET_SEPARATOR}`].open
? this._renderDomains(
entries[`device${TARGET_SEPARATOR}`].devices!,
"entity_"
)
entry.open
? this._renderDomains(entry.devices!, "entity_")
: undefined
)
);
}
if (unassignedHelpersLength) {
const open = entries[`helper${TARGET_SEPARATOR}`].open;
const entry = entries[`helper${TARGET_SEPARATOR}`];
items.push(
this._renderItem(
this._i18n.localize("ui.panel.config.automation.editor.helpers"),
`helper${TARGET_SEPARATOR}`,
true,
false,
!open,
open,
!entry.open,
entry.open,
undefined,
entries[`helper${TARGET_SEPARATOR}`].open
? this._renderDomains(
entries[`helper${TARGET_SEPARATOR}`].devices!,
"helper_"
)
entry.open
? this._renderDomains(entry.devices!, "helper_")
: undefined
)
);
}
if (unassignedDevicesLength) {
const open = entries[`area${TARGET_SEPARATOR}`].open;
const entry = entries[`area${TARGET_SEPARATOR}`];
items.push(
this._renderItem(
this._i18n.localize("ui.components.target-picker.type.devices"),
`area${TARGET_SEPARATOR}`,
true,
false,
!open,
open,
!entry.open,
entry.open,
undefined,
entries[`area${TARGET_SEPARATOR}`].open
? this._renderDevices(entries[`area${TARGET_SEPARATOR}`].devices!)
: undefined
entry.open ? this._renderDevices(entry.devices!) : undefined
)
);
}
if (unassignedServicesLength) {
const open = entries[`service${TARGET_SEPARATOR}`].open;
const entry = entries[`service${TARGET_SEPARATOR}`];
items.push(
this._renderItem(
this._i18n.localize("ui.panel.config.automation.editor.services"),
`service${TARGET_SEPARATOR}`,
true,
false,
!open,
open,
!entry.open,
entry.open,
undefined,
entries[`service${TARGET_SEPARATOR}`].open
? this._renderDevices(
entries[`service${TARGET_SEPARATOR}`].devices!
)
: undefined
entry.open ? this._renderDevices(entry.devices!) : undefined
)
);
}
@@ -386,9 +386,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._dirtyState?.setState(
{
name: this._name.trim() || null,
icon: this._icon.trim() || null,
entityId: this._entityId.trim(),
name: this._name || null,
icon: this._icon || null,
entityId: this._entityId,
areaId: this._areaId ?? null,
labels: this._labels ?? [],
deviceClass: this._deviceClass,
@@ -214,13 +214,14 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
for (const component of components) {
const componentDomain = component.split(".")[0];
const manifest = manifests[componentDomain];
if (
!entryDomains.has(componentDomain) &&
manifests[componentDomain] &&
!manifests[componentDomain].config_flow &&
(!manifests[componentDomain].integration_type ||
manifest &&
!manifest.config_flow &&
(!manifest.integration_type ||
["device", "hub", "service", "integration"].includes(
manifests[componentDomain].integration_type!
manifest.integration_type!
))
) {
domains.add(componentDomain);
@@ -3,6 +3,10 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
} from "../../../common/string/is_ip_address";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -10,7 +14,11 @@ import "../../../components/ha-card";
import "../../../components/ha-form/ha-form";
import type { HaForm } from "../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../components/ha-form/types";
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
import {
fetchHttpConfig,
HTTP_CONFIG_FIELDS,
saveHttpConfig,
} from "../../../data/http";
import type { HttpConfig } from "../../../data/http";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
@@ -24,10 +32,6 @@ const SCHEMA = memoizeOne(
required: true,
selector: { number: { min: 1, max: 65535, mode: "box" } },
},
{
name: "server_host",
selector: { text: { multiple: true } },
},
{
name: "ssl",
type: "expandable",
@@ -81,7 +85,15 @@ const SCHEMA = memoizeOne(
},
{
name: "trusted_proxies",
selector: { text: { multiple: true } },
selector: {
text: {
multiple: true,
pattern: IP_ADDRESS_OR_NETWORK_PATTERN,
validation_message: localize(
"ui.panel.config.network.http.invalid_network"
),
},
},
},
],
},
@@ -108,6 +120,18 @@ const SCHEMA = memoizeOne(
flatten: true,
title: localize("ui.panel.config.network.http.sections.advanced"),
schema: [
{
name: "server_host",
selector: {
text: {
multiple: true,
pattern: IP_ADDRESS_PATTERN,
validation_message: localize(
"ui.panel.config.network.http.invalid_host"
),
},
},
},
{
name: "cors_allowed_origins",
selector: { text: { multiple: true } },
@@ -165,6 +189,9 @@ class HaConfigHttpForm extends LitElement {
const schema = SCHEMA(this.hass.localize);
const portChanged =
!!this._stable && this._config?.server_port !== this._stable.server_port;
return html`
<ha-card
outlined
@@ -174,6 +201,17 @@ class HaConfigHttpForm extends LitElement {
<p class="description">
${this.hass.localize("ui.panel.config.network.http.description")}
</p>
${
portChanged
? html`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.http.port_warning"
)}
</ha-alert>
`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -318,15 +356,7 @@ class HaConfigHttpForm extends LitElement {
) {
return;
}
// voluptuous formats errors as "<message> @ data['<field>']".
// If a field is identified, mark it inline; otherwise show a card-level
// alert.
const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/);
if (fieldMatch) {
this._fieldErrors = { [fieldMatch[1]]: err.message };
} else {
this._error = err.message;
}
this._handleSaveError(err);
} finally {
this._saving = false;
}
@@ -340,6 +370,34 @@ class HaConfigHttpForm extends LitElement {
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
private _handleSaveError(err: any): void {
const rawMessage =
(typeof err === "string" ? err : err?.message) ||
this.hass.localize("ui.panel.config.network.http.save_error");
// Voluptuous formats validation errors as
// "<reason> @ data['config']['<field>'][<index>]. Got '<value>'"
// Strip the internal data path for display and pick the deepest known
// field name so it can also be flagged inline.
const message =
rawMessage.replace(/\s*@\s*data(\['[^']*'\]|\[\d+\])+/g, "").trim() ||
rawMessage;
const field = [...rawMessage.matchAll(/\['([^']+)'\]/g)]
.map((match) => match[1])
.reverse()
.find((name) => HTTP_CONFIG_FIELDS.includes(name as keyof HttpConfig)) as
keyof HttpConfig | undefined;
if (field) {
// Show a card-level alert too — the field may sit in a collapsed section.
this._error = `${this.hass.localize(
`ui.panel.config.network.http.fields.${field}` as any
)}: ${message}`;
this._fieldErrors = { [field]: message };
} else {
this._error = message;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
+13 -20
View File
@@ -635,16 +635,13 @@ export class HassioNetwork extends LitElement {
const value = source.value as "disabled" | "auto" | "static";
const version = (source as any).version as "ipv4" | "ipv6";
if (
!value ||
!this._interface ||
this._interface[version]!.method === value
) {
const iface = this._interface?.[version];
if (!value || !iface || iface.method === value) {
return;
}
this._dirty = true;
this._interface[version]!.method = value;
iface.method = value;
this.requestUpdate("_interface");
}
@@ -662,7 +659,8 @@ export class HassioNetwork extends LitElement {
const version = (ev.target as any).version as "ipv4" | "ipv6";
const id = source.id;
if (!value || !this._interface?.[version]) {
const iface = this._interface?.[version];
if (!value || !iface) {
source.reportValidity();
return;
}
@@ -670,31 +668,26 @@ export class HassioNetwork extends LitElement {
this._dirty = true;
if (id === "address") {
const index = (ev.target as any).index as number;
const { mask: oldMask } = parseAddress(
this._interface[version].address![index]
);
const { mask: oldMask } = parseAddress(iface.address![index]);
const { mask } = parseAddress(value);
this._interface[version].address![index] = formatAddress(
value,
mask || oldMask || ""
);
iface.address![index] = formatAddress(value, mask || oldMask || "");
this.requestUpdate("_interface");
} else if (id === "netmask") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = formatAddress(ip, value);
const { ip } = parseAddress(iface.address![index]);
iface.address![index] = formatAddress(ip, value);
this.requestUpdate("_interface");
} else if (id === "prefix") {
const index = (ev.target as any).index as number;
const { ip } = parseAddress(this._interface[version].address![index]);
this._interface[version].address![index] = `${ip}/${value}`;
const { ip } = parseAddress(iface.address![index]);
iface.address![index] = `${ip}/${value}`;
this.requestUpdate("_interface");
} else if (id === "nameserver") {
const index = (ev.target as any).index as number;
this._interface[version].nameservers![index] = value;
iface.nameservers![index] = value;
this.requestUpdate("_interface");
} else {
this._interface[version][id] = value;
iface[id] = value;
}
}
@@ -326,13 +326,11 @@ class DialogSystemInformation extends LitElement {
const keys: TemplateResult[] = [];
for (const key of Object.keys(domainInfo.info)) {
const infoValue = domainInfo.info[key];
let value: unknown;
if (
domainInfo.info[key] &&
typeof domainInfo.info[key] === "object"
) {
const info = domainInfo.info[key] as SystemCheckValueObject;
if (infoValue && typeof infoValue === "object") {
const info = infoValue as SystemCheckValueObject;
if (info.type === "pending") {
value = html` <ha-spinner size="small"></ha-spinner> `;
@@ -363,7 +361,7 @@ class DialogSystemInformation extends LitElement {
);
}
} else {
value = domainInfo.info[key];
value = infoValue;
}
keys.push(html`
@@ -431,10 +429,11 @@ class DialogSystemInformation extends LitElement {
];
for (const key of Object.keys(domainInfo.info)) {
const infoValue = domainInfo.info[key];
let value: unknown;
if (domainInfo.info[key] && typeof domainInfo.info[key] === "object") {
const info = domainInfo.info[key] as SystemCheckValueObject;
if (infoValue && typeof infoValue === "object") {
const info = infoValue as SystemCheckValueObject;
if (info.type === "pending") {
value = "pending";
@@ -448,7 +447,7 @@ class DialogSystemInformation extends LitElement {
);
}
} else {
value = domainInfo.info[key];
value = infoValue;
}
if (first) {
parts.push(`${key} | ${value}\n-- | --`);
+87
View File
@@ -36,6 +36,11 @@ export class HaPanelCustom extends ReactiveElement {
private _wasDisconnected = false;
// Set for embedded-iframe panels that opt out of the container padding via
// `handle_safe_area`; we then inject the resolved insets into the (same
// origin) iframe document so the panel can consume `--safe-area-inset-*`.
private _syncSafeArea = false;
protected createRenderRoot() {
return this;
}
@@ -54,10 +59,14 @@ export class HaPanelCustom extends ReactiveElement {
});
this._setProperties = setProperties;
this.querySelector("iframe")?.classList.add("loaded");
// registerIframe also fires on the iframe's `pageshow`, so this re-applies
// the insets after an internal reload.
this._syncSafeAreaToIframe();
}
public connectedCallback() {
super.connectedCallback();
window.addEventListener("resize", this._handleResize);
// Only rebuild when reattached after a real disconnect (the 5-minute
// suspendWhenHidden timer in partial-panel-resolver). On first mount,
// update() handles creation via the panel-changed branch, so calling
@@ -70,10 +79,51 @@ export class HaPanelCustom extends ReactiveElement {
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
this._wasDisconnected = true;
this._cleanupPanel();
}
// Safe-area insets can change on orientation change; keep the embedded
// document in sync.
private _handleResize = () => {
this._syncSafeAreaToIframe();
};
private _syncSafeAreaToIframe() {
if (!this._syncSafeArea) {
return;
}
const root =
this.querySelector("iframe")?.contentWindow?.document?.documentElement;
if (!root) {
return;
}
// CSS variables don't cross the iframe boundary, so copy the resolved
// values onto the (same-origin) iframe document. Vertical uses the raw
// insets, horizontal the content variables (the sidebar already absorbs
// its side).
const styles = getComputedStyle(this);
root.style.setProperty(
"--safe-area-inset-top",
styles.getPropertyValue("--safe-area-inset-top").trim()
);
root.style.setProperty(
"--safe-area-inset-bottom",
styles.getPropertyValue("--safe-area-inset-bottom").trim()
);
root.style.setProperty(
"--safe-area-inset-left",
styles.getPropertyValue("--safe-area-content-inset-left").trim() ||
styles.getPropertyValue("--safe-area-inset-left").trim()
);
root.style.setProperty(
"--safe-area-inset-right",
styles.getPropertyValue("--safe-area-content-inset-right").trim() ||
styles.getPropertyValue("--safe-area-inset-right").trim()
);
}
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
if (changedProps.has("panel")) {
@@ -101,6 +151,7 @@ export class HaPanelCustom extends ReactiveElement {
private _cleanupPanel() {
delete window.customPanel;
this._setProperties = undefined;
this._syncSafeArea = false;
while (this.lastChild) {
this.removeChild(this.lastChild);
}
@@ -112,6 +163,30 @@ export class HaPanelCustom extends ReactiveElement {
const config = panel.config!._panel_custom;
const panelUrl = getUrl(config);
// Keep the panel content clear of the device safe areas (status bar, home
// indicator, notch). Panels rendered in light DOM inherit the
// `--safe-area-inset-*` variables but most don't consume them, so we apply
// the padding on the container as a safe baseline. The embedded-iframe
// branch below applies the equivalent padding on the iframe instead. Panels
// that manage the safe area themselves can opt out via `handle_safe_area`.
const applySafeArea = !config.handle_safe_area;
// For opted-out embedded-iframe panels we inject the insets into the iframe
// document instead of padding it (see _syncSafeAreaToIframe).
this._syncSafeArea = !applySafeArea && !!config.embed_iframe;
if (applySafeArea && !config.embed_iframe) {
this.style.display = "block";
this.style.boxSizing = "border-box";
// Vertical insets aren't absorbed by any chrome around the panel, so use
// the raw insets. Horizontal uses the (physical) content variables, since
// the sidebar already absorbs the inset on its side (avoids doubling it).
this.style.paddingTop = "var(--safe-area-inset-top)";
this.style.paddingBottom = "var(--safe-area-inset-bottom)";
this.style.paddingLeft =
"var(--safe-area-content-inset-left, var(--safe-area-inset-left))";
this.style.paddingRight =
"var(--safe-area-content-inset-right, var(--safe-area-inset-right))";
}
const tempA = document.createElement("a");
tempA.href = panelUrl.url;
@@ -168,14 +243,26 @@ export class HaPanelCustom extends ReactiveElement {
window.customPanel = this;
const titleAttr = this.panel.title ? `title="${this.panel.title}"` : "";
// Pad the iframe (not the host) with the safe-area insets so the embedded
// document stays clear of the device safe areas. CSS variables don't cross
// the iframe boundary, so this container padding is the baseline; panels
// that handle it themselves opt out via `handle_safe_area`.
const safeAreaPadding = applySafeArea
? `padding: var(--safe-area-inset-top)
var(--safe-area-content-inset-right, var(--safe-area-inset-right))
var(--safe-area-inset-bottom)
var(--safe-area-content-inset-left, var(--safe-area-inset-left));`
: "";
this.innerHTML = `
<style>
iframe {
border: none;
box-sizing: border-box;
width: 100%;
height: 100vh;
height: 100dvh;
display: block;
${safeAreaPadding}
background-color: var(--primary-background-color);
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease;
+18 -27
View File
@@ -49,8 +49,8 @@ import { resolveEntityIDs } from "../../data/selector";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { fileDownload } from "../../util/file_download";
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
import { csvSafeString, csvDownload } from "../../util/csv";
@customElement("ha-panel-history")
class HaPanelHistory extends LitElement {
@@ -128,19 +128,13 @@ class HaPanelHistory extends LitElement {
<h1 class="page-title" slot="title">
${this.hass.localize("panel.history")}
</h1>
${
entitiesSelected
? html`
<ha-icon-button
slot="actionItems"
@click=${this._removeAll}
.disabled=${this._isLoading}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.panel.history.remove_all")}
></ha-icon-button>
`
: ""
}
<ha-icon-button
slot="actionItems"
@click=${this._removeAll}
.disabled=${this._isLoading || !entitiesSelected}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.panel.history.remove_all")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
@@ -449,8 +443,8 @@ class HaPanelHistory extends LitElement {
return;
}
const csv: string[] = [""]; // headers will be replaced later.
const headers = ["entity_id", "state", "last_changed"];
const csv: string[][] = [[]]; // headers will be replaced later.
const processedDomainAttributes = new Set<string>();
const domainAttributes: Record<string, Record<string, number>> = {
climate: {
@@ -492,7 +486,7 @@ class HaPanelHistory extends LitElement {
if (entity.statistics) {
for (const s of entity.statistics) {
csv.push(`${entityId},${s.state},${formatDate(s.last_changed)}\n`);
csv.push([entityId, s.state, formatDate(s.last_changed)]);
}
}
@@ -509,25 +503,22 @@ class HaPanelHistory extends LitElement {
}
}
csv.push(data.join(",") + "\n");
csv.push(data);
}
}
}
for (const timeline of this._mungedStateHistory.timeline) {
const entityId = timeline.entity_id;
for (const s of timeline.data) {
const safeState = /,|"/.test(s.state)
? `"${s.state.replaceAll('"', '""')}"`
: s.state;
csv.push(`${entityId},${safeState},${formatDate(s.last_changed)}\n`);
csv.push([
entityId,
csvSafeString(s.state),
formatDate(s.last_changed),
]);
}
}
csv[0] = headers.join(",") + "\n";
const blob = new Blob(csv, {
type: "text/csv",
});
const url = window.URL.createObjectURL(blob);
fileDownload(url, "history.csv");
csv[0] = headers;
csvDownload(csv, "history.csv");
}
private _suggestCard() {
+4 -1
View File
@@ -49,10 +49,13 @@ class HaPanelIframe extends LitElement {
}
static styles = css`
/* Fill hass-subpage's content box, which already excludes the safe-area
insets (see hass-subpage .content), instead of positioning absolutely
and spilling into the bottom/side insets. */
iframe {
border: 0;
display: block;
width: 100%;
position: absolute;
height: 100%;
background-color: var(--primary-background-color);
}
+4
View File
@@ -100,6 +100,10 @@ export class HaLogbook extends LitElement {
private _readyListenerAttached = false;
public getEntries(): LogbookEntry[] {
return this._logbookEntries || [];
}
protected render() {
if (!isComponentLoaded(this.hass.config, "logbook")) {
return nothing;
+130 -12
View File
@@ -1,9 +1,15 @@
import { mdiRefresh } from "@mdi/js";
import {
mdiDotsVertical,
mdiDownload,
mdiFilterRemove,
mdiRefresh,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fromUnixTime } from "date-fns";
import { storage } from "../../common/decorators/storage";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -16,7 +22,11 @@ import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
@@ -26,6 +36,13 @@ import { resolveEntityIDs } from "../../data/selector";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { csvDownload, csvSafeString } from "../../util/csv";
interface LogbookState {
time: { range: [Date, Date] };
targetPickerValue: HassServiceTarget;
}
@customElement("ha-panel-logbook")
export class HaPanelLogbook extends LitElement {
@@ -54,14 +71,7 @@ export class HaPanelLogbook extends LitElement {
public constructor() {
super();
const start = new Date();
start.setHours(start.getHours() - 1, 0, 0, 0);
const end = new Date();
end.setHours(end.getHours() + 2, 0, 0, 0);
this._time = { range: [start, end] };
this._time = this._defaultState.time;
}
protected render() {
@@ -73,11 +83,30 @@ export class HaPanelLogbook extends LitElement {
<div slot="title">${this.hass.localize("panel.logbook")}</div>
<ha-icon-button
slot="actionItems"
@click=${this._refreshLogbook}
.path=${mdiRefresh}
.label=${this.hass!.localize("ui.common.refresh")}
@click=${this._resetLogbook}
.disabled=${this._isDefaultState()}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.common.reset")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="refresh">
${this.hass.localize("ui.common.refresh")}
<ha-svg-icon slot="icon" .path=${mdiRefresh}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="download">
${this.hass.localize("ui.panel.logbook.download_data")}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<div class="filters">
<ha-date-range-picker
@@ -230,10 +259,99 @@ export class HaPanelLogbook extends LitElement {
);
}
private get _defaultState(): LogbookState {
const start = new Date();
start.setHours(start.getHours() - 1, 0, 0, 0);
const end = new Date();
end.setHours(end.getHours() + 2, 0, 0, 0);
return {
time: { range: [start, end] },
targetPickerValue: {},
};
}
private _isDefaultState(): boolean {
return deepEqual(
{ time: this._time, targetPickerValue: this._targetPickerValue },
this._defaultState
);
}
private _resetLogbook() {
const defaultState = this._defaultState;
this._time = defaultState.time;
this._targetPickerValue = defaultState.targetPickerValue;
this._storedTargetPickerValue = undefined;
navigate("/logbook", { replace: true });
}
private _refreshLogbook() {
this.shadowRoot!.querySelector("ha-logbook")?.refresh();
}
private async _handleMenuAction(ev: HaDropdownSelectEvent) {
const action = ev.detail.item.value;
switch (action) {
case "download":
this._downloadData();
break;
case "refresh":
this._refreshLogbook();
break;
}
}
private _downloadData() {
const data =
this.shadowRoot!.querySelector("ha-logbook")?.getEntries() || [];
if (data.length === 0) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.logbook.download_data_error"),
text: this.hass.localize("ui.panel.logbook.error_no_data"),
warning: true,
});
return;
}
const headers = [
"time",
"entity_id",
"state",
"event_type",
"context_id",
"context_user_id",
"context_event_type",
"context_domain",
"context_service",
"context_entity_id",
"context_state",
"context_source",
];
const csv: string[][] = [headers];
for (const d of data) {
const time = fromUnixTime(d.when).toISOString();
csv.push([
time,
d.entity_id || "",
csvSafeString(d.state),
csvSafeString(d.attributes?.event_type),
d.context_id || "",
d.context_user_id || "",
csvSafeString(d.context_event_type),
d.context_domain || "",
d.context_service || "",
d.context_entity_id || "",
csvSafeString(d.context_state),
d.context_source || "",
]);
}
csvDownload(csv, "activity.csv");
}
static get styles() {
return [
haStyle,
@@ -0,0 +1,243 @@
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig, ClockCardDatePart } from "../types";
type ClockCardSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
type ClockCardValuePart = Exclude<ClockCardDatePart, ClockCardSeparatorPart>;
/**
* Normalized date configuration used by clock card renderers.
*/
interface ClockCardDateConfig {
parts: ClockCardDatePart[];
}
/**
* Resolves the locale and time zone for a clock card from `hass` and the
* card's configuration. Applies the optional `time_format` override to the
* locale and falls back to the user's preferred time zone.
*/
export const resolveClockCardLocale = (
hass: HomeAssistant,
config: Pick<ClockCardConfig, "time_format" | "time_zone">
): { locale: HomeAssistant["locale"]; timeZone: string } => {
const locale = config.time_format
? { ...hass.locale, time_format: config.time_format }
: hass.locale;
const timeZone =
config.time_zone ||
resolveTimeZone(locale.time_zone, hass.config?.time_zone);
return { locale, timeZone };
};
/**
* All selectable date tokens exposed by the clock card editor.
*/
export const CLOCK_CARD_DATE_PARTS: readonly ClockCardDatePart[] = [
"weekday-short",
"weekday-long",
"day-numeric",
"day-2-digit",
"month-short",
"month-long",
"month-numeric",
"month-2-digit",
"year-2-digit",
"year-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
];
const DATE_PART_OPTIONS: Record<
ClockCardValuePart,
Pick<Intl.DateTimeFormatOptions, "weekday" | "day" | "month" | "year">
> = {
"weekday-short": { weekday: "short" },
"weekday-long": { weekday: "long" },
"day-numeric": { day: "numeric" },
"day-2-digit": { day: "2-digit" },
"month-short": { month: "short" },
"month-long": { month: "long" },
"month-numeric": { month: "numeric" },
"month-2-digit": { month: "2-digit" },
"year-2-digit": { year: "2-digit" },
"year-numeric": { year: "numeric" },
};
const DATE_SEPARATORS: Record<ClockCardSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "\n",
};
const DATE_SEPARATOR_PARTS = new Set<ClockCardSeparatorPart>([
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
]);
const DATE_PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
const isClockCardDatePart = (value: string): value is ClockCardDatePart =>
CLOCK_CARD_DATE_PARTS.includes(value as ClockCardDatePart);
const isDateSeparatorPart = (
part: ClockCardDatePart
): part is ClockCardSeparatorPart =>
DATE_SEPARATOR_PARTS.has(part as ClockCardSeparatorPart);
/**
* Returns a reusable formatter for a specific date token.
*/
const getDatePartFormatter = (
part: ClockCardValuePart,
language: string,
timeZone?: string
): Intl.DateTimeFormat => {
const cacheKey = `${language}|${timeZone || ""}|${part}`;
const cached = DATE_PART_FORMATTERS.get(cacheKey);
if (cached) {
return cached;
}
const formatter = new Intl.DateTimeFormat(language, {
...DATE_PART_OPTIONS[part],
...(timeZone ? { timeZone } : {}),
});
DATE_PART_FORMATTERS.set(cacheKey, formatter);
return formatter;
};
const formatDatePart = (
part: ClockCardValuePart,
date: Date,
language: string,
timeZone?: string
) => getDatePartFormatter(part, language, timeZone).format(date);
/**
* Applies a single date token to Intl.DateTimeFormat options.
*/
const applyDatePartOption = (
options: Intl.DateTimeFormatOptions,
part: ClockCardDatePart
) => {
if (isDateSeparatorPart(part)) {
return;
}
const partOptions = DATE_PART_OPTIONS[part];
if (partOptions.weekday) {
options.weekday = partOptions.weekday;
}
if (partOptions.day) {
options.day = partOptions.day;
}
if (partOptions.month) {
options.month = partOptions.month;
}
if (partOptions.year) {
options.year = partOptions.year;
}
};
/**
* Sanitizes configured date tokens while preserving their literal order.
*/
const normalizeDateParts = (
parts: ClockCardConfig["date_format"]
): ClockCardDatePart[] =>
parts?.filter((part): part is ClockCardDatePart =>
isClockCardDatePart(part)
) || [];
/**
* Returns a normalized date config from a card configuration object.
*/
export const getClockCardDateConfig = (
config?: Pick<ClockCardConfig, "date_format">
): ClockCardDateConfig => ({
parts: normalizeDateParts(config?.date_format),
});
/**
* Checks whether the clock configuration resolves to any visible date output.
*/
export const hasClockCardDate = (
config?: Pick<ClockCardConfig, "date_format">
): boolean => getClockCardDateConfig(config).parts.length > 0;
/**
* Converts normalized date tokens into Intl.DateTimeFormat options.
*
* Separator tokens are ignored. If multiple tokens target the same Intl field,
* the last one wins.
*/
export const getClockCardDateTimeFormatOptions = (
dateConfig: ClockCardDateConfig
): Intl.DateTimeFormatOptions => {
const options: Intl.DateTimeFormatOptions = {};
dateConfig.parts.forEach((part) => {
applyDatePartOption(options, part);
});
return options;
};
/**
* Builds the final date string from literal date tokens.
*
* Value tokens are localized through Intl.DateTimeFormat. Separator tokens are
* always rendered literally. A default space is only inserted between adjacent
* value tokens.
*/
export const formatClockCardDate = (
date: Date,
dateConfig: ClockCardDateConfig,
language: string,
timeZone?: string
): string => {
let result = "";
let previousRenderedPartWasValue = false;
dateConfig.parts.forEach((part) => {
if (isDateSeparatorPart(part)) {
result += DATE_SEPARATORS[part];
previousRenderedPartWasValue = false;
return;
}
const value = formatDatePart(part, date, language, timeZone);
if (!value) {
return;
}
if (previousRenderedPartWasValue) {
result += " ";
}
result += value;
previousRenderedPartWasValue = true;
});
return result;
};
@@ -2,9 +2,17 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import "../../../../components/ha-marquee-text";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig } from "../types";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
resolveClockCardLocale,
} from "./clock-date-format";
function romanize12HourClock(num: number) {
const numerals = [
@@ -26,6 +34,11 @@ function romanize12HourClock(num: number) {
return numerals[num];
}
const DATE_UPDATE_INTERVAL = 60_000;
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
@customElement("hui-clock-card-analog")
export class HuiClockCardAnalog extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -40,42 +53,18 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
private _initDate() {
if (!this.config || !this.hass) {
return;
}
@state() private _date?: string;
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
private _dateInterval?: number;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
private _timeZone?: string;
this._computeOffsets();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
private _language?: string;
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
this._initDate();
}
public disconnectedCallback() {
@@ -84,18 +73,80 @@ export class HuiClockCardAnalog extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
this._stopDateTick();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
this._updateDate();
}
};
private _initDate() {
if (!this.config || !this.hass) {
this._stopDateTick();
this._date = undefined;
return;
}
const { timeZone } = resolveClockCardLocale(this.hass, this.config);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone,
});
this._computeOffsets();
this._updateDate();
if (this.isConnected && hasClockCardDate(this.config)) {
this._startDateTick();
} else {
this._stopDateTick();
}
}
private _startDateTick() {
this._stopDateTick();
this._dateInterval = window.setInterval(
() => this._updateDate(),
DATE_UPDATE_INTERVAL
);
}
private _stopDateTick() {
if (this._dateInterval) {
clearInterval(this._dateInterval);
this._dateInterval = undefined;
}
}
private _computeOffsets() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
const hourStr = parts.find((p) => p.type === "hour")?.value;
const minuteStr = parts.find((p) => p.type === "minute")?.value;
const secondStr = parts.find((p) => p.type === "second")?.value;
@@ -103,7 +154,7 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = new Date().getMilliseconds();
const ms = date.getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -113,16 +164,45 @@ export class HuiClockCardAnalog extends LitElement {
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
}
private _updateDate() {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
new Date(),
dateConfig,
this._language,
this._timeZone
);
}
private _computeClock = memoizeOne((config: ClockCardConfig) => {
const faceParts = config.face_style?.split("_");
const dateConfig = getClockCardDateConfig(config);
const showDate = hasClockCardDate(config);
const isLongDate =
dateConfig.parts.includes("month-long") ||
dateConfig.parts.includes("weekday-long");
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
showDate,
isLongDate,
};
});
render() {
if (!this.config) return nothing;
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const isNumbers = this.config?.face_style?.startsWith("numbers");
const isRoman = this.config?.face_style?.startsWith("roman");
const isUpright = this.config?.face_style?.endsWith("upright");
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate, showDate } =
this._computeClock(this.config);
const dateLines = this._date?.split("\n") ?? [];
const indicator = (number?: number) => html`
<div
@@ -168,14 +248,14 @@ export class HuiClockCardAnalog extends LitElement {
>
${
this.config.ticks === "quarter"
? Array.from({ length: 4 }, (_, i) => i).map(
? QUARTER_TICKS.map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 90}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -183,28 +263,30 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? Array.from({ length: 12 }, (_, i) => i).map(
? HOUR_TICKS.map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 30}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? Array.from({ length: 60 }, (_, i) => i).map(
? MINUTE_TICKS.map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${`--tick-rotation: ${i * 6}deg;`}
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
>
${
i % 5 === 0
@@ -216,14 +298,43 @@ export class HuiClockCardAnalog extends LitElement {
)
: nothing
}
${
showDate
? html`<div
class=${classMap({
date: true,
[sizeClass]: true,
"long-date": isLongDate,
})}
>
${dateLines.map(
(line) => html`
<ha-marquee-text
class="date-line"
speed="5"
pause-duration="1500"
pause-on-hover
>
${line}
</ha-marquee-text>
`
)}
</div>`
: nothing
}
<div class="center-dot"></div>
<div
class="hand hour"
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
></div>
<div
class="hand minute"
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
></div>
${
this.config.show_seconds
@@ -233,11 +344,13 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
style=${styleMap({
"animation-delay": `-${
this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)
}s`,
})}
></div>`
: nothing
}
@@ -417,6 +530,42 @@ export class HuiClockCardAnalog extends LitElement {
transform: translate(-50%, 0) rotate(360deg);
}
}
.date {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-align: center;
opacity: 0.8;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.date-line {
width: 100%;
}
.date.long-date:not(.size-medium):not(.size-large) {
top: 66%;
font-size: var(--ha-font-size-xs);
line-height: 1;
width: 45%;
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-xl);
}
`;
}
@@ -1,10 +1,16 @@
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-marquee-text";
import type { ClockCardConfig } from "../types";
import type { HomeAssistant } from "../../../../types";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
resolveClockCardLocale,
} from "./clock-date-format";
const INTERVAL = 1000;
@@ -24,37 +30,50 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _lastDateMinute?: string;
private _timeZone?: string;
private _language?: string;
private _initDate() {
if (!this.config || !this.hass) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
let locale = this.hass?.locale;
if (this.config?.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
const { locale, timeZone } = resolveClockCardLocale(this.hass, this.config);
const h12 = useAmPm(locale);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: h12 ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: h12 ? "h12" : "h23",
timeZone:
this.config?.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
timeZone,
});
this._lastDateMinute = undefined;
this._tick();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("hass")) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass");
if (!oldHass || oldHass.locale !== this.hass?.locale) {
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
@@ -71,6 +90,7 @@ export class HuiClockCardDigital extends LitElement {
}
private _startTick() {
this._stopTick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
this._tick();
}
@@ -85,7 +105,8 @@ export class HuiClockCardDigital extends LitElement {
private _tick() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
this._timeHour = parts.find((part) => part.type === "hour")?.value;
this._timeMinute = parts.find((part) => part.type === "minute")?.value;
@@ -93,6 +114,33 @@ export class HuiClockCardDigital extends LitElement {
? parts.find((part) => part.type === "second")?.value
: undefined;
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
this._updateDate(date);
}
private _updateDate(date: Date) {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
if (
this._timeMinute !== undefined &&
this._timeMinute === this._lastDateMinute &&
this._date !== undefined
) {
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
date,
dateConfig,
this._language,
this._timeZone
);
this._lastDateMinute = this._timeMinute;
}
render() {
@@ -101,28 +149,65 @@ export class HuiClockCardDigital extends LitElement {
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const showDate = hasClockCardDate(this.config);
return html`
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${
this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing
}
${
this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing
}
<div class="clock-container">
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${
this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing
}
${
this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing
}
</div>
</div>
${
showDate
? html`<div class="date-container">
<div class="date ${sizeClass}">
${this._date
?.split("\n")
.map(
(line) => html`
<ha-marquee-text
class="date-line"
speed="10"
pause-duration="1500"
pause-on-hover
>
${line}
</ha-marquee-text>
`
)}
</div>
</div>`
: nothing
}
`;
}
static styles = css`
:host {
display: block;
width: 100%;
}
.clock-container {
width: 100%;
display: flex;
justify-content: center;
}
.date-container {
width: 100%;
margin-top: var(--ha-space-1);
}
.time-parts {
@@ -192,6 +277,29 @@ export class HuiClockCardDigital extends LitElement {
content: ":";
margin: 0 2px;
}
.date {
margin-inline: auto;
text-align: center;
opacity: 0.8;
font-size: var(--ha-font-size-s);
line-height: 1.1;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.date-line {
width: 100%;
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-2xl);
}
`;
}
@@ -226,21 +226,24 @@ export function generatePowerSourcesGraphData(
// Draw in reverse order so 0 value lines are overwritten
["solar", "battery", "grid"].forEach((key, i) => {
if (seriesData[key]) {
pushSeries(key, seriesData[key].positive, "positive", 3 - i);
const series = seriesData[key];
if (series) {
pushSeries(key, series.positive, "positive", 3 - i);
}
});
// Draw in reverse order but above positive series
["battery", "grid"].forEach((key, i) => {
if (seriesData[key]) {
pushSeries(key, seriesData[key].negative, "negative", 4 - i);
const series = seriesData[key];
if (series) {
pushSeries(key, series.negative, "negative", 4 - i);
}
});
Object.keys(statIds).forEach((key) => {
if (seriesData[key]) {
const { colorHex, rgb } = seriesData[key];
const series = seriesData[key];
if (series) {
const { colorHex, rgb } = series;
legendData!.push({
id: key,
+2 -26
View File
@@ -23,6 +23,7 @@ import type {
LovelaceGridOptions,
LovelaceHeaderFooter,
} from "../types";
import { migrateEntitiesCardConfig } from "./migrate-card-config";
import type { EntitiesCardConfig } from "./types";
import { haStyleScrollbar } from "../../../resources/styles";
@@ -49,32 +50,7 @@ export const computeShowHeaderToggle = <
return !!config.show_header_toggle;
};
export const migrateEntitiesCardConfig = (
config: EntitiesCardConfig
): EntitiesCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: (rest as EntityConfig).time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (LovelaceRowConfig | string)[],
};
};
export { migrateEntitiesCardConfig };
@customElement("hui-entities-card")
class HuiEntitiesCard extends LitElement implements LovelaceCard {
+2 -26
View File
@@ -31,35 +31,11 @@ import "../components/hui-timestamp-display";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import "../components/hui-warning-element";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import { migrateGlanceCardConfig } from "./migrate-card-config";
import type { GlanceCardConfig, GlanceConfigEntity } from "./types";
import { TIMESTAMP_STATE_DOMAINS } from "../../../common/const";
export const migrateGlanceCardConfig = (
config: GlanceCardConfig
): GlanceCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: rest.time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (GlanceConfigEntity | string)[],
};
};
export { migrateGlanceCardConfig };
@customElement("hui-glance-card")
export class HuiGlanceCard extends LitElement implements LovelaceCard {
@@ -0,0 +1,66 @@
import type { EntityConfig, LovelaceRowConfig } from "../entity-rows/types";
import type {
EntitiesCardConfig,
GlanceCardConfig,
GlanceConfigEntity,
} from "./types";
export const migrateEntitiesCardConfig = (
config: EntitiesCardConfig
): EntitiesCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
// Custom rows own their config schema and may use `format` with a
// different meaning (e.g. custom:multiple-entity-row), so leave it
// untouched.
if (e.type?.startsWith("custom:")) {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: (rest as EntityConfig).time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (LovelaceRowConfig | string)[],
};
};
export const migrateGlanceCardConfig = (
config: GlanceCardConfig
): GlanceCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: rest.time_format ?? format,
};
});
if (!changed) {
return config;
}
return {
...config,
entities: newEntities as (GlanceConfigEntity | string)[],
};
};
+17
View File
@@ -445,12 +445,29 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date_format?: ClockCardDatePart[];
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";
face_style?: "markers" | "numbers_upright" | "roman";
}
export type ClockCardDatePart =
| "weekday-short"
| "weekday-long"
| "day-numeric"
| "day-2-digit"
| "month-short"
| "month-long"
| "month-numeric"
| "month-2-digit"
| "year-2-digit"
| "year-numeric"
| "separator-dash"
| "separator-slash"
| "separator-dot"
| "separator-new-line";
export interface MediaControlCardConfig extends LovelaceCardConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
@@ -250,18 +250,14 @@ export class HuiBadgePicker extends LitElement {
const usedEntities = computeUsedEntities(this.lovelace);
const unusedEntities = calcUnusedEntities(this.hass, usedEntities);
this._usedEntities = [...usedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
this._unusedEntities = [...unusedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
const isAvailable = (eid: string) => {
const stateObj = this.hass!.states[eid];
return (
stateObj && stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
};
this._usedEntities = [...usedEntities].filter(isAvailable);
this._unusedEntities = [...unusedEntities].filter(isAvailable);
this._loadBages();
}
@@ -274,18 +274,14 @@ export class HuiCardPicker extends LitElement {
const usedEntities = computeUsedEntities(this.lovelace);
const unusedEntities = calcUnusedEntities(this.hass, usedEntities);
this._usedEntities = [...usedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
this._unusedEntities = [...unusedEntities].filter(
(eid) =>
this.hass!.states[eid] &&
this.hass!.states[eid].state !== UNAVAILABLE &&
this.hass!.states[eid].state !== UNKNOWN
);
const isAvailable = (eid: string) => {
const stateObj = this.hass!.states[eid];
return (
stateObj && stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
};
this._usedEntities = [...usedEntities].filter(isAvailable);
this._unusedEntities = [...unusedEntities].filter(isAvailable);
this._loadCards();
}
@@ -1,99 +1,84 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
assign,
boolean,
defaulted,
enums,
literal,
object,
optional,
string,
union,
} from "superstruct";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { ValueChangedEvent } from "../../../../types";
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
implements LovelaceCardEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _config?: ClockCardConfig;
private _schema = memoizeOne(
(
localize: LocalizeFunc,
clockStyle: ClockCardConfig["clock_style"],
ticks: ClockCardConfig["ticks"],
showSeconds: boolean | undefined
) =>
(localize: LocalizeFunc) =>
[
{ name: "title", selector: { text: {} } },
{
name: "clock_style",
selector: {
select: {
mode: "dropdown",
mode: "box",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -119,140 +104,155 @@ export class HuiClockCardEditor
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
...(clockStyle === "digital"
? ([
{
name: "time_format",
selector: {
select: {
mode: "dropdown",
options: ["auto", ...Object.values(TimeFormat)].map(
(value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.time_formats.${value}`
),
})
),
},
},
},
] as const satisfies readonly HaFormSchema[])
: clockStyle === "analog"
? ([
{
name: "border",
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.border.description`
),
},
default: false,
selector: {
boolean: {},
},
},
{
name: "ticks",
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.ticks.description`
),
},
default: "hour",
selector: {
select: {
mode: "dropdown",
options: ["none", "quarter", "hour", "minute"].map(
(value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.ticks.${value}.label`
),
description: localize(
`ui.panel.lovelace.editor.card.clock.ticks.${value}.description`
),
})
),
},
},
},
...(showSeconds
? ([
{
name: "seconds_motion",
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
),
},
default: "continuous",
selector: {
select: {
mode: "dropdown",
options: ["continuous", "tick"].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.label`
),
description: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.description`
),
})),
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
...(ticks !== "none"
? ([
{
name: "face_style",
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.face_style.description`
),
},
default: "markers",
selector: {
select: {
mode: "dropdown",
options: [
"markers",
"numbers_upright",
"roman",
].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.face_style.${value}.label`
),
description: localize(
`ui.panel.lovelace.editor.card.clock.face_style.${value}.description`
),
})),
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "date_format",
required: false,
selector: {
ui_clock_date_format: {},
},
},
{
name: "time_format",
hidden: {
field: "clock_style",
operator: "not_eq",
value: "digital",
},
selector: {
select: {
mode: "dropdown",
options: ["auto", ...Object.values(TimeFormat)].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.time_formats.${value}`
),
})),
},
},
},
{
name: "border",
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.border.description`
),
},
default: false,
selector: {
boolean: {},
},
},
{
name: "ticks",
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.ticks.description`
),
},
default: "hour",
selector: {
select: {
mode: "dropdown",
options: ["none", "quarter", "hour", "minute"].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.ticks.${value}.label`
),
description: localize(
`ui.panel.lovelace.editor.card.clock.ticks.${value}.description`
),
})),
},
},
},
{
name: "seconds_motion",
hidden: {
condition: "or",
conditions: [
{ field: "clock_style", operator: "not_eq", value: "analog" },
{ field: "show_seconds", operator: "not_eq", value: true },
],
},
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
),
},
default: "continuous",
selector: {
select: {
mode: "dropdown",
options: ["continuous", "tick"].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.label`
),
description: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.description`
),
})),
},
},
},
{
name: "face_style",
hidden: {
condition: "or",
conditions: [
{ field: "clock_style", operator: "not_eq", value: "analog" },
{ field: "ticks", value: "none" },
],
},
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.face_style.description`
),
},
default: "markers",
selector: {
select: {
mode: "dropdown",
options: ["markers", "numbers_upright", "roman"].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.face_style.${value}.label`
),
description: localize(
`ui.panel.lovelace.editor.card.clock.face_style.${value}.description`
),
})),
},
},
},
{ name: "time_zone", selector: { timezone: {} } },
] 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);
@@ -260,20 +260,14 @@ export class HuiClockCardEditor
}
protected render() {
if (!this.hass || !this._config) {
if (!this._config) {
return nothing;
}
return html`
<ha-form
.hass=${this.hass}
.data=${this._data(this._config)}
.schema=${this._schema(
this.hass.localize,
this._data(this._config).clock_style,
this._data(this._config).ticks,
this._data(this._config).show_seconds
)}
.schema=${this._schema(this._localize)}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
@@ -281,35 +275,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 = (
@@ -317,47 +316,43 @@ export class HuiClockCardEditor
) => {
switch (schema.name) {
case "title":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.title"
);
return this._localize("ui.panel.lovelace.editor.card.generic.title");
case "clock_style":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.clock_style`
);
case "clock_size":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.clock_size`
);
return this._localize(`ui.panel.lovelace.editor.card.clock.clock_size`);
case "time_format":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.time_format`
);
case "time_zone":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.time_zone`
);
return this._localize(`ui.panel.lovelace.editor.card.clock.time_zone`);
case "show_seconds":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.show_seconds`
);
case "no_background":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
case "date_format":
return this._localize(`ui.panel.lovelace.editor.card.clock.date.label`);
case "border":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.border.label`
);
case "ticks":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.ticks.label`
);
case "seconds_motion":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.label`
);
case "face_style":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.face_style.label`
);
default:
@@ -369,20 +364,24 @@ export class HuiClockCardEditor
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "date_format":
return this._localize(
`ui.panel.lovelace.editor.card.clock.date.description`
);
case "border":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.border.description`
);
case "ticks":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.ticks.description`
);
case "seconds_motion":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
);
case "face_style":
return this.hass!.localize(
return this._localize(
`ui.panel.lovelace.editor.card.clock.face_style.description`
);
default:
@@ -1,3 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -53,6 +54,65 @@ const cardConfigStruct = assign(
})
);
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 0, step: "any", mode: "box" } },
},
{
name: "show_names",
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
],
},
{
name: "",
type: "grid",
schema: [
{
name: "min_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
{
name: "max_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
],
},
{
name: "fit_y_data",
required: false,
hidden: {
condition: "and",
conditions: [
{ field: "min_y_axis", operator: "not_exists" },
{ field: "max_y_axis", operator: "not_exists" },
],
},
selector: { boolean: {} },
},
] as const satisfies readonly HaFormSchema[];
@customElement("hui-history-graph-card-editor")
export class HuiHistoryGraphCardEditor
extends LitElement
@@ -69,65 +129,6 @@ export class HuiHistoryGraphCardEditor
this._config = config;
}
private _schema = memoizeOne(
(showFitOption: boolean) =>
[
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 0, step: "any", mode: "box" } },
},
{
name: "show_names",
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
],
},
{
name: "",
type: "grid",
schema: [
{
name: "min_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
{
name: "max_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
],
},
...(showFitOption
? [
{
name: "fit_y_data",
required: false,
selector: { boolean: {} },
},
]
: []),
] as const
);
private _subForm = memoizeOne((localize: LocalizeFunc, entityId: string) => ({
schema: [
{ name: "entity", selector: { entity: {} }, required: true },
@@ -175,11 +176,6 @@ export class HuiHistoryGraphCardEditor
`;
}
const schema = this._schema(
this._config!.min_y_axis !== undefined ||
this._config!.max_y_axis !== undefined
);
const configEntities = this._config.entities
? (processEditorEntities(this._config.entities) as GraphEntityConfig[])
: [];
@@ -187,7 +183,7 @@ export class HuiHistoryGraphCardEditor
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
@@ -260,9 +256,10 @@ export class HuiHistoryGraphCardEditor
const domain = computeDomain(entityId);
const isNumberDomain =
domain === "counter" || domain === "number" || domain === "input_number";
const stateObj = this.hass!.states[entityId];
const stateObj = this.hass!.states[entityId] as HassEntity | undefined;
const attributes = stateObj?.attributes;
return !isNumericFromAttributes(attributes) && !isNumberDomain;
const isNumeric = attributes ? isNumericFromAttributes(attributes) : false;
return !isNumeric && !isNumberDomain;
};
// remove "color" option when needed
@@ -281,9 +278,7 @@ export class HuiHistoryGraphCardEditor
) as HistoryGraphCardConfig;
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "show_names":
case "logarithmic_scale":
@@ -92,7 +92,6 @@ export class HuiTileCardEditor
(
localize: LocalizeFunc,
entityId: string | undefined,
hideState: boolean,
showTimeFormat: boolean
) =>
[
@@ -144,31 +143,25 @@ export class HuiTileCardEditor
},
],
},
...(!hideState
? ([
{
name: "state_content",
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
] as const satisfies readonly HaFormSchema[])
: []),
...(showTimeFormat
? ([
{
name: "time_format",
selector: {
ui_time_format: {},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "state_content",
hidden: { field: "hide_state", value: true },
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
{
name: "time_format",
hidden: !showTimeFormat,
selector: {
ui_time_format: {},
},
},
{
name: "content_layout",
required: true,
@@ -293,12 +286,7 @@ export class HuiTileCardEditor
this._config.state_content
);
const schema = this._schema(
this.hass.localize,
entityId,
this._config.hide_state ?? false,
showTimeFormat
);
const schema = this._schema(this.hass.localize, entityId, showTimeFormat);
const vertical = this._config.vertical ?? false;
+12 -9
View File
@@ -608,22 +608,24 @@ class HaPanelMy extends LitElement {
}
const resultParams = {};
for (const [key, type] of Object.entries(this._redirect!.params || {})) {
if (!params[key] && type.endsWith("?")) {
const value = params[key];
if (!value && type.endsWith("?")) {
continue;
}
if (!params[key] || !this._checkParamType(type, params[key])) {
if (!value || !this._checkParamType(type, value)) {
throw Error();
}
resultParams[key] = params[key];
resultParams[key] = value;
}
for (const [key, type] of Object.entries(
this._redirect!.optional_params || {}
)) {
if (params[key]) {
if (!this._checkParamType(type, params[key])) {
const value = params[key];
if (value) {
if (!this._checkParamType(type, value)) {
throw Error();
}
resultParams[key] = params[key];
resultParams[key] = value;
}
}
return Object.keys(resultParams).length
@@ -637,11 +639,12 @@ class HaPanelMy extends LitElement {
}
const resultParams = {};
for (const [key, type] of Object.entries(this._redirect!.optional_params)) {
if (params[key]) {
if (!this._checkParamType(type, params[key])) {
const value = params[key];
if (value) {
if (!this._checkParamType(type, value)) {
throw Error();
}
resultParams[key] = params[key];
resultParams[key] = value;
}
}
return Object.keys(resultParams).length
+38 -1
View File
@@ -1486,6 +1486,7 @@
"auto_revert": "Settings will automatically revert in {time}.",
"reverted": "Home Assistant reverted the HTTP server settings.",
"changes_label": "Changed settings:",
"not_set": "Not set",
"confirm": "Confirm",
"revert": "Revert",
"close": "Close",
@@ -8679,6 +8680,10 @@
"description": "Configure how Home Assistant serves its web interface. Saving restarts Home Assistant.",
"save": "Save",
"save_no_changes": "Nothing changed — no restart needed.",
"save_error": "Could not save the HTTP configuration.",
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
"invalid_host": "Enter a valid IP address.",
"invalid_network": "Enter a valid IP address or network.",
"save_confirm": {
"title": "Restart required",
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
@@ -8690,7 +8695,7 @@
"ssl": "SSL/TLS",
"reverse_proxy": "Reverse proxy",
"ip_banning": "IP banning",
"advanced": "Advanced"
"advanced": "More options"
},
"fields": {
"server_port": "Server port",
@@ -10140,6 +10145,33 @@
"large": "Large"
},
"show_seconds": "Display seconds",
"date": {
"label": "Date",
"description": "Select and order the date parts. Add a separator to control punctuation.",
"sections": {
"weekday": "Weekday",
"day": "Day",
"month": "Month",
"year": "Year",
"separator": "Separator"
},
"parts": {
"weekday-short": "Short",
"weekday-long": "Long",
"day-numeric": "Numeric",
"day-2-digit": "2-digit",
"month-short": "Short",
"month-long": "Long",
"month-numeric": "Numeric",
"month-2-digit": "2-digit",
"year-2-digit": "2-digit",
"year-numeric": "Full",
"separator-dash": "Dash",
"separator-slash": "Slash",
"separator-dot": "Dot",
"separator-new-line": "New line"
}
},
"time_format": "[%key:ui::panel::profile::time_format::dropdown_label%]",
"time_formats": {
"auto": "Use user settings",
@@ -11539,6 +11571,11 @@
"add_card": "Add current view as card",
"add_card_error": "Unable to add card",
"error_no_data": "You need to select some data sources first."
},
"logbook": {
"download_data": "[%key:ui::panel::history::download_data%]",
"download_data_error": "[%key:ui::panel::history::download_data_error%]",
"error_no_data": "No activity for the selected target in the selected time range."
}
},
"tips": {
+15
View File
@@ -0,0 +1,15 @@
import { fileDownload } from "./file_download";
export function csvSafeString(s: string | undefined): string {
if (!s) return "";
return /,|"/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
}
export function csvDownload(data: string[][], filename: string) {
const csv = data.map((row) => row.join(",").concat("\n"));
const blob = new Blob(csv, {
type: "text/csv",
});
const url = window.URL.createObjectURL(blob);
fileDownload(url, filename);
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
isIPAddress,
isIPAddressV4OrV6,
isIPNetwork,
isIPv6Address,
} from "../../../src/common/string/is_ip_address";
describe("isIPAddress (IPv4)", () => {
it("accepts valid IPv4 addresses", () => {
expect(isIPAddress("192.168.1.10")).toBe(true);
expect(isIPAddress("0.0.0.0")).toBe(true);
expect(isIPAddress("255.255.255.255")).toBe(true);
});
it("rejects invalid IPv4 addresses", () => {
expect(isIPAddress("256.1.1.1")).toBe(false);
expect(isIPAddress("192.168.1")).toBe(false);
expect(isIPAddress("192.168.1.10/24")).toBe(false);
expect(isIPAddress("fe80::1")).toBe(false);
expect(isIPAddress("")).toBe(false);
});
});
describe("isIPv6Address", () => {
it("accepts valid IPv6 addresses", () => {
expect(isIPv6Address("fe80::85d:e82c:9446:7995")).toBe(true);
expect(isIPv6Address("::1")).toBe(true);
expect(isIPv6Address("1050:0000:0000:0000:0005:0600:300c:326b")).toBe(true);
expect(isIPv6Address("::ffff:192.168.1.1")).toBe(true);
});
it("rejects invalid IPv6 addresses", () => {
expect(isIPv6Address("192.168.1.10")).toBe(false);
expect(isIPv6Address("fe80::85d::7995")).toBe(false);
expect(isIPv6Address("gggg::1")).toBe(false);
expect(isIPv6Address("")).toBe(false);
});
});
describe("isIPAddressV4OrV6", () => {
it("accepts both families", () => {
expect(isIPAddressV4OrV6("192.168.1.10")).toBe(true);
expect(isIPAddressV4OrV6("fe80::1")).toBe(true);
});
it("rejects networks and garbage", () => {
expect(isIPAddressV4OrV6("192.168.1.0/24")).toBe(false);
expect(isIPAddressV4OrV6("not-an-ip")).toBe(false);
});
});
describe("isIPNetwork (CIDR)", () => {
it("accepts valid networks", () => {
expect(isIPNetwork("192.168.1.0/24")).toBe(true);
expect(isIPNetwork("10.0.0.0/8")).toBe(true);
expect(isIPNetwork("172.16.0.0/12")).toBe(true);
expect(isIPNetwork("0.0.0.0/0")).toBe(true);
expect(isIPNetwork("fd00::/8")).toBe(true);
expect(isIPNetwork("fe80::/128")).toBe(true);
});
it("rejects invalid networks", () => {
// Prefix out of range — the reported production error.
expect(isIPNetwork("172.30.33.0/24444444")).toBe(false);
expect(isIPNetwork("192.168.1.0/33")).toBe(false);
expect(isIPNetwork("fd00::/129")).toBe(false);
expect(isIPNetwork("192.168.1.0")).toBe(false);
expect(isIPNetwork("192.168.1.0/24/24")).toBe(false);
expect(isIPNetwork("not-a-network/24")).toBe(false);
});
});
// The HTML `pattern` attribute is anchored by the browser as `^(?:…)$`.
const matchesPattern = (pattern: string, value: string): boolean =>
new RegExp(`^(?:${pattern})$`).test(value);
describe("IP_ADDRESS_PATTERN", () => {
it("accepts IPv4 and IPv6 addresses", () => {
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.10")).toBe(true);
expect(matchesPattern(IP_ADDRESS_PATTERN, "fe80::1")).toBe(true);
});
it("rejects networks and garbage", () => {
expect(matchesPattern(IP_ADDRESS_PATTERN, "192.168.1.0/24")).toBe(false);
expect(matchesPattern(IP_ADDRESS_PATTERN, "not-an-ip")).toBe(false);
expect(matchesPattern(IP_ADDRESS_PATTERN, "256.1.1.1")).toBe(false);
});
});
describe("IP_ADDRESS_OR_NETWORK_PATTERN", () => {
it("accepts addresses and networks", () => {
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.10")).toBe(
true
);
expect(
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/24")
).toBe(true);
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "fd00::/8")).toBe(
true
);
});
it("rejects out-of-range prefixes and garbage", () => {
// The reported production error.
expect(
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "172.30.33.0/24444444")
).toBe(false);
expect(
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "192.168.1.0/33")
).toBe(false);
expect(
matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "1.1.1.1/233444")
).toBe(false);
expect(matchesPattern(IP_ADDRESS_OR_NETWORK_PATTERN, "not-an-ip")).toBe(
false
);
});
});
@@ -0,0 +1,110 @@
import { describe, it, expect } from "vitest";
import { getAvailableClockDatePartSections } from "../../src/components/ha-clock-date-format-picker";
type TestSection = "weekday" | "day" | "month" | "year" | "separator";
const section = (id: TestSection, itemIds: string[]) => ({
id,
title: id,
items: itemIds.map((itemId) => ({ id: itemId, primary: itemId })),
});
const ALL_SECTIONS = [
section("day", ["day-numeric", "day-2-digit"]),
section("month", ["month-numeric", "month-short", "month-long"]),
section("year", ["year-numeric", "year-2-digit"]),
section("weekday", ["weekday-short", "weekday-long"]),
section("separator", [
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
]),
];
describe("getAvailableClockDatePartSections", () => {
it("returns every section when no value is set", () => {
const result = getAvailableClockDatePartSections(ALL_SECTIONS, []);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"month",
"year",
"weekday",
"separator",
]);
});
it("hides a group's section once a value from that group is used", () => {
const result = getAvailableClockDatePartSections(ALL_SECTIONS, [
"day-numeric",
]);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"month",
"year",
"weekday",
"separator",
]);
});
it("keeps the edited item's own group visible via excludeIndex", () => {
const value = ["day-numeric", "month-short"];
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value, 0);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"year",
"weekday",
"separator",
]);
});
it("keeps the edited item's group visible even when a duplicate of that group exists elsewhere", () => {
const value = ["day-numeric", "day-2-digit", "month-long"];
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value, 0);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"year",
"weekday",
"separator",
]);
});
it("does not crash and keeps normal filtering when excludeIndex is out of range", () => {
const value = ["day-numeric", "month-long"];
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value, 5);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"year",
"weekday",
"separator",
]);
});
it("never hides the separator section, even with multiple separators used", () => {
const value = [
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
];
const result = getAvailableClockDatePartSections(ALL_SECTIONS, value);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"month",
"year",
"weekday",
"separator",
]);
});
it("treats an unrecognized token as a separator and does not hide any group", () => {
const result = getAvailableClockDatePartSections(ALL_SECTIONS, [
"not-a-real-part",
]);
expect(result.map((sectionData) => sectionData.id)).toEqual([
"day",
"month",
"year",
"weekday",
"separator",
]);
});
});
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { isFieldHidden } from "../../../src/components/ha-form/conditions";
import type { HaFormSchema } from "../../../src/components/ha-form/types";
const field = (hidden: HaFormSchema["hidden"]): HaFormSchema =>
({ name: "field", selector: { text: {} }, hidden }) as HaFormSchema;
describe("isFieldHidden", () => {
it("shows a field without a hidden condition", () => {
expect(isFieldHidden(field(undefined), { a: 1 })).toBe(false);
});
it("honors a boolean hidden", () => {
expect(isFieldHidden(field(true), {})).toBe(true);
expect(isFieldHidden(field(false), {})).toBe(false);
});
describe("operators", () => {
it("eq (default) matches equal values", () => {
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 1 })).toBe(
true
);
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 2 })).toBe(
false
);
});
it("not_eq matches different values", () => {
const schema = field({ field: "a", operator: "not_eq", value: 1 });
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
});
it("in matches membership", () => {
const schema = field({ field: "a", operator: "in", value: ["x", "y"] });
expect(isFieldHidden(schema, { a: "y" })).toBe(true);
expect(isFieldHidden(schema, { a: "z" })).toBe(false);
});
it("not_in matches non-membership", () => {
const schema = field({
field: "a",
operator: "not_in",
value: ["x", "y"],
});
expect(isFieldHidden(schema, { a: "z" })).toBe(true);
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
});
it("exists matches a defined non-empty value", () => {
const schema = field({ field: "a", operator: "exists" });
expect(isFieldHidden(schema, { a: "x" })).toBe(true);
expect(isFieldHidden(schema, { a: "" })).toBe(false);
expect(isFieldHidden(schema, {})).toBe(false);
});
it("not_exists matches a missing or empty value", () => {
const schema = field({ field: "a", operator: "not_exists" });
expect(isFieldHidden(schema, {})).toBe(true);
expect(isFieldHidden(schema, { a: null } as any)).toBe(true);
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
});
});
describe("combinators", () => {
it("and requires every condition", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
});
it("or requires any condition", () => {
const schema = field({
condition: "or",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldHidden(schema, { a: 9, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 9, b: 9 })).toBe(false);
});
it("not negates its conditions", () => {
const schema = field({
condition: "not",
conditions: [{ field: "a", value: 1 }],
});
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
});
it("nests combinators", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{
condition: "or",
conditions: [
{ field: "b", value: 2 },
{ field: "c", value: 3 },
],
},
],
});
expect(isFieldHidden(schema, { a: 1, b: 9, c: 3 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9, c: 9 })).toBe(false);
expect(isFieldHidden(schema, { a: 9, b: 2, c: 3 })).toBe(false);
});
});
it("treats an array of conditions as AND", () => {
const schema = field([
{ field: "a", value: 1 },
{ field: "b", value: 2 },
]);
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
});
it("handles missing data", () => {
expect(isFieldHidden(field({ field: "a", value: 1 }), undefined)).toBe(
false
);
});
});
+2
View File
@@ -483,6 +483,7 @@ test.describe("Theming", () => {
() =>
(document.querySelector("ha-test") as any)?.hass?.themes?.darkMode ===
true,
undefined,
{ timeout: QUICK_TIMEOUT }
);
});
@@ -499,6 +500,7 @@ test.describe("Theming", () => {
getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color")
.trim() !== "",
undefined,
{ timeout: QUICK_TIMEOUT }
);
+6 -9
View File
@@ -1,20 +1,19 @@
import { assert, describe, it } from "vitest";
import { createHassioSession } from "../../src/data/hassio/ingress";
import type { HomeAssistant } from "../../src/types";
describe("Create hassio session", () => {
const hass = {
config: { version: "1.0.0" },
callApi: async () => ({
data: { session: "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh" },
callWS: async () => ({
session: "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh",
}),
};
} as unknown as Pick<HomeAssistant, "callWS">;
it("Test create session without HTTPS", async () => {
// @ts-ignore
global.document = {};
// @ts-ignore
global.location = {};
// @ts-ignore
await createHassioSession(hass);
assert.strictEqual(
// @ts-ignore
@@ -27,7 +26,6 @@ describe("Create hassio session", () => {
global.document = {};
// @ts-ignore
global.location = { protocol: "https:" };
// @ts-ignore
await createHassioSession(hass);
assert.strictEqual(
// @ts-ignore
@@ -43,9 +41,8 @@ describe("Create hassio session", () => {
});
it("Test fail to create", async () => {
const createSessionPromise = createHassioSession({
// @ts-ignore
callApi: async () => {
// noop
callWS: async () => {
throw new Error("Failed to create session");
},
}).then(
() => true,
@@ -0,0 +1,261 @@
import { assert, describe, it } from "vitest";
import type { ClockCardDatePart } from "../../../../src/panels/lovelace/cards/types";
import {
formatClockCardDate,
getClockCardDateConfig,
getClockCardDateTimeFormatOptions,
hasClockCardDate,
} from "../../../../src/panels/lovelace/cards/clock/clock-date-format";
describe("clock-date-format", () => {
const date = new Date("2024-11-08T10:20:30.000Z");
it("returns an empty config when date format is missing", () => {
assert.deepEqual(getClockCardDateConfig(), { parts: [] });
});
it("preserves literal token order", () => {
const config = getClockCardDateConfig({
date_format: [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
],
});
assert.deepEqual(config.parts, [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
]);
});
it("filters invalid date tokens", () => {
const config = getClockCardDateConfig({
date_format: [
"month-short",
"invalid",
"year-2-digit",
] as unknown as ClockCardDatePart[],
});
assert.deepEqual(config.parts, ["month-short", "year-2-digit"]);
});
it("builds Intl options from selected date tokens", () => {
const options = getClockCardDateTimeFormatOptions({
parts: [
"weekday-short",
"separator-slash",
"day-2-digit",
"month-long",
"month-numeric",
"year-2-digit",
],
});
assert.deepEqual(options, {
weekday: "short",
day: "2-digit",
month: "numeric",
year: "2-digit",
});
});
it("reports whether any date part is configured", () => {
assert.equal(hasClockCardDate(), false);
assert.equal(hasClockCardDate({ date_format: [] }), false);
assert.equal(hasClockCardDate({ date_format: ["separator-dot"] }), true);
assert.equal(
hasClockCardDate({ date_format: ["separator-new-line"] }),
true
);
assert.equal(hasClockCardDate({ date_format: ["weekday-short"] }), true);
});
it("formats output in configured part order with literal separators", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-dash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08-24");
});
it("uses separator only for the next gap", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dot",
"month-numeric",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8.11 2024");
});
it("supports using the same separator style multiple times", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-slash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08/24");
});
it("renders separators even when no value follows", () => {
const result = formatClockCardDate(
date,
{
parts: ["day-numeric", "separator-dash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "8-.");
});
it("renders separators even when no value precedes", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-slash", "separator-dot", "day-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "/.8");
});
it("renders all consecutive separators between values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8-/.11");
});
it("renders repeated separators without deduplication", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-dash",
"separator-dash",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8---11");
});
it("renders separator-only configurations", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-dash", "separator-slash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "-/.");
});
it("supports inserting a new line between date values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-new-line",
"day-2-digit",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "11\n08 2024");
});
it("allows multiple variants for the same date part", () => {
const result = formatClockCardDate(
date,
{
parts: ["month-short", "month-long", "year-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "Nov November 2024");
});
it("filters invalid tokens when formatting", () => {
const config = getClockCardDateConfig({
date_format: [
"month-numeric",
"invalid",
"year-numeric",
] as unknown as ClockCardDatePart[],
});
const result = formatClockCardDate(date, config, "en", "UTC");
assert.equal(result, "11 2024");
});
});
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
migrateEntitiesCardConfig,
migrateGlanceCardConfig,
} from "../../../../src/panels/lovelace/cards/migrate-card-config";
import type {
EntitiesCardConfig,
GlanceCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
describe("migrateEntitiesCardConfig", () => {
it("migrates the legacy `format` option to `time_format` on native rows", () => {
const config = {
type: "entities",
entities: [{ entity: "sensor.last_changed", format: "relative" }],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result.entities).toEqual([
{ entity: "sensor.last_changed", time_format: "relative" },
]);
// `format` is dropped after migration
expect(result.entities[0]).not.toHaveProperty("format");
});
it("leaves custom rows untouched", () => {
const customRow = {
type: "custom:multiple-entity-row",
entity: "sensor.power",
format: "precision1",
};
const config = {
type: "entities",
entities: [customRow],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
// Nothing changed, so the same config reference is returned and the
// custom row keeps its own `format` semantics.
expect(result).toBe(config);
expect(result.entities[0]).toEqual(customRow);
});
it("returns the same config reference when there is nothing to migrate", () => {
const config = {
type: "entities",
entities: [{ entity: "sensor.temperature" }, { entity: "light.kitchen" }],
} as EntitiesCardConfig;
expect(migrateEntitiesCardConfig(config)).toBe(config);
});
it("passes string rows through untouched", () => {
const config = {
type: "entities",
entities: ["sensor.temperature", { entity: "sensor.x", format: "time" }],
} as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result.entities[0]).toBe("sensor.temperature");
expect(result.entities[1]).toEqual({
entity: "sensor.x",
time_format: "time",
});
});
it("keeps a pre-existing `time_format` over the legacy `format`", () => {
const config = {
type: "entities",
entities: [
{ entity: "sensor.x", format: "relative", time_format: "datetime" },
],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result.entities[0]).toEqual({
entity: "sensor.x",
time_format: "datetime",
});
});
});
describe("migrateGlanceCardConfig", () => {
it("migrates the legacy `format` option to `time_format`", () => {
const config = {
type: "glance",
entities: [{ entity: "sensor.last_changed", format: "relative" }],
} as unknown as GlanceCardConfig;
const result = migrateGlanceCardConfig(config);
expect(result.entities).toEqual([
{ entity: "sensor.last_changed", time_format: "relative" },
]);
expect(result.entities[0]).not.toHaveProperty("format");
});
it("returns the same config reference when there is nothing to migrate", () => {
const config = {
type: "glance",
entities: ["sensor.temperature", { entity: "light.kitchen" }],
} as GlanceCardConfig;
expect(migrateGlanceCardConfig(config)).toBe(config);
});
it("keeps a pre-existing `time_format` over the legacy `format`", () => {
const config = {
type: "glance",
entities: [
{ entity: "sensor.x", format: "relative", time_format: "datetime" },
],
} as unknown as GlanceCardConfig;
const result = migrateGlanceCardConfig(config);
expect(result.entities[0]).toEqual({
entity: "sensor.x",
time_format: "datetime",
});
});
});
+315 -103
View File
@@ -3434,10 +3434,10 @@ __metadata:
languageName: node
linkType: hard
"@lokalise/node-api@npm:16.0.0":
version: 16.0.0
resolution: "@lokalise/node-api@npm:16.0.0"
checksum: 10/88075629f30feb19537f8c40dabf7a85cc0539445322c216686f7e5a68ba6735a083d7ce1eee806ce31606d28ee6250ca39b185a783ce5ec263e1e0e71c3a701
"@lokalise/node-api@npm:16.1.0":
version: 16.1.0
resolution: "@lokalise/node-api@npm:16.1.0"
checksum: 10/f77f9e7d25af18be950f15750863061d00382f0abd2cfab1461b4354ba3d7ecc514e30811bc656e9cf382632d205c099d5d3d974b487dba75ff00dc9c1e193e0
languageName: node
linkType: hard
@@ -5741,105 +5741,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.63.0"
"@typescript-eslint/eslint-plugin@npm:8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.64.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.63.0"
"@typescript-eslint/type-utils": "npm:8.63.0"
"@typescript-eslint/utils": "npm:8.63.0"
"@typescript-eslint/visitor-keys": "npm:8.63.0"
"@typescript-eslint/scope-manager": "npm:8.64.0"
"@typescript-eslint/type-utils": "npm:8.64.0"
"@typescript-eslint/utils": "npm:8.64.0"
"@typescript-eslint/visitor-keys": "npm:8.64.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.63.0
"@typescript-eslint/parser": ^8.64.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/ab60da4c4a66e8b882b6d585457c6cd352492917940d43b255021f71fc057d327fe8e46856316b312a5c5b78b1245acb129869a7c60a7c2fa65e7822ef58c7c6
checksum: 10/ec7cbcb44968386a4b9aff9272ce6b75bdcc7f398db5f2d07a21854baf0364ca33c74268c0e19d21386c6b87b9358b141df7bef3d26e4e4e7a9eb5f8f394dc75
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/parser@npm:8.63.0"
"@typescript-eslint/parser@npm:8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/parser@npm:8.64.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.63.0"
"@typescript-eslint/types": "npm:8.63.0"
"@typescript-eslint/typescript-estree": "npm:8.63.0"
"@typescript-eslint/visitor-keys": "npm:8.63.0"
"@typescript-eslint/scope-manager": "npm:8.64.0"
"@typescript-eslint/types": "npm:8.64.0"
"@typescript-eslint/typescript-estree": "npm:8.64.0"
"@typescript-eslint/visitor-keys": "npm:8.64.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/40ee2a1c703898b9c6df0731e5584ce0bf63d8c13fb464a952500dae156b68b41b522b2cb4755a2f0cb7e0d529b0888e3ba783e3eeb1236c062a3a7ca227f634
checksum: 10/7239b16a6ca6bb1764ad04bc1eb46997336541d049fcffb4966ef404fbb02324b2b33aed50c2b976359ec8d3c97b90668cfd6aa9177228b4b799152f01a3904a
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/project-service@npm:8.63.0"
"@typescript-eslint/project-service@npm:8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/project-service@npm:8.64.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.63.0"
"@typescript-eslint/types": "npm:^8.63.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.64.0"
"@typescript-eslint/types": "npm:^8.64.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/16b07d0e95abfbe56eccc7c67185caf43e9059740c82a09a0f0f4393539f89a19f8c1b996d396f9476401af55bd804897fd19c988712db3e0f36562d2805c223
checksum: 10/511b9a8f1dcb32c99003cab9791309056ac6e889fe46227600deb743e869a63b3e78d7d2d3ef1bf65b2d5e574b73add3040fbef4c0f94aed587368aaea149559
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/scope-manager@npm:8.63.0"
"@typescript-eslint/scope-manager@npm:8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/scope-manager@npm:8.64.0"
dependencies:
"@typescript-eslint/types": "npm:8.63.0"
"@typescript-eslint/visitor-keys": "npm:8.63.0"
checksum: 10/6b0183e85d79e287660274d39fed1fc8321ae03ae40545acaad6718fd2524063d302f3348c04576d038a25f16f8c9ca4e8780a0ad754f1a494a00027e867deda
"@typescript-eslint/types": "npm:8.64.0"
"@typescript-eslint/visitor-keys": "npm:8.64.0"
checksum: 10/3c72c4915cee19d632ddc7491c3a4668dd04aa8bcb112fde8ac10aea2cc0364aaa8439d9939d0c62a7b4159fc22f4ced7cba3a77df4422b21d76497068f08076
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.63.0, @typescript-eslint/tsconfig-utils@npm:^8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.63.0"
"@typescript-eslint/tsconfig-utils@npm:8.64.0, @typescript-eslint/tsconfig-utils@npm:^8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.64.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/7c23671159f8593b676d53bc52b4a3bd4c3ad62abe2ad99b379b70298abf3b784cdcf18ca2b10f2aa62a9f8e0232e6efb87364940a3bd4a32126a357f39ac3c8
checksum: 10/905469c5067a9a2d18d065848c6497081ab787b22a9d7c06499f4bb593b7d67f9fb17e7861a114c46626555853cc52d4542db5311c8dc7cd138e45461a73e589
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/type-utils@npm:8.63.0"
"@typescript-eslint/type-utils@npm:8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/type-utils@npm:8.64.0"
dependencies:
"@typescript-eslint/types": "npm:8.63.0"
"@typescript-eslint/typescript-estree": "npm:8.63.0"
"@typescript-eslint/utils": "npm:8.63.0"
"@typescript-eslint/types": "npm:8.64.0"
"@typescript-eslint/typescript-estree": "npm:8.64.0"
"@typescript-eslint/utils": "npm:8.64.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/8a1001edb129aec55c4ef8567f5f33af4b0ee92e89463f45e7d8a006872e438db1a3cbe2364769d205124d9d143018c920e165868788a0e7aee15466ed6d38f0
checksum: 10/7ab1fd8c0d292c0155cf137d316b1618681eca0fe4cf446a05d909de41311ec6bb64fed82513e822063a962508d5b3e615fb0155a4a565d6b9c6cb81d06a5cd2
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.63.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/types@npm:8.63.0"
checksum: 10/f72eb114970cae0da8e53f68cd8dca1b51ac410f4438141a2851655fa07aacb3090e24a2ae53462cf0970e4d5b52cabf9693aa6f07abdff5c210874806427e5b
"@typescript-eslint/types@npm:8.64.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/types@npm:8.64.0"
checksum: 10/b8951c00ce9b9702f3201f017354774ea5f39c30c2b6f815cb50d91f53f61c325e30f548329daf731d5169d752095cf102da54b754fb202bcfb619faa56fa9f4
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/typescript-estree@npm:8.63.0"
"@typescript-eslint/typescript-estree@npm:8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/typescript-estree@npm:8.64.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.63.0"
"@typescript-eslint/tsconfig-utils": "npm:8.63.0"
"@typescript-eslint/types": "npm:8.63.0"
"@typescript-eslint/visitor-keys": "npm:8.63.0"
"@typescript-eslint/project-service": "npm:8.64.0"
"@typescript-eslint/tsconfig-utils": "npm:8.64.0"
"@typescript-eslint/types": "npm:8.64.0"
"@typescript-eslint/visitor-keys": "npm:8.64.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -5847,32 +5847,243 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/793e0227698f4d8b325922816f27a4e737760aae255874c2021c5e2179499fd036b9e87bf0346ead6360444fb7ab4815bb558c94a17d8481632351bbdb2780bf
checksum: 10/833101d25e820d1d0e317dfc9beca985b3c87e5458b20ed002c86c2d425667aa506f756f1759b54f724033effb529266f836f912c460ee662f347fb43dded6ed
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/utils@npm:8.63.0"
"@typescript-eslint/utils@npm:8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/utils@npm:8.64.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.63.0"
"@typescript-eslint/types": "npm:8.63.0"
"@typescript-eslint/typescript-estree": "npm:8.63.0"
"@typescript-eslint/scope-manager": "npm:8.64.0"
"@typescript-eslint/types": "npm:8.64.0"
"@typescript-eslint/typescript-estree": "npm:8.64.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/017bd1998822902b5dde5af9e031908aeb8861565b947aa217d5641bac4758fb831c52629b600bb58a990fcfef1ca58eaf5b44595575bb71a43761c2f40a7f56
checksum: 10/ff7e5374bd6ef60d7df723e6440468e3a5d4879d1d9876e322904319a1f1d2f98d858fc9b9169dbbd7ee0786a07102a058f13e2b6c16d1bf9aae9eb836a258a3
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.63.0":
version: 8.63.0
resolution: "@typescript-eslint/visitor-keys@npm:8.63.0"
"@typescript-eslint/visitor-keys@npm:8.64.0":
version: 8.64.0
resolution: "@typescript-eslint/visitor-keys@npm:8.64.0"
dependencies:
"@typescript-eslint/types": "npm:8.63.0"
"@typescript-eslint/types": "npm:8.64.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/9edcff478ba98f81f843b0c02874f3ef58c15b1001b2cf74d878416340c460a7c6e37442b4bee3ef226f0029131d620ee05a9ee3d19ec694ffa07fcf9a7a03ca
checksum: 10/d149be4ac0e67c51097cdb7335d8e2d29b9dc0de173961c5cc550e938a00a3af28b1f8ecd7ad832fcfe489d1b11e36fdd394b6ea92115a7558123562e31a704f
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
@@ -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
@@ -9259,10 +9470,10 @@ __metadata:
languageName: node
linkType: hard
"fuse.js@npm:7.4.2":
version: 7.4.2
resolution: "fuse.js@npm:7.4.2"
checksum: 10/1605bb929331056f9215a8f0a19b2d22615d9195e17794f41254bdeeb77b0145ed0ad6b6842227b329783a4ab53586d795924e7a4a3fb664295f365ef6e2405c
"fuse.js@npm:7.5.0":
version: 7.5.0
resolution: "fuse.js@npm:7.5.0"
checksum: 10/44dafab623a1144971ccbb380d6874ef6c51a73c85474016b0d47d6ad732d819cdb070f056149cd968b5304e7c0975f1890aaf98f3c4def2e9c21aebe4d62e23
languageName: node
linkType: hard
@@ -9760,7 +9971,7 @@ __metadata:
"@lit/context": "npm:1.1.6"
"@lit/reactive-element": "npm:2.1.2"
"@lit/task": "npm:1.0.3"
"@lokalise/node-api": "npm:16.0.0"
"@lokalise/node-api": "npm:16.1.0"
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch"
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
"@material/web": "npm:2.4.1"
@@ -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"
@@ -9824,7 +10036,7 @@ __metadata:
eslint-plugin-wc: "npm:3.1.0"
fancy-log: "npm:2.0.0"
fs-extra: "npm:11.3.6"
fuse.js: "npm:7.4.2"
fuse.js: "npm:7.5.0"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
globals: "npm:17.7.0"
@@ -9872,12 +10084,12 @@ __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"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.63.0"
typescript-eslint: "npm:8.64.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.10"
webpack-stats-plugin: "npm:1.1.3"
@@ -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
@@ -14876,18 +15088,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.63.0":
version: 8.63.0
resolution: "typescript-eslint@npm:8.63.0"
"typescript-eslint@npm:8.64.0":
version: 8.64.0
resolution: "typescript-eslint@npm:8.64.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.63.0"
"@typescript-eslint/parser": "npm:8.63.0"
"@typescript-eslint/typescript-estree": "npm:8.63.0"
"@typescript-eslint/utils": "npm:8.63.0"
"@typescript-eslint/eslint-plugin": "npm:8.64.0"
"@typescript-eslint/parser": "npm:8.64.0"
"@typescript-eslint/typescript-estree": "npm:8.64.0"
"@typescript-eslint/utils": "npm:8.64.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/ab0f3324cbf6ab910903977b9364138c67ccad35675962702bfbe052231440439dcdc50de0c01a1eb1ccecde796a4e117858b10166d9d1153b3b2d82c73a92ba
checksum: 10/7812e25506003c2a5b395a51ca3c31929028485d191049e6a813e65829e79fcebb07251ba42c5d75af8d15233a5bbb44dd37138345787779eabe07b88fcf75ec
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