Compare commits

...

30 Commits

Author SHA1 Message Date
Bram Kragten 36e1572070 Fall back to normal "not found" when no replacement is available
When a referenced device is a removed composite device but none of its
split devices are available (all deleted, or none match the picker's
filters), don't surface any replaced-specific UI — just behave like a
normal unknown/not-found device.

- Device picker: only show the warning name and the replace alert when
  there is an available replacement; otherwise render the raw id and the
  standard "unknown device" text, with no alert.
- Compact target chip: only apply the replaced (warning) treatment when a
  replacement device still exists.

Removes the now-unused device-picker.device_replaced and
device_replaced_no_match strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:34:12 +02:00
Bram Kragten c62292212d Handle deleted replacement devices gracefully
Replacement candidates were already filtered against the registry, so a
deleted split device is never offered or migrated to. Improve the display
for the remaining edge cases:

- Only show the "replaced" state (count + Replace/migrate) in the target
  picker when at least one replacement device is still available;
  otherwise fall back to the plain "not found" state instead of showing
  "Replaced by 0 devices".
- Fall back to the first still-existing split device's name when the
  primary replacement device itself was deleted, instead of reverting to
  the removed composite device id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:27:35 +02:00
Bram Kragten ed591a09f4 Surface replaced composite devices in device and target pickers
Devices that used to belong to multiple config entries are split into
one device per config entry by core. The original composite device is
removed from the registry, so existing references to it (device
selectors, targets in automations/scripts/scenes) point at a device that
no longer exists.

Using the new `config/device_registry/list_composite_splits` websocket
command, the device and target pickers now detect these references and
show a "replaced" state instead of a plain "not found", offering to
point the reference at the replacement device(s). Replacement candidates
are filtered through the picker's own filters, so in practice usually a
single device matches:

- Device selector: one matching replacement resolves in one click, and
  multiple matches open a chooser dialog.
- Target picker: the replaced row/chip offers to migrate to all matching
  replacement devices and removes the old reference.

The split map is fetched once per connection and cached (the core
migration is a one-time operation, so it is static at runtime).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:18:59 +02:00
Bram Kragten bbd26ce5a9 Improve messaging and error handling http config (#53126)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-07-17 14:38:25 +02:00
pcan08 d3c23dd704 Limit clock card date picker to one value per group (#53160) 2026-07-17 08:53:28 +01:00
Paulus Schoutsen 7d4e5dc03f Use /store/reload instead of legacy /addons/reload alias when checking for add-on updates (#53162)
Use /store/reload instead of legacy /addons/reload alias

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


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

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

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

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

Fixes #52935

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

* Extract entities/glance format migration into a testable module

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

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

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

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

---------

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

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

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

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

* Update create session test for WebSocket-only path

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

* Fix ts-ignore placement in create session test

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:23:32 +02:00
Aidan Timson 4327871c30 Add clear/reset action to Activity panel (#53137) 2026-07-15 11:56:24 +01:00
Bruno Pantaleão Gonçalves 88381e0b11 Revert "Add App settings entry to dashboard more menu" (#53135) 2026-07-15 08:11:33 +00:00
Bruno Pantaleão Gonçalves f44590a2aa Add App settings entry to dashboard more menu (#53130) 2026-07-15 08:39:52 +01:00
Bram Kragten 1c03daac41 Add safe area handling and opt-out to custom panels and apps (#53127) 2026-07-15 08:35:54 +01:00
Yosi Levy a2216c6bf5 Minor RTL fix (#53132) 2026-07-15 08:33:08 +01:00
renovate[bot] 4a755e00ac Update dependency tar to v7.5.20 (#53133) 2026-07-15 08:28:09 +01:00
Aidan Timson 36a9da37f8 Update FormatJS dependencies (#53121) 2026-07-14 12:31:09 +02:00
renovate[bot] cee5361525 Update dependency eslint to v10.7.0 (#53116)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 07:12:03 +02:00
83 changed files with 4521 additions and 1307 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 () => {};
@@ -0,0 +1,23 @@
---
title: Replaced device selectors
subtitle: How device and target selectors surface devices that were split into separate devices
---
A device that used to belong to multiple config entries is split into one
device per config entry. The original composite device is removed from the
registry, so existing references to it (targets in automations, device
selectors) point at a device that no longer exists.
When a selector holds such a reference, it shows a **replaced** state instead of
a plain "not found", and offers to point the reference at the replacement
device(s). The candidate replacements are filtered through the selector's own
filters, so in practice usually a single device matches:
- **Target selector** — the replaced device row offers **Replace**, which adds
every replacement device that matches the target filters and removes the old
reference.
- **Device selector** — when exactly one replacement matches, **Replace** swaps
to it in one click; when several match, a dialog lets you pick one.
All samples below reference the removed composite device `old_composite`, which
was split into a light device and a switch device.
@@ -0,0 +1,314 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
import type { Selector } from "../../../../src/data/selector";
import {
showDialog,
type ShowDialogParams,
} from "../../../../src/dialogs/make-dialog-manager";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
// The composite device "old_composite" is intentionally NOT in the registry:
// it was split into "device_light" and "device_switch". References to the old
// id (targets, device selectors) should surface a "replaced" state.
const DEVICES: DeviceRegistryEntry[] = [
{
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_light"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_light",
identifiers: [["demo", "light"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Living room lamp",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
{
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_switch"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_switch",
identifiers: [["demo", "switch"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Garden socket",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
];
const ENTITIES = [
{
entity_id: "light.living_room_lamp",
state: "on",
attributes: { friendly_name: "Living room lamp" },
},
{
entity_id: "switch.garden_socket",
state: "off",
attributes: { friendly_name: "Garden socket" },
},
];
// Registry display entries link the demo entities to the split devices so the
// pickers can filter split candidates by domain.
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
"light.living_room_lamp": {
entity_id: "light.living_room_lamp",
name: "Living room lamp",
device_id: "device_light",
area_id: "bedroom",
platform: "demo",
labels: [],
},
"switch.garden_socket": {
entity_id: "switch.garden_socket",
name: "Garden socket",
device_id: "device_switch",
area_id: "backyard",
platform: "demo",
labels: [],
},
};
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
// Maps the removed composite device to the devices that replaced it.
const COMPOSITE_SPLITS = {
old_composite: {
split_ids: ["device_light", "device_switch"],
primary_id: "device_light",
},
};
interface Sample {
name: string;
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
{
name: "Target",
description:
"Migrate adds every replacement device that matches the target filters (here both).",
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
"Both replacement devices qualify, so Replace opens a dialog to pick one.",
selector: { device: {} },
value: "old_composite",
},
{
name: "Device (filtered to lights, single match)",
description:
"Only the light device passes the filter, so Replace swaps to it in one click.",
selector: { device: { entity: [{ domain: "light" }] } },
value: "old_composite",
},
{
name: "Device (multiple)",
description: "Each slot resolves independently to a matching replacement.",
selector: { device: { multiple: true } },
value: ["old_composite"],
},
];
@customElement("demo-components-ha-selector-replaced-device")
class DemoHaSelectorReplacedDevice
extends LitElement
implements ProvideHassElement
{
@state() public hass!: HomeAssistant;
private _values = SAMPLES.map((sample) => sample.value);
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.addEntities(ENTITIES);
mockEntityRegistry(hass);
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockAreaRegistry(hass, AREAS);
mockHassioSupervisor(hass);
// Link the demo entities to the split devices.
hass.updateHass({ entities: ENTITY_REGISTRY });
hass.mockWS(
"config/device_registry/list_composite_splits",
() => COMPOSITE_SPLITS
);
hass.mockWS("extract_from_target", () => ({
referenced_entities: [],
referenced_devices: [],
referenced_areas: [],
}));
hass.mockWS("auth/sign_path", (params) => params);
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._dialogManager);
}
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
protected render(): TemplateResult {
return html`
${SAMPLES.map(
(sample, idx) => html`
<demo-black-white-row .title=${sample.name}>
${["light", "dark"].map(
(slot) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
</ha-settings-row>
`
)}
</demo-black-white-row>
`
)}
`;
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this._values[idx] = ev.detail.value;
this.requestUpdate();
}
static styles = css`
ha-settings-row {
--settings-row-content-width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-selector-replaced-device": DemoHaSelectorReplacedDevice;
}
}
+18 -17
View File
@@ -12,7 +12,7 @@
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "tsc",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
@@ -52,15 +52,15 @@
"@codemirror/view": "6.43.6",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.10",
"@formatjs/intl-displaynames": "7.3.11",
"@formatjs/intl-durationformat": "0.10.16",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.11",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.12",
"@formatjs/intl-pluralrules": "6.3.11",
"@formatjs/intl-relativetimeformat": "12.3.11",
"@formatjs/intl-datetimeformat": "7.5.0",
"@formatjs/intl-displaynames": "7.3.12",
"@formatjs/intl-durationformat": "0.10.17",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.12",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.3.13",
"@formatjs/intl-pluralrules": "6.3.12",
"@formatjs/intl-relativetimeformat": "12.3.12",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
@@ -89,7 +89,7 @@
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.0",
"barcode-detector": "3.2.1",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -102,12 +102,12 @@
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.2",
"fuse.js": "7.5.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.10",
"intl-messageformat": "11.2.11",
"js-yaml": "5.2.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -146,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,12 +168,13 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:typescript@7.0.2",
"@vitest/coverage-v8": "4.1.10",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.6.0",
"eslint": "10.7.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -206,11 +207,11 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.19",
"tar": "7.5.20",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.63.0",
"typescript-eslint": "8.64.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
+3 -2
View File
@@ -51,8 +51,9 @@ class StorageClass {
storageKey: string,
callback: Callback
): UnsubscribeFunc {
if (this._listeners[storageKey]) {
this._listeners[storageKey].push(callback);
const listeners = this._listeners[storageKey];
if (listeners) {
listeners.push(callback);
} else {
this._listeners[storageKey] = [callback];
}
+52 -3
View File
@@ -1,4 +1,53 @@
const regexp =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// Unanchored regex fragments, shared as the single source of truth for both
// the boolean validators below and the HTML `pattern` attribute (the browser
// anchors a pattern as `^(?:…)$`).
const IPV4 =
"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
export const isIPAddress = (input: string): boolean => regexp.test(input);
const IPV6 =
"(?:([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
// CIDR prefix lengths: 0-32 for IPv4, 0-128 for IPv6.
const IPV4_PREFIX = "(?:3[0-2]|[12]?[0-9])";
const IPV6_PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])";
// IPv4 or IPv6 address.
export const IP_ADDRESS_PATTERN = `${IPV4}|${IPV6}`;
// IPv4/IPv6 address, optionally with a CIDR prefix (network).
export const IP_ADDRESS_OR_NETWORK_PATTERN = `${IPV4}(?:/${IPV4_PREFIX})?|${IPV6}(?:/${IPV6_PREFIX})?`;
const anchored = (pattern: string): RegExp => new RegExp(`^(?:${pattern})$`);
const ipv4Regexp = anchored(IPV4);
const ipv6Regexp = anchored(IPV6);
// IPv4 address, e.g. 192.168.1.10
export const isIPAddress = (input: string): boolean => ipv4Regexp.test(input);
// IPv6 address, e.g. fe80::85d:e82c:9446:7995
export const isIPv6Address = (input: string): boolean => ipv6Regexp.test(input);
// IPv4 or IPv6 address
export const isIPAddressV4OrV6 = (input: string): boolean =>
isIPAddress(input) || isIPv6Address(input);
// IP network in CIDR notation, e.g. 192.168.1.0/24 or fd00::/8
export const isIPNetwork = (input: string): boolean => {
const parts = input.split("/");
if (parts.length !== 2) {
return false;
}
const [address, prefix] = parts;
if (!/^\d{1,3}$/.test(prefix)) {
return false;
}
const prefixLength = Number(prefix);
if (isIPAddress(address)) {
return prefixLength >= 0 && prefixLength <= 32;
}
if (isIPv6Address(address)) {
return prefixLength >= 0 && prefixLength <= 128;
}
return false;
};
+4 -5
View File
@@ -33,13 +33,12 @@ const extractVarFromBase = (
varName: string,
baseVars: Record<string, string>
): string | undefined => {
if (baseVars[varName] && baseVars[varName].startsWith("var(")) {
const baseVarName = baseVars[varName]
.substring(6, baseVars[varName].length - 1)
.trim();
const value = baseVars[varName];
if (value && value.startsWith("var(")) {
const baseVarName = value.substring(6, value.length - 1).trim();
return extractVarFromBase(baseVarName, baseVars);
}
return baseVars[varName];
return value;
};
/**
@@ -0,0 +1,124 @@
import { mdiDevices } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import "../ha-dialog";
import "../ha-svg-icon";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import type { DeviceReplacedDialogParams } from "./show-dialog-device-replaced";
@customElement("dialog-device-replaced")
export class DialogDeviceReplaced
extends LitElement
implements HassDialog<DeviceReplacedDialogParams>
{
@state() private _params?: DeviceReplacedDialogParams;
@state() private _open = false;
@property({ attribute: false }) public hass!: HomeAssistant;
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
this._params = params;
this._open = true;
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _pick(ev: Event): void {
const item = (ev.target as HTMLElement).closest("ha-list-item-button") as
(HTMLElement & { deviceId?: string }) | null;
if (!item?.deviceId) {
return;
}
this._params?.onResolved(item.deviceId);
this.closeDialog();
}
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
.headerTitle=${this.hass.localize(
"ui.components.device-picker.replaced_dialog.title"
)}
@closed=${this._dialogClosed}
>
<p class="description">
${this.hass.localize(
"ui.components.device-picker.replaced_dialog.description"
)}
</p>
<ha-list-base @click=${this._pick}>
${this._params.candidates.map((deviceId) => {
const device = this.hass.devices[deviceId];
const name = device ? computeDeviceName(device) : deviceId;
const area = device
? getDeviceArea(device, this.hass.areas)
: undefined;
const secondary = area ? computeAreaName(area) : undefined;
const isPrimary = deviceId === this._params!.primaryId;
return html`
<ha-list-item-button .deviceId=${deviceId}>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<span slot="headline">${name}</span>
${
secondary || isPrimary
? html`<span slot="supporting-text">
${[
secondary,
isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ")}
</span>`
: nothing
}
</ha-list-item-button>
`;
})}
</ha-list-base>
</ha-dialog>
`;
}
static styles = css`
ha-dialog {
--dialog-content-padding: 0;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.description {
margin: 0;
padding: 0 var(--ha-space-6) var(--ha-space-4);
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-device-replaced": DialogDeviceReplaced;
}
}
+236 -46
View File
@@ -1,6 +1,7 @@
import { mdiAlertOutline } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -13,12 +14,20 @@ import {
getDevices,
type DevicePickerItem,
} from "../../data/device/device_picker";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-svg-icon";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -95,6 +104,10 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
@@ -123,6 +136,29 @@ export class HaDevicePicker extends LitElement {
this._loadConfigEntries();
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
!this.hass ||
!this.value ||
this._compositeSplits !== undefined ||
this._loadingCompositeSplits
) {
return;
}
const oldHass = changedProperties.get("hass") as HomeAssistant | undefined;
const devicesChanged =
changedProperties.has("hass") && this.hass.devices !== oldHass?.devices;
if (
(changedProperties.has("value") || devicesChanged) &&
!this.hass.devices[this.value]
) {
// The selected device is not in the registry; it might be a legacy
// composite device that was split into separate devices. Fetch the
// split map so we can offer to replace the reference.
this._loadCompositeSplits();
}
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -130,6 +166,43 @@ export class HaDevicePicker extends LitElement {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _getReplacement = memoizeOne(
(
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[]
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
}
const split = compositeSplits[value];
if (!split) {
return undefined;
}
// Keep only the split devices that pass this picker's filters. In
// practice usually exactly one of the split devices matches.
const selectableIds = new Set(
items
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
return { candidates, primaryId: split.primary_id };
}
);
private _getItems = () =>
this._getDevicesMemoized(
this.hass.devices,
@@ -144,49 +217,66 @@ export class HaDevicePicker extends LitElement {
);
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
if (!device) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
return html`
<ha-svg-icon
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
@@ -235,7 +325,31 @@ export class HaDevicePicker extends LitElement {
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
const valueRenderer = this._valueRenderer(this._configEntryLookup);
const replacement = this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
);
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
replacementName
);
return html`
<ha-generic-picker
@@ -256,15 +370,84 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
${canReplace ? this._renderReplacedAlert(replacement!) : nothing}
`;
}
private _renderReplacedAlert(replacement: {
candidates: string[];
primaryId: string | null;
}) {
const { candidates } = replacement;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
@click=${this._handleReplace}
>
${this.hass.localize("ui.components.device-picker.replace_device")}
</ha-button>
</ha-alert>
`;
}
private _handleReplace = () => {
const replacement = this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
);
if (!replacement?.candidates.length) {
return;
}
const { candidates, primaryId } = replacement;
if (candidates.length === 1) {
this._setValue(candidates[0]);
return;
}
showDeviceReplacedDialog(this, {
originalDeviceId: this.value!,
candidates,
primaryId,
onResolved: (deviceId) => this._setValue(deviceId),
});
};
private _setValue(value: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete;
await this._picker?.open();
@@ -281,6 +464,13 @@ export class HaDevicePicker extends LitElement {
this.hass.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
static styles = css`
ha-alert {
display: block;
margin-top: 8px;
}
`;
}
declare global {
@@ -0,0 +1,22 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface DeviceReplacedDialogParams {
/** The removed composite device that is being referenced. */
originalDeviceId: string;
/** The split devices the reference can be pointed at. */
candidates: string[];
/** The split device that took over the composite's primary config entry. */
primaryId: string | null;
/** Called with the device the user picked as replacement. */
onResolved: (deviceId: string) => void;
}
export const showDeviceReplacedDialog = (
element: HTMLElement,
params: DeviceReplacedDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "dialog-device-replaced",
dialogImport: () => import("./dialog-device-replaced"),
dialogParams: params,
});
@@ -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;
}
}
@@ -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);
});
}
+55
View File
@@ -27,6 +27,10 @@ import {
getDevices,
type DevicePickerItem,
} from "../data/device/device_picker";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import {
entityComboBoxKeys,
@@ -122,6 +126,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@@ -211,6 +219,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._loadConfigEntries();
}
const devicesChanged =
changedProps.has("hass") &&
this.hass.devices !== changedProps.get("hass")?.devices;
if (
(changedProps.has("value") || devicesChanged) &&
this.hass &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this.value?.device_id &&
ensureArray(this.value.device_id).some(
(deviceId) => !this.hass.devices[deviceId]
)
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map to offer a fix.
this._loadCompositeSplits();
}
if (
this._pendingEntityId &&
changedProps.has("hass") &&
@@ -297,6 +323,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
@@ -390,6 +417,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
@replace-target-item=${this._handleReplace}
@migrate-target-item=${this._handleMigrate}
type="device"
.hass=${this.hass}
.items=${{ device: deviceIds }}
@@ -398,6 +426,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this._compositeSplits}
>
</ha-target-picker-item-group>
`
@@ -1175,6 +1204,31 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _handleMigrate(
ev: HASSDomEvent<HASSDomEvents["migrate-target-item"]>
) {
const { id, replacements } = ev.detail;
let value = this._removeItem(this.value, "device", id);
for (const replacement of replacements) {
value = this._addTargetToValue(value, {
type: "device",
id: replacement,
});
}
fireEvent(this, "value-changed", { value });
}
private _renderRow = (
item:
| PickerComboBoxItem
@@ -1357,6 +1411,7 @@ declare global {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
}
}
+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"
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
@@ -45,6 +46,9 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
protected render() {
let count = 0;
Object.values(this.items).forEach((items) => {
@@ -80,6 +84,7 @@ export class HaTargetPickerItemGroup extends LitElement {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this.compositeSplits}
></ha-target-picker-item-row>`
)
: nothing
@@ -34,7 +34,10 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type {
DeviceCompositeSplits,
DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
@@ -108,6 +111,9 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _iconImg?: string;
@state() private _domainName?: string;
@@ -128,6 +134,15 @@ export class HaTargetPickerItemRow extends LitElement {
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
this._itemData(this.type, this.itemId);
const replacement =
this.type === "device" && notFound
? this._getReplacement(this.itemId)
: undefined;
// Only surface the "replaced" state when there is at least one available
// replacement device to migrate to. If every replacement device was
// deleted (or filtered out), fall back to the plain "not found" state.
const canMigrate = !!replacement?.candidates.length;
const showEntities = this.type !== "entity" && !notFound;
const entries = this.parentEntries || this._entries;
@@ -174,15 +189,20 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${name}</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
? canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context
}</span
>`
@@ -229,6 +249,21 @@ export class HaTargetPickerItemRow extends LitElement {
`
: nothing
}
${
canMigrate
? html`
<button
class="main link migrate"
slot="end"
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_device"
)}
</button>
`
: nothing
}
${
!this.expand && !this.subEntry
? html`
@@ -707,6 +742,51 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
// Returns the split devices that replaced a removed composite device and
// pass this row's filters, or undefined if the item is not a replaced device.
private _getReplacement(item: string) {
const split = this.compositeSplits?.[item];
if (!split || this.hass.devices[item]) {
return undefined;
}
const candidates = split.split_ids.filter((id) => {
const device = this.hass.devices[id];
return (
device &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
);
});
// Display the replaced reference using the primary replacement device's
// name instead of the removed composite device id. Fall back to the first
// available candidate if the primary device itself was deleted.
const nameDevice =
(split.primary_id && this.hass.devices[split.primary_id]) ||
(candidates.length ? this.hass.devices[candidates[0]] : undefined);
const name = nameDevice ? computeDeviceName(nameDevice) : undefined;
return { candidates, name };
}
private _migrate = (ev: MouseEvent) => {
ev.stopPropagation();
const replacement = this._getReplacement(this.itemId);
if (!replacement?.candidates.length) {
return;
}
fireEvent(this, "migrate-target-item", {
id: this.itemId,
replacements: replacement.candidates,
});
};
private _openDetails(ev: MouseEvent) {
ev.stopPropagation();
showTargetDetailsDialog(this, {
@@ -746,6 +826,13 @@ export class HaTargetPickerItemRow extends LitElement {
color: var(--ha-color-on-warning-normal);
}
.migrate {
align-self: center;
white-space: nowrap;
font-weight: var(--ha-font-weight-medium);
color: var(--ha-color-on-warning-normal);
}
.replaceable {
cursor: pointer;
}
@@ -1,6 +1,7 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
@@ -9,6 +10,7 @@ import {
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
@@ -19,6 +21,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
@@ -39,6 +42,9 @@ export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@@ -51,38 +57,74 @@ export class HaTargetPickerValueChip extends LitElement {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${this.type}
class=${classMap({ [this.type]: true, replaced })}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name"> ${name} </span>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
${
this.type === "entity"
this.type === "entity" || replaced
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
@@ -125,7 +167,7 @@ export class HaTargetPickerValueChip extends LitElement {
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device.primary_config_entry) {
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
@@ -240,6 +282,11 @@ export class HaTargetPickerValueChip extends LitElement {
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
+47
View File
@@ -1,3 +1,4 @@
import type { Connection } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { HomeAssistant } from "../../types";
@@ -54,6 +55,52 @@ export interface DeviceRegistryEntryMutableParams {
labels?: string[];
}
/**
* Describes how a legacy composite device (that lived on multiple config
* entries) was split into separate devices. The composite device no longer
* exists in the registry; references to it (in automations, targets, ...) now
* need to point at one or more of the split devices instead.
*/
export interface DeviceCompositeSplit {
/** Ids of the devices that replaced the composite device. */
split_ids: string[];
/** The split device that took over the composite's primary config entry. */
primary_id: string | null;
}
/** Map of removed composite device id -> its split information. */
export type DeviceCompositeSplits = Record<string, DeviceCompositeSplit>;
// The composite split migration in core is a one-time operation, so the split
// map is static for the lifetime of the connection. Cache the request per
// connection so it is fetched once and shared across all pickers, instead of
// being requested again by every device/target picker.
const compositeSplitsCache = new WeakMap<
Connection,
Promise<DeviceCompositeSplits>
>();
export const fetchDeviceCompositeSplits = (
hass: HomeAssistant
): Promise<DeviceCompositeSplits> => {
const conn = hass.connection;
let request = compositeSplitsCache.get(conn);
if (!request) {
request = hass
.callWS<DeviceCompositeSplits>({
type: "config/device_registry/list_composite_splits",
})
.catch((err) => {
// Don't cache failures so the next caller retries.
compositeSplitsCache.delete(conn);
throw err;
});
compositeSplitsCache.set(conn, request);
}
return request;
};
export const fallbackDeviceName = (
hass: HomeAssistant,
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
+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();
}
@@ -2,16 +2,15 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
assign,
boolean,
defaulted,
enums,
literal,
object,
optional,
string,
union,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
@@ -19,58 +18,47 @@ import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { ClockCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { TimeFormat } from "../../../../data/translation";
import { getTimezoneOptions } from "../../../../components/ha-timezone-picker";
import {
CLOCK_CARD_DATE_PARTS,
getClockCardDateConfig,
} from "../../cards/clock/clock-date-format";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
title: optional(string()),
clock_style: optional(union([literal("digital"), literal("analog")])),
clock_size: optional(
union([literal("small"), literal("medium"), literal("large")])
),
clock_style: optional(enums(["digital", "analog"])),
clock_size: optional(enums(["small", "medium", "large"])),
time_format: optional(enums(Object.values(TimeFormat))),
time_zone: optional(enums(getTimezoneOptions().map((option) => option.id))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
date_format: optional(defaulted(array(enums(CLOCK_CARD_DATE_PARTS)), [])),
// Analog clock options
border: optional(defaulted(boolean(), false)),
ticks: optional(
defaulted(
union([
literal("none"),
literal("quarter"),
literal("hour"),
literal("minute"),
]),
literal("hour")
)
defaulted(enums(["none", "quarter", "hour", "minute"]), "hour")
),
seconds_motion: optional(
defaulted(
union([literal("continuous"), literal("tick")]),
literal("continuous")
)
defaulted(enums(["continuous", "tick"]), "continuous")
),
face_style: optional(
defaulted(
union([
literal("markers"),
literal("numbers_upright"),
literal("roman"),
]),
literal("markers")
)
defaulted(enums(["markers", "numbers_upright", "roman"]), "markers")
),
})
);
type ClockCardFormData = Omit<ClockCardConfig, "time_format"> & {
time_format?: ClockCardConfig["time_format"] | "auto";
};
@customElement("hui-clock-card-editor")
export class HuiClockCardEditor
extends LitElement
@@ -93,7 +81,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "dropdown",
mode: "box",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -119,6 +107,13 @@ export class HuiClockCardEditor
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
{
name: "date_format",
required: false,
selector: {
ui_clock_date_format: {},
},
},
...(clockStyle === "digital"
? ([
{
@@ -241,18 +236,28 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config) => ({
clock_style: "digital",
clock_size: "small",
time_format: "auto",
show_seconds: false,
no_background: false,
// Analog clock options
border: false,
ticks: "hour",
face_style: "markers",
...config,
}));
private _data = memoizeOne((config: ClockCardConfig): ClockCardFormData => {
const dateConfig = getClockCardDateConfig(config);
const data: ClockCardFormData = {
...config,
clock_style: config.clock_style ?? "digital",
clock_size: config.clock_size ?? "small",
time_format: config.time_format ?? "auto",
show_seconds: config.show_seconds ?? false,
no_background: config.no_background ?? false,
// Analog clock options
border: config.border ?? false,
ticks: config.ticks ?? "hour",
face_style: config.face_style ?? "markers",
};
if (config.date_format === undefined) {
data.date_format = dateConfig.parts;
}
return data;
});
public setConfig(config: ClockCardConfig): void {
assert(config, cardConfigStruct);
@@ -281,35 +286,40 @@ export class HuiClockCardEditor
`;
}
private _valueChanged(ev: CustomEvent): void {
if (ev.detail.value.time_format === "auto") {
delete ev.detail.value.time_format;
private _valueChanged(ev: ValueChangedEvent<ClockCardFormData>): void {
const config = ev.detail.value;
if (!config.date_format || config.date_format.length === 0) {
delete config.date_format;
}
if (ev.detail.value.clock_style === "analog") {
ev.detail.value.border = ev.detail.value.border ?? false;
ev.detail.value.ticks = ev.detail.value.ticks ?? "hour";
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (ev.detail.value.show_seconds) {
ev.detail.value.seconds_motion =
ev.detail.value.seconds_motion ?? "continuous";
if (config.time_format === "auto") {
delete config.time_format;
}
if (config.clock_style === "analog") {
config.border = config.border ?? false;
config.ticks = config.ticks ?? "hour";
config.face_style = config.face_style ?? "markers";
if (config.show_seconds) {
config.seconds_motion = config.seconds_motion ?? "continuous";
} else {
delete ev.detail.value.seconds_motion;
delete config.seconds_motion;
}
} else {
delete ev.detail.value.border;
delete ev.detail.value.ticks;
delete ev.detail.value.face_style;
delete ev.detail.value.seconds_motion;
delete config.border;
delete config.ticks;
delete config.face_style;
delete config.seconds_motion;
}
if (ev.detail.value.ticks !== "none") {
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (config.ticks !== "none") {
config.face_style = config.face_style ?? "markers";
} else {
delete ev.detail.value.face_style;
delete config.face_style;
}
fireEvent(this, "config-changed", { config: ev.detail.value });
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
@@ -344,6 +354,10 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.label`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.label`
@@ -369,6 +383,10 @@ export class HuiClockCardEditor
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.description`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.description`
@@ -1,3 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -260,9 +261,10 @@ export class HuiHistoryGraphCardEditor
const domain = computeDomain(entityId);
const isNumberDomain =
domain === "counter" || domain === "number" || domain === "input_number";
const stateObj = this.hass!.states[entityId];
const stateObj = this.hass!.states[entityId] as HassEntity | undefined;
const attributes = stateObj?.attributes;
return !isNumericFromAttributes(attributes) && !isNumberDomain;
const isNumeric = attributes ? isNumericFromAttributes(attributes) : false;
return !isNumeric && !isNumberDomain;
};
// remove "color" option when needed
+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
+51 -2
View File
@@ -791,6 +791,9 @@
"device_not_found": "Device not found",
"entity_not_found": "Entity not found",
"label_not_found": "Label not found",
"replaced_device": "Replaced device",
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"replace_device": "Replace",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"target_details": "Target details",
@@ -910,7 +913,16 @@
"unnamed_device": "Unnamed device",
"no_area": "No area",
"placeholder": "Select a device",
"unknown": "Unknown device selected"
"unknown": "Unknown device selected",
"device_replaced_count": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"device_replaced_by_one": "This device was replaced by {device}.",
"device_replaced_by_multiple": "This device was replaced by {count} devices.",
"replace_device": "Replace",
"replaced_dialog": {
"title": "Replace device",
"description": "This device was replaced by multiple devices. Choose which one to use.",
"recommended": "Recommended"
}
},
"category-picker": {
"clear": "Clear",
@@ -1486,6 +1498,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 +8692,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 +8707,7 @@
"ssl": "SSL/TLS",
"reverse_proxy": "Reverse proxy",
"ip_banning": "IP banning",
"advanced": "Advanced"
"advanced": "More options"
},
"fields": {
"server_port": "Server port",
@@ -10140,6 +10157,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 +11583,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",
]);
});
});
+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",
});
});
});
+412 -200
View File
@@ -2745,135 +2745,135 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/bigdecimal@npm:0.2.6":
version: 0.2.6
resolution: "@formatjs/bigdecimal@npm:0.2.6"
checksum: 10/5ef248f0feadeb1bceb9fa65ba36ccd1768a41ea6165bc58ae794564ebb09a67621d894fc94ab8b328cc7bba60e4c181847562c92b7cec5cd1208a4191fbbe67
"@formatjs/bigdecimal@npm:0.2.7":
version: 0.2.7
resolution: "@formatjs/bigdecimal@npm:0.2.7"
checksum: 10/e78fe804ed6805708b849fbd49006e981cfa736677bac29556f84bd6ca5271f848076f2f35de087197a559d434435bbfaecca2776563a5b46ecd6246bde7406d
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:3.1.6":
version: 3.1.6
resolution: "@formatjs/fast-memoize@npm:3.1.6"
checksum: 10/7dec3e82586d4c4889671c6081d7b0d87b2c229ba551d8328f96ae8ab58b2cd6056fc5d360c0b0c9688b7164f12ef517428016fbce15b950987a77005e481824
"@formatjs/fast-memoize@npm:3.1.7":
version: 3.1.7
resolution: "@formatjs/fast-memoize@npm:3.1.7"
checksum: 10/2d7ead48684539764ecb8a52178719169186595e830d9d6941eff12bec1ae31e20642a9cfd197007c48a8cc7e965bf61445ffcccc0fd77281341271d00e7b8fb
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.13":
version: 3.5.13
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.13"
"@formatjs/icu-messageformat-parser@npm:3.5.14":
version: 3.5.14
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.14"
dependencies:
"@formatjs/icu-skeleton-parser": "npm:2.1.10"
checksum: 10/b6451febdcfbe32571af5ae1c94cc560f6b3d815401321849c9dba229c460edf9ee4eca4c2d4da6dedefea416f2e3b10b4aa5507383e12b67042f941157e8045
"@formatjs/icu-skeleton-parser": "npm:2.1.11"
checksum: 10/e842d8ec0c17c174da0eb562e161f5216b31cb83546bf94fd3dd6e4b6493f8c0c404b9fb47b6b8106a607a83d305386bc1fb44abbc70453cf467d261e39d09c1
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:2.1.10":
version: 2.1.10
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.10"
checksum: 10/ec30d106ce38de80f4128d0cdfac15699628652807695843254bf0d31650bd0dc4b57e48691d164556234494d59b2816e710fa12321234c85b803c5cda32bedf
"@formatjs/icu-skeleton-parser@npm:2.1.11":
version: 2.1.11
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.11"
checksum: 10/492f42bd4ad72b2cdb4fd75a07e3874aa78e88f346027058f74c58e3828378092784176c9f7c77f4b5baf58ffaa1a4e8f5efcb53143552def2990510d0ac3c16
languageName: node
linkType: hard
"@formatjs/intl-datetimeformat@npm:7.4.10":
version: 7.4.10
resolution: "@formatjs/intl-datetimeformat@npm:7.4.10"
"@formatjs/intl-datetimeformat@npm:7.5.0":
version: 7.5.0
resolution: "@formatjs/intl-datetimeformat@npm:7.5.0"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/67fd55857555fe2651bc8ed5544fb036807d926542c92ccc07e475c14493d93a87d2cc805d04c812c6df68c9fdaeb4d64b399ac019f8c27940e2b872c9837618
"@formatjs/bigdecimal": "npm:0.2.7"
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/8f5bbcd2768609e466e5d03cd42880e62fa5c584ef57d716023a0f89ffff12036da41a248956cc1b3369b1af0155459464ac05d5f8fdfb42c0aa066d77ede815
languageName: node
linkType: hard
"@formatjs/intl-displaynames@npm:7.3.11":
version: 7.3.11
resolution: "@formatjs/intl-displaynames@npm:7.3.11"
"@formatjs/intl-displaynames@npm:7.3.12":
version: 7.3.12
resolution: "@formatjs/intl-displaynames@npm:7.3.12"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/3278e430d7f2bbb0656f5cf0a76a1a539a572c58f0d2473e2a175bc927de7e3a4a01823c3c98a38dc2878fd05ca3a6ebe83afa94dbd254733fe9195377064347
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/d802f9d44831fcb567efd7399b2f20da7872bf6cdf3cc8c5b38b188111fc8c38af704a79f55902caadd50edb8ebf2f830c473aecc5a93573ddbe1bcfba275793
languageName: node
linkType: hard
"@formatjs/intl-durationformat@npm:0.10.16":
version: 0.10.16
resolution: "@formatjs/intl-durationformat@npm:0.10.16"
"@formatjs/intl-durationformat@npm:0.10.17":
version: 0.10.17
resolution: "@formatjs/intl-durationformat@npm:0.10.17"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/8d0f5bbf24528c6d8e65636502de64b13e12c71854935db8bf2ce6bcc5b6e0e19533d0e0178c1079b1dd52214b025369c98e297b0e8484fa8ce29c9f6408c953
"@formatjs/bigdecimal": "npm:0.2.7"
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/ee3210da7b946a80995b36609a360a55d63f4626e3f40223215d782082f1f8254c49af6145f397ea6324b370038095145c6ff51fda8712e7b611f8609e1ac2b3
languageName: node
linkType: hard
"@formatjs/intl-getcanonicallocales@npm:3.2.10":
version: 3.2.10
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.10"
checksum: 10/dbf704d141bd4efc4e2687bd745d1a847a7b94955c23d2f06fe26add8e5ab8ad6096168babad72f2b4568f1fbee32c1528082269273b750bed4bdd1dc5b5d396
"@formatjs/intl-getcanonicallocales@npm:3.2.11":
version: 3.2.11
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.11"
checksum: 10/3132dba96c7cfe63787e126a91e620211a32fa6a623cb763f96102ff153845fb12486f378aa1d761736a7641adb19f5cc307156c8b7a40eedd00223b87c8d2a9
languageName: node
linkType: hard
"@formatjs/intl-listformat@npm:8.3.11":
version: 8.3.11
resolution: "@formatjs/intl-listformat@npm:8.3.11"
"@formatjs/intl-listformat@npm:8.3.12":
version: 8.3.12
resolution: "@formatjs/intl-listformat@npm:8.3.12"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/a08a74c22bb01871944cad91fcb87342e30f3f6792375af3028737794a56b2dec6b7506c8e2b9d0495f4eff50f4d63542fcf86116cd4fdf1d4d8b6838a54ac5c
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/3ae640ab968b3051fd1b2d150d892a087d5a4feecb8bdf4a6a61726888996ee0b054de43e35548374d0a61d8577b5a48f23abfb9506c1560f88d64c0809691f1
languageName: node
linkType: hard
"@formatjs/intl-locale@npm:5.3.9":
version: 5.3.9
resolution: "@formatjs/intl-locale@npm:5.3.9"
"@formatjs/intl-locale@npm:5.3.10":
version: 5.3.10
resolution: "@formatjs/intl-locale@npm:5.3.10"
dependencies:
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
"@formatjs/intl-supportedvaluesof": "npm:2.3.8"
checksum: 10/e2af858ec3ff75611d5412a1e39abce882eab2cf13eb278e4cd1647481321823cc2303c74e86c38b95662e668f6900f9d666136debb2377231d5ddf0bdba3218
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
"@formatjs/intl-supportedvaluesof": "npm:2.3.9"
checksum: 10/f7d8be5ea145089b9daa56e24158f835ed0f3d67d3230b42c3a8a32f3dbdd98534236e9262d86f628288d09968fec32d1c2f5906ec1d5f38e692d436a4052255
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.8.11":
version: 0.8.11
resolution: "@formatjs/intl-localematcher@npm:0.8.11"
"@formatjs/intl-localematcher@npm:0.8.12":
version: 0.8.12
resolution: "@formatjs/intl-localematcher@npm:0.8.12"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.6"
checksum: 10/482b819100997439b2c7295c8112b43c81f64083644cde7124ef1afecaa8fc663bc7b9820b6386d02b45872db7a943c3f1f43a4f78780fe52ed4873ef422e7dc
"@formatjs/fast-memoize": "npm:3.1.7"
checksum: 10/4f9ecb936ac76cb978460edacbb2704e9760f65203e2c17f96b8db605a3c344ecc7da2ac78c897316f519b033c623b39062ba720e23fe0c2162af7c761f765e6
languageName: node
linkType: hard
"@formatjs/intl-numberformat@npm:9.3.12":
version: 9.3.12
resolution: "@formatjs/intl-numberformat@npm:9.3.12"
"@formatjs/intl-numberformat@npm:9.3.13":
version: 9.3.13
resolution: "@formatjs/intl-numberformat@npm:9.3.13"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/6a9f3115136ce9f74fab520d5eeddb6a3f23d32492b004c9c6f108a19dd18e9ccaec04582569238f089b3028cc68783a2b75328b3ce38b13bf30d6267cb33b20
"@formatjs/bigdecimal": "npm:0.2.7"
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/59530d3aec0411a2dae8f2113829b969dceec3ec4a59032cc985ef1b9cebec6d406fbe4dd44ac69e905b4f3b3edb333d61f728a5610fc1e1e132e4bdcb2436aa
languageName: node
linkType: hard
"@formatjs/intl-pluralrules@npm:6.3.11":
version: 6.3.11
resolution: "@formatjs/intl-pluralrules@npm:6.3.11"
"@formatjs/intl-pluralrules@npm:6.3.12":
version: 6.3.12
resolution: "@formatjs/intl-pluralrules@npm:6.3.12"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/c5506c1a20f1aa7fdca24b6800243aec5cbacd5aa0de96d01b497fa9485f6affa7abf6b0d2ea04ebff0ec8a2d22a41913f4353b9fd9c6054899a9f168ef2ebd0
"@formatjs/bigdecimal": "npm:0.2.7"
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/23833b17822a648fc4e87dfe581733f303a8d1c4721a47e875994bcb56c5985c83ea01f0ab176557886fc6b2ef158ce50f9cafa1e1441badefddec0ea79c5303
languageName: node
linkType: hard
"@formatjs/intl-relativetimeformat@npm:12.3.11":
version: 12.3.11
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.11"
"@formatjs/intl-relativetimeformat@npm:12.3.12":
version: 12.3.12
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.12"
dependencies:
"@formatjs/intl-localematcher": "npm:0.8.11"
checksum: 10/ead23de113c9c28334ff2f8696e0f5ff0567f2b929def10d06e67dba2ca502967671abb1bb6ad06b200329e0215b469edd3c3ad9ed4259b313e97f2badfdab53
"@formatjs/intl-localematcher": "npm:0.8.12"
checksum: 10/c667ed62a13cbc8cd5d07f5dbc57a4d1b7e23c31f2aac0ef9e5f509feec014c5a2dfd77ac41a2c34fe537d6f1f6d8ebc8391a479a4294867bb4e980c7211f822
languageName: node
linkType: hard
"@formatjs/intl-supportedvaluesof@npm:2.3.8":
version: 2.3.8
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.8"
"@formatjs/intl-supportedvaluesof@npm:2.3.9":
version: 2.3.9
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.9"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.6"
checksum: 10/d9d4e4d5dda1c26c771d0f2746e1031f9695dee663fb2d2ef9f83794c1e8683de88caf6cdb9718022c592cc879c5c1afa7f72af4c77da9ce5e1d98b54267dfff
"@formatjs/fast-memoize": "npm:3.1.7"
checksum: 10/36254064dd8aab3cb2cbe31db88d751cb0c1d2b0739563ddd1736ed7bd8ec02881de385a69cc4ab7b691f670a3f11ec61a41ba678baa3d0967d87dc0b395cc7b
languageName: node
linkType: hard
@@ -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
@@ -8681,9 +8892,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.6.0":
version: 10.6.0
resolution: "eslint@npm:10.6.0"
"eslint@npm:10.7.0":
version: 10.7.0
resolution: "eslint@npm:10.7.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -8722,7 +8933,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/36d02cdbe37668e601f255917c605d10a5d06d278648dc72cec21fe23b7b60a53453241b32c382ba8107533cacba70aeec97581cc4f4bb591544b1ada2516335
checksum: 10/51cb70fbdd0de6586f0cb12625388faab18eb679cbdb523ecb631cae9f47fd9171d9ff02f570dc4d2e5700017908a81477511025ccb2a6603c5928fbfddcaebf
languageName: node
linkType: hard
@@ -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
@@ -9736,15 +9947,15 @@ __metadata:
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
"@eslint/js": "npm:10.0.1"
"@formatjs/intl-datetimeformat": "npm:7.4.10"
"@formatjs/intl-displaynames": "npm:7.3.11"
"@formatjs/intl-durationformat": "npm:0.10.16"
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
"@formatjs/intl-listformat": "npm:8.3.11"
"@formatjs/intl-locale": "npm:5.3.9"
"@formatjs/intl-numberformat": "npm:9.3.12"
"@formatjs/intl-pluralrules": "npm:6.3.11"
"@formatjs/intl-relativetimeformat": "npm:12.3.11"
"@formatjs/intl-datetimeformat": "npm:7.5.0"
"@formatjs/intl-displaynames": "npm:7.3.12"
"@formatjs/intl-durationformat": "npm:0.10.17"
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
"@formatjs/intl-listformat": "npm:8.3.12"
"@formatjs/intl-locale": "npm:5.3.10"
"@formatjs/intl-numberformat": "npm:9.3.13"
"@formatjs/intl-pluralrules": "npm:6.3.12"
"@formatjs/intl-relativetimeformat": "npm:12.3.12"
"@fullcalendar/core": "npm:6.1.21"
"@fullcalendar/daygrid": "npm:6.1.21"
"@fullcalendar/interaction": "npm:6.1.21"
@@ -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"
@@ -9814,7 +10026,7 @@ __metadata:
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.6.0"
eslint: "npm:10.7.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"
@@ -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"
@@ -9838,7 +10050,7 @@ __metadata:
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.10"
intl-messageformat: "npm:11.2.11"
js-yaml: "npm:5.2.1"
jsdom: "npm:29.1.1"
jszip: "npm:3.10.1"
@@ -9872,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"
@@ -10186,13 +10398,13 @@ __metadata:
languageName: node
linkType: hard
"intl-messageformat@npm:11.2.10":
version: 11.2.10
resolution: "intl-messageformat@npm:11.2.10"
"intl-messageformat@npm:11.2.11":
version: 11.2.11
resolution: "intl-messageformat@npm:11.2.11"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.6"
"@formatjs/icu-messageformat-parser": "npm:3.5.13"
checksum: 10/d3f751ebfe2015cf4a011afe5679398dfa7156c0bc2ccd76ba8c5aa4e82946bd663e750538c97a8b4c946a6d5f5d19fbb56b2e159d422c416e51bba9b10b2746
"@formatjs/fast-memoize": "npm:3.1.7"
"@formatjs/icu-messageformat-parser": "npm:3.5.14"
checksum: 10/5074494588d6c16fb2730234b3c8eb80799189c9c21485323b5c119c22d3a7a8b385868b46479b81c09ca350d82ea0eeaf82b5e9799911b9a1b8bddbb2c3ad1c
languageName: node
linkType: hard
@@ -14427,16 +14639,16 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:*, tar@npm:7.5.19, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.19
resolution: "tar@npm:7.5.19"
"tar@npm:*, tar@npm:7.5.20, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.20
resolution: "tar@npm:7.5.20"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10/0b06a0917fe68a4dff361e147db30fd67ae2ee85ab2863d62046a6ccef46f0d1906eed20f92277a436300eaaa0e3cd31d8763d7f02fa389f41d7966e58388db8
checksum: 10/90a0fe423ac921197ad5eefc5e5f7ad7f42b06e80444c8c347c1e4112384cbe9cb53ade3ef71748eed3684888756869c2aeef6b6303c766101f3217e254d4be9
languageName: node
linkType: hard
@@ -14814,12 +15026,12 @@ __metadata:
languageName: node
linkType: hard
"type-fest@npm:^5.7.0":
version: 5.7.0
resolution: "type-fest@npm:5.7.0"
"type-fest@npm:^5.8.0":
version: 5.8.0
resolution: "type-fest@npm:5.8.0"
dependencies:
tagged-tag: "npm:^1.0.0"
checksum: 10/4867626aa489968df98e09ecdefbc45dfbb191ae5fb8924b3bd45da9cd940879b387086226366dce028570983a3fbe80adc53ad105a169bbbd27621c496bd6f0
checksum: 10/4dbe4c78ac6933d3d165b01f9d552991a84d63e5352110e01bebb5c46499fb1f44d199fc88835b18ee5662ee44c73926621f59525e46f7faa031086ac00b2686
languageName: node
linkType: hard
@@ -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