mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-16 15:18:16 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d203799e57 | ||
|
|
6dcca89185 | ||
|
|
3f1b2ef65f | ||
|
|
34afbe37c3 | ||
|
|
848e81313e | ||
|
|
24e7bd306a | ||
|
|
3791548067 | ||
|
|
01cef9a99b | ||
|
|
8eb179a0d9 | ||
|
|
34e9b50920 | ||
|
|
2ebe452334 | ||
|
|
3052989e9d | ||
|
|
fcab4004b2 | ||
|
|
03ed72147d | ||
|
|
b585d41bf5 | ||
|
|
4610adb368 | ||
|
|
e0dba17b66 | ||
|
|
3e069f2eac | ||
|
|
94e7fdba47 | ||
|
|
0b6e3f20b1 | ||
|
|
4ef97bc2d0 | ||
|
|
6bc6be27ad | ||
|
|
33b7a59aef | ||
|
|
3c08f602dd | ||
|
|
7d564c8047 | ||
|
|
6c352e904d | ||
|
|
c12fa5591c | ||
|
|
f389679bf3 | ||
|
|
9b8bbafa7e | ||
|
|
b0f0eb1819 | ||
|
|
eee0f9447b | ||
|
|
8d149a914f | ||
|
|
b2bb3e9d9d | ||
|
|
13ef9712bc | ||
|
|
40b27608c0 | ||
|
|
e0f03254b5 | ||
|
|
35dcba4f3f |
@@ -358,6 +358,12 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/ingress/panels") {
|
||||
// The navigation picker waits for this collection before it enables
|
||||
// itself, so it has to answer even when no add-on exposes a panel.
|
||||
return { panels: {} };
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/store/reload") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ This component is based on the webawesome input component.
|
||||
| invalid | Boolean | false | Marks the input as invalid. |
|
||||
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
|
||||
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
|
||||
| input-id | String | - | Sets the native input's `id` and associated label `for` on the inner `wa-input`. Defaults to `"input"`. |
|
||||
| pattern | String | - | A regular expression pattern to validate input against. |
|
||||
| minlength | Number | - | The minimum length of input that will be considered valid. |
|
||||
| maxlength | Number | - | The maximum length of input that will be considered valid. |
|
||||
|
||||
@@ -21,6 +21,8 @@ import "../../../../src/components/ha-settings-row";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import type { SerialPort } from "../../../../src/data/usb";
|
||||
import {
|
||||
showDialog,
|
||||
type ShowDialogParams,
|
||||
@@ -293,9 +295,72 @@ const LABELS: LabelRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const serialPort = (port: Partial<SerialPort>): SerialPort => ({
|
||||
device: "/dev/ttyUSB0",
|
||||
resolved_device: null,
|
||||
serial_number: null,
|
||||
manufacturer: null,
|
||||
description: null,
|
||||
matching_integrations: [],
|
||||
present: true,
|
||||
...port,
|
||||
});
|
||||
|
||||
// One port per section the picker groups by, relative to the "zha" domain the
|
||||
// serial port selector below is given as its flow context
|
||||
const SERIAL_PORTS: SerialPort[] = [
|
||||
serialPort({
|
||||
device:
|
||||
"/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
|
||||
description: "SkyConnect v1.0",
|
||||
manufacturer: "Nabu Casa",
|
||||
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
matching_integrations: ["zha"],
|
||||
}),
|
||||
serialPort({
|
||||
device: "esphome-hass://01JQ8Z5X9WQ0/?port_name=UART0",
|
||||
}),
|
||||
serialPort({
|
||||
device: "socket://192.168.1.10:6638",
|
||||
}),
|
||||
serialPort({
|
||||
device: "/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_AB0KVD1L-if00-port0",
|
||||
description: "FT232R USB UART",
|
||||
manufacturer: "FTDI",
|
||||
serial_number: "AB0KVD1L",
|
||||
vid: "0403",
|
||||
pid: "6001",
|
||||
}),
|
||||
serialPort({
|
||||
device: "/dev/ttyS0",
|
||||
description: "ttyS0",
|
||||
manufacturer: "Intel",
|
||||
}),
|
||||
serialPort({ device: "/dev/ttyAMA0" }),
|
||||
serialPort({
|
||||
device:
|
||||
"/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0",
|
||||
description: "CP2102 USB to UART Bridge Controller",
|
||||
manufacturer: "Silicon Labs",
|
||||
serial_number: "0001",
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
matching_integrations: ["matter"],
|
||||
}),
|
||||
];
|
||||
|
||||
const SCHEMAS: {
|
||||
name: string;
|
||||
input: Record<string, (BlueprintInput & { required?: boolean }) | null>;
|
||||
input: Record<
|
||||
string,
|
||||
| (BlueprintInput & {
|
||||
required?: boolean;
|
||||
context?: Record<string, unknown>;
|
||||
})
|
||||
| null
|
||||
>;
|
||||
}[] = [
|
||||
{
|
||||
name: "One of each",
|
||||
@@ -319,7 +384,22 @@ const SCHEMAS: {
|
||||
selector: { config_entry: {} },
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
signed_duration: {
|
||||
name: "Signed duration",
|
||||
selector: { duration: { mode: "signed" } },
|
||||
},
|
||||
offset_duration: {
|
||||
name: "Offset",
|
||||
selector: { duration: { mode: "offset", enable_day: true } },
|
||||
},
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
serial_port: {
|
||||
name: "Serial port",
|
||||
selector: { serial_port: {} },
|
||||
// A config flow passes its own domain as context, which is what the
|
||||
// picker groups recommended and not recommended ports by
|
||||
context: { domain: "zha" },
|
||||
},
|
||||
number_box: {
|
||||
name: "Number Box",
|
||||
selector: {
|
||||
@@ -504,6 +584,99 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
addon: { name: "Add-on", selector: { addon: {} } },
|
||||
areas_display: {
|
||||
name: "Areas display",
|
||||
selector: { areas_display: {} },
|
||||
},
|
||||
assist_pipeline: {
|
||||
name: "Assist pipeline",
|
||||
selector: { assist_pipeline: {} },
|
||||
},
|
||||
automation_behavior: {
|
||||
name: "Automation behavior",
|
||||
selector: { automation_behavior: { mode: "trigger" } },
|
||||
},
|
||||
backup_location: {
|
||||
name: "Backup location",
|
||||
selector: { backup_location: {} },
|
||||
},
|
||||
button_toggle: {
|
||||
name: "Button toggle",
|
||||
selector: {
|
||||
button_toggle: {
|
||||
options: [
|
||||
{ label: "Left", value: "left" },
|
||||
{ label: "Center", value: "center" },
|
||||
{ label: "Right", value: "right" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
condition: { name: "Condition", selector: { condition: {} } },
|
||||
conversation_agent: {
|
||||
name: "Conversation agent",
|
||||
selector: { conversation_agent: {} },
|
||||
},
|
||||
country: {
|
||||
name: "Country",
|
||||
selector: { country: { countries: ["DE", "GB", "NL", "US"] } },
|
||||
},
|
||||
entity_name: {
|
||||
name: "Entity name",
|
||||
selector: { entity_name: { entity_id: "light.bedroom" } },
|
||||
},
|
||||
file: {
|
||||
name: "File",
|
||||
selector: { file: { accept: "image/png,image/jpeg" } },
|
||||
},
|
||||
language: { name: "Language", selector: { language: {} } },
|
||||
navigation: { name: "Navigation", selector: { navigation: {} } },
|
||||
numeric_threshold: {
|
||||
name: "Numeric threshold",
|
||||
selector: { numeric_threshold: {} },
|
||||
},
|
||||
period: {
|
||||
name: "Period",
|
||||
selector: {
|
||||
period: {
|
||||
options: ["today", "yesterday", "this_week", "this_month"],
|
||||
},
|
||||
},
|
||||
// Without a value that matches one of the options the selector offers
|
||||
// its custom-period editor instead of the list
|
||||
default: { calendar: { period: "day" } },
|
||||
},
|
||||
selector: {
|
||||
name: "Selector",
|
||||
selector: { selector: {} },
|
||||
default: { text: {} },
|
||||
},
|
||||
state_class: { name: "State class", selector: { state_class: {} } },
|
||||
statistic: { name: "Statistic", selector: { statistic: {} } },
|
||||
stt: { name: "Speech-to-text", selector: { stt: {} } },
|
||||
theme: { name: "Theme", selector: { theme: {} } },
|
||||
timezone: { name: "Time zone", selector: { timezone: {} } },
|
||||
trigger: { name: "Trigger", selector: { trigger: {} } },
|
||||
tts: { name: "Text-to-speech", selector: { tts: {} } },
|
||||
tts_voice: {
|
||||
name: "Text-to-speech voice",
|
||||
selector: { tts_voice: { engineId: "tts.cloud", language: "en-US" } },
|
||||
},
|
||||
ui_action: { name: "UI action", selector: { ui_action: {} } },
|
||||
ui_clock_date_format: {
|
||||
name: "Clock date format",
|
||||
selector: { ui_clock_date_format: {} },
|
||||
},
|
||||
ui_color: { name: "UI color", selector: { ui_color: {} } },
|
||||
ui_state_content: {
|
||||
name: "State content",
|
||||
selector: { ui_state_content: { entity_id: "light.bedroom" } },
|
||||
},
|
||||
ui_time_format: {
|
||||
name: "Time format",
|
||||
selector: { ui_time_format: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -605,8 +778,98 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
mockFloorRegistry(hass, FLOORS);
|
||||
mockLabelRegistry(hass, LABELS);
|
||||
mockHassioSupervisor(hass);
|
||||
hass.addTranslations({
|
||||
"component.matter.title": "Matter",
|
||||
"component.zha.title": "Zigbee Home Automation",
|
||||
});
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...hass.config.components, "usb"],
|
||||
},
|
||||
});
|
||||
hass.mockWS("auth/sign_path", (params) => params);
|
||||
hass.mockWS("media_player/browse_media", this._browseMedia);
|
||||
hass.mockWS("usb/list_serial_ports", () => SERIAL_PORTS);
|
||||
hass.mockWS("recorder/list_statistic_ids", () => [
|
||||
{
|
||||
statistic_id: "sensor.energy_consumption",
|
||||
statistics_unit_of_measurement: "kWh",
|
||||
source: "recorder",
|
||||
name: null,
|
||||
has_sum: true,
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
unit_class: "energy",
|
||||
},
|
||||
{
|
||||
statistic_id: "sensor.outside_temperature",
|
||||
statistics_unit_of_measurement: "\u00b0C",
|
||||
source: "recorder",
|
||||
name: null,
|
||||
has_sum: false,
|
||||
mean_type: StatisticMeanType.ARITHMETIC,
|
||||
unit_class: "temperature",
|
||||
},
|
||||
]);
|
||||
hass.mockWS("assist_pipeline/pipeline/list", () => ({
|
||||
pipelines: [
|
||||
{
|
||||
id: "pipeline_home",
|
||||
name: "Home Assistant",
|
||||
language: "en",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "en",
|
||||
stt_engine: "stt.cloud",
|
||||
stt_language: "en-US",
|
||||
tts_engine: "tts.cloud",
|
||||
tts_language: "en-US",
|
||||
tts_voice: "JennyNeural",
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
],
|
||||
preferred_pipeline: "pipeline_home",
|
||||
}));
|
||||
hass.mockWS("conversation/agent/list", () => ({
|
||||
agents: [
|
||||
{
|
||||
id: "conversation.home_assistant",
|
||||
name: "Home Assistant",
|
||||
supported_languages: "*",
|
||||
},
|
||||
{
|
||||
id: "conversation.openai",
|
||||
name: "OpenAI Conversation",
|
||||
supported_languages: ["en"],
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("stt/engine/list", () => ({
|
||||
providers: [
|
||||
{
|
||||
engine_id: "stt.cloud",
|
||||
name: "Home Assistant Cloud",
|
||||
supported_languages: ["en-US"],
|
||||
deprecated: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("tts/engine/list", () => ({
|
||||
providers: [
|
||||
{
|
||||
engine_id: "tts.cloud",
|
||||
name: "Home Assistant Cloud",
|
||||
supported_languages: ["en-US"],
|
||||
deprecated: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("tts/engine/voices", () => ({
|
||||
voices: [
|
||||
{ voice_id: "JennyNeural", name: "Jenny" },
|
||||
{ voice_id: "GuyNeural", name: "Guy" },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
public provideHass(el) {
|
||||
@@ -776,6 +1039,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${value!.selector}
|
||||
.context=${value!.context}
|
||||
.key=${key}
|
||||
.label=${this._label ? value!.name : undefined}
|
||||
.value=${data[key] ?? value!.default}
|
||||
|
||||
@@ -21,7 +21,8 @@ const ENTITIES = [
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Dining Room",
|
||||
supported_features: 1,
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 100,
|
||||
},
|
||||
},
|
||||
@@ -30,7 +31,7 @@ const ENTITIES = [
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Dining Room",
|
||||
supported_features: 1,
|
||||
supported_color_modes: ["brightness"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -38,7 +39,7 @@ const ENTITIES = [
|
||||
state: "unavailable",
|
||||
attributes: {
|
||||
friendly_name: "Lost Light",
|
||||
supported_features: 1,
|
||||
supported_color_modes: ["brightness"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
|
||||
const ALL_FEATURES =
|
||||
LawnMowerEntityFeature.START_MOWING +
|
||||
LawnMowerEntityFeature.PAUSE +
|
||||
LawnMowerEntityFeature.STOP +
|
||||
LawnMowerEntityFeature.DOCK;
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -49,6 +50,14 @@ const ENTITIES = [
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.idle",
|
||||
state: "idle",
|
||||
attributes: {
|
||||
friendly_name: "Idle",
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.error",
|
||||
state: "error",
|
||||
|
||||
+5
-6
@@ -67,7 +67,7 @@
|
||||
"@fullcalendar/list": "6.1.21",
|
||||
"@fullcalendar/luxon3": "6.1.21",
|
||||
"@fullcalendar/timegrid": "6.1.21",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.0",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.1",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -84,7 +84,6 @@
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
@@ -115,7 +114,7 @@
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.11",
|
||||
"marked": "18.0.12",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -153,7 +152,7 @@
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@rspack/core": "2.2.2",
|
||||
"@rspack/core": "2.2.3",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -195,7 +194,7 @@
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.0.1",
|
||||
"jszip": "3.10.1",
|
||||
"jszip": "3.10.2",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.5.0",
|
||||
@@ -213,7 +212,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"typescript-eslint": "8.70.0",
|
||||
"vite": "8.2.2",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.0",
|
||||
|
||||
@@ -9,36 +9,34 @@ export const createDurationData = (
|
||||
}
|
||||
if (typeof duration !== "object") {
|
||||
if (typeof duration === "string" || isNaN(duration)) {
|
||||
const parts = duration?.toString().split(":") || [];
|
||||
const durationString = duration.toString().trim();
|
||||
// A leading "-" negates the whole period, matching
|
||||
// `cv.time_period_str` in core.
|
||||
const negative = durationString[0] === "-";
|
||||
const parts = durationString
|
||||
.split(":")
|
||||
.map((part) =>
|
||||
negative && part ? -Math.abs(Number(part)) : Number(part)
|
||||
);
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { seconds: Number(parts[0]) };
|
||||
return { seconds: parts[0] };
|
||||
}
|
||||
if (parts.length > 3) {
|
||||
return undefined;
|
||||
}
|
||||
const seconds = Number(parts[2]) || 0;
|
||||
const seconds_whole = Math.floor(seconds);
|
||||
const seconds = parts[2] || 0;
|
||||
const secondsWhole = Math.trunc(seconds);
|
||||
return {
|
||||
hours: Number(parts[0]) || 0,
|
||||
minutes: Number(parts[1]) || 0,
|
||||
seconds: seconds_whole,
|
||||
milliseconds: Math.floor(
|
||||
Number((seconds - seconds_whole).toFixed(4)) * 1000
|
||||
hours: parts[0] || 0,
|
||||
minutes: parts[1] || 0,
|
||||
seconds: secondsWhole,
|
||||
milliseconds: Math.trunc(
|
||||
Number((seconds - secondsWhole).toFixed(4)) * 1000
|
||||
),
|
||||
};
|
||||
}
|
||||
return { seconds: duration };
|
||||
}
|
||||
if (!("days" in duration)) {
|
||||
return duration;
|
||||
}
|
||||
const { days, minutes, seconds, milliseconds } = duration;
|
||||
let hours = duration.hours || 0;
|
||||
hours = (hours || 0) + (days || 0) * 24;
|
||||
return {
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
milliseconds,
|
||||
};
|
||||
return duration;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
|
||||
export const durationValueToData = (
|
||||
value?: HaDurationData | string | number
|
||||
): HaDurationData | undefined => {
|
||||
if (typeof value === "number") {
|
||||
return value < 0
|
||||
? { negative: true, seconds: Math.abs(value) }
|
||||
: { seconds: value };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const negative = value.trim()[0] === "-";
|
||||
const parts = value.split(":").map((p) => Math.abs(Number(p)));
|
||||
let data: HaDurationData | undefined;
|
||||
if (parts.length === 1) {
|
||||
data = { seconds: parts[0] };
|
||||
} else if (parts.length === 2) {
|
||||
data = { hours: parts[0], minutes: parts[1] };
|
||||
} else if (parts.length === 3) {
|
||||
data = { hours: parts[0], minutes: parts[1], seconds: parts[2] };
|
||||
}
|
||||
return data && negative ? { negative: true, ...data } : data;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
import { durationDataToSeconds } from "./duration_to_seconds";
|
||||
|
||||
const COMPONENTS = [
|
||||
"days",
|
||||
"hours",
|
||||
"minutes",
|
||||
"seconds",
|
||||
"milliseconds",
|
||||
] as const;
|
||||
|
||||
export interface NormalizedDuration extends HaDurationData {
|
||||
negative: boolean;
|
||||
}
|
||||
|
||||
export const normalizeDuration = (
|
||||
duration: HaDurationData
|
||||
): NormalizedDuration => {
|
||||
const { negative, ...components } = duration;
|
||||
if (negative !== undefined) {
|
||||
return { negative, ...components };
|
||||
}
|
||||
for (const field of COMPONENTS) {
|
||||
const amount = components[field];
|
||||
if (amount !== undefined) {
|
||||
components[field] = Math.abs(amount);
|
||||
}
|
||||
}
|
||||
return { negative: durationDataToSeconds(duration) < 0, ...components };
|
||||
};
|
||||
@@ -29,7 +29,7 @@ export const FIXED_DOMAIN_STATES = {
|
||||
fan: ["on", "off"],
|
||||
humidifier: ["on", "off"],
|
||||
input_boolean: ["on", "off"],
|
||||
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
|
||||
lawn_mower: ["error", "paused", "mowing", "returning", "docked", "idle"],
|
||||
light: ["on", "off"],
|
||||
lock: [
|
||||
"jammed",
|
||||
|
||||
@@ -36,7 +36,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
case "person":
|
||||
return compareState !== "not_home";
|
||||
case "lawn_mower":
|
||||
return !["docked", "paused"].includes(compareState);
|
||||
return !["docked", "paused", "idle"].includes(compareState);
|
||||
case "lock":
|
||||
return compareState !== "locked";
|
||||
case "media_player":
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
TimeZone,
|
||||
} from "../../data/translation";
|
||||
import { translationMetadata } from "../../resources/translations-metadata";
|
||||
import type { HomeAssistantInternationalization } from "../../types";
|
||||
import type { LocalizeFunc } from "./localize";
|
||||
|
||||
/**
|
||||
* Builds an internationalization context value for the "lite" localize mode
|
||||
* used before a `hass` object exists. Locale falls back to browser defaults and
|
||||
* the lazy loaders are stubs that warn when called.
|
||||
*/
|
||||
export const buildLiteInternationalization = (
|
||||
language: string,
|
||||
localize: LocalizeFunc
|
||||
): HomeAssistantInternationalization => ({
|
||||
language,
|
||||
selectedLanguage: language,
|
||||
locale: {
|
||||
language,
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
localize,
|
||||
translationMetadata,
|
||||
loadBackendTranslation: async (category, integrations, configFlow) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"loadBackendTranslation called in lite i18n mode; backend strings are not available",
|
||||
{ category, integrations, configFlow }
|
||||
);
|
||||
return localize;
|
||||
},
|
||||
loadFragmentTranslation: async (fragment) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`loadFragmentTranslation("${fragment}") called in lite i18n mode; fragment not loaded`
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
@@ -44,7 +44,8 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
// Render each row's name inside the plot (under its bar) instead of in a
|
||||
// left-hand category-label column. Opt-in; used by the history panel.
|
||||
// left-hand category-label column. Opt-in; used by the history panel and
|
||||
// history-graph card.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@@ -79,7 +80,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${
|
||||
this.data.length *
|
||||
(this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) +
|
||||
(this.insideLabels && (this.chunked || this.showNames)
|
||||
? ROW_HEIGHT_INSIDE_LABELS
|
||||
: ROW_HEIGHT) +
|
||||
GRID_BOTTOM
|
||||
}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
|
||||
@@ -80,7 +80,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
// Render timeline row names inside the plot (under each bar) instead of in a
|
||||
// left-hand column. Opt-in; used by the history panel.
|
||||
// left-hand column. Opt-in; used by the history panel and history-graph card.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@ export class HaFilterChip extends FilterChip {
|
||||
`,
|
||||
];
|
||||
|
||||
protected getContainerClasses() {
|
||||
const classes = super.getContainerClasses();
|
||||
if (this.noLeadingIcon) {
|
||||
classes["has-icon"] = false;
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
protected renderLeadingIcon() {
|
||||
if (this.noLeadingIcon) {
|
||||
// eslint-disable-next-line lit/prefer-nothing
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import { mdiClose, mdiMenuDown, mdiMinus, mdiPlus } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import "./ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "./ha-dropdown";
|
||||
import "./ha-dropdown-item";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-input-helper-text";
|
||||
import "./ha-select";
|
||||
import type { HaSelectSelectEvent } from "./ha-select";
|
||||
import "./input/ha-input";
|
||||
import type { HaInput } from "./input/ha-input";
|
||||
|
||||
const SIGNS = [
|
||||
{ negative: false, label: "+" },
|
||||
{ negative: true, label: "−" },
|
||||
];
|
||||
|
||||
export interface TimeChangedEvent {
|
||||
negative?: boolean;
|
||||
days?: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
@@ -132,6 +142,10 @@ export class HaBaseTimeInput extends LitElement {
|
||||
*/
|
||||
@property({ attribute: false }) amPm: "AM" | "PM" = "AM";
|
||||
|
||||
@property({ attribute: "enable-sign", type: Boolean }) enableSign = false;
|
||||
|
||||
@property({ type: Boolean }) negative = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
|
||||
|
||||
@property({ attribute: "placeholder-labels", type: Boolean })
|
||||
@@ -165,6 +179,43 @@ export class HaBaseTimeInput extends LitElement {
|
||||
role="group"
|
||||
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
|
||||
>
|
||||
${
|
||||
this.enableSign
|
||||
? html`<ha-dropdown
|
||||
placement="bottom-start"
|
||||
@wa-select=${this._signSelected}
|
||||
@wa-after-hide=${stopPropagation}
|
||||
@wa-hide=${stopPropagation}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
type="button"
|
||||
class="sign"
|
||||
aria-label=${this.negative ? "-" : "+"}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${this.negative ? mdiMinus : mdiPlus}
|
||||
></ha-svg-icon>
|
||||
<ha-svg-icon
|
||||
class="chevron"
|
||||
.path=${mdiMenuDown}
|
||||
></ha-svg-icon>
|
||||
</button>
|
||||
${SIGNS.map(
|
||||
({ negative, label }) => html`
|
||||
<ha-dropdown-item
|
||||
.value=${negative ? "-" : "+"}
|
||||
.selected=${this.negative === negative}
|
||||
>
|
||||
${label}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
)}
|
||||
</ha-dropdown>
|
||||
<div class="sign-divider"></div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.enableDay
|
||||
? html`
|
||||
@@ -324,18 +375,35 @@ export class HaBaseTimeInput extends LitElement {
|
||||
fireEvent(this, "value-changed");
|
||||
}
|
||||
|
||||
private _signSelected(ev: HaDropdownSelectEvent): void {
|
||||
ev.stopPropagation();
|
||||
const negative = ev.detail.item.value === "-";
|
||||
if (negative === this.negative) {
|
||||
return;
|
||||
}
|
||||
this.negative = negative;
|
||||
this._fireValue();
|
||||
}
|
||||
|
||||
private _valueChanged(ev: InputEvent | HaSelectSelectEvent): void {
|
||||
const textField = ev.currentTarget as HaInput;
|
||||
this[textField.name || ""] =
|
||||
textField.name === "amPm"
|
||||
? (ev as HaSelectSelectEvent).detail.value
|
||||
: Number(textField.value);
|
||||
this._fireValue();
|
||||
}
|
||||
|
||||
private _fireValue(): void {
|
||||
const value: TimeChangedEvent = {
|
||||
hours: this.hours,
|
||||
minutes: this.minutes,
|
||||
seconds: this.seconds,
|
||||
milliseconds: this.milliseconds,
|
||||
};
|
||||
if (this.enableSign) {
|
||||
value.negative = this.negative;
|
||||
}
|
||||
if (this.enableDay) {
|
||||
value.days = this.days;
|
||||
}
|
||||
@@ -411,6 +479,10 @@ export class HaBaseTimeInput extends LitElement {
|
||||
padding-inline-start: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.sign-divider + ha-input::part(wa-base) {
|
||||
padding-inline-start: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-input:last-child::part(wa-base) {
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
}
|
||||
@@ -423,6 +495,53 @@ export class HaBaseTimeInput extends LitElement {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sign {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
padding-inline: var(--ha-space-3) var(--ha-space-1);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
|
||||
background-color: var(--ha-color-form-background);
|
||||
color: var(--ha-color-text-secondary);
|
||||
cursor: pointer;
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
.sign .chevron {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.sign:hover {
|
||||
background-color: var(--ha-color-form-background-hover);
|
||||
}
|
||||
.sign:disabled {
|
||||
cursor: default;
|
||||
color: var(--ha-color-text-disabled);
|
||||
}
|
||||
.sign:focus-visible {
|
||||
outline: 2px solid var(--ha-color-border-primary-normal);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
ha-dropdown-item {
|
||||
font-size: var(--ha-font-size-l);
|
||||
text-align: center;
|
||||
}
|
||||
.sign-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
padding-inline: 0 var(--ha-space-1);
|
||||
background-color: var(--ha-color-form-background);
|
||||
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
|
||||
}
|
||||
.sign-divider::after {
|
||||
content: "";
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: var(--ha-color-border-neutral-quiet);
|
||||
}
|
||||
.time-separator,
|
||||
ha-icon-button {
|
||||
background-color: var(--ha-color-form-background);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { mdiMinusThick, mdiPlusThick } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { normalizeDuration } from "../common/datetime/normalize_duration";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import "./ha-base-time-input";
|
||||
import type { HaBaseTimeInput, TimeChangedEvent } from "./ha-base-time-input";
|
||||
import "./ha-button-toggle-group";
|
||||
|
||||
export interface HaDurationData {
|
||||
negative?: boolean;
|
||||
days?: number;
|
||||
hours?: number;
|
||||
minutes?: number;
|
||||
@@ -44,8 +44,6 @@ export class HaDurationInput extends LitElement {
|
||||
|
||||
@query("ha-base-time-input", true) private _input?: HaBaseTimeInput;
|
||||
|
||||
private _toggleNegative = false;
|
||||
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
@@ -56,24 +54,11 @@ export class HaDurationInput extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const folded = this._data;
|
||||
const data =
|
||||
folded && this.allowNegative ? normalizeDuration(folded) : folded;
|
||||
return html`
|
||||
<div class="row">
|
||||
${
|
||||
this.allowNegative
|
||||
? html`
|
||||
<ha-button-toggle-group
|
||||
size="s"
|
||||
.buttons=${[
|
||||
{ label: "+", iconPath: mdiPlusThick, value: "+" },
|
||||
{ label: "-", iconPath: mdiMinusThick, value: "-" },
|
||||
]}
|
||||
.active=${this._negative ? "-" : "+"}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._negativeChanged}
|
||||
></ha-button-toggle-group>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-base-time-input
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
@@ -85,12 +70,14 @@ export class HaDurationInput extends LitElement {
|
||||
.enableSecond=${this.enableSecond}
|
||||
.enableMillisecond=${this.enableMillisecond}
|
||||
.enableDay=${this.enableDay}
|
||||
.enableSign=${this.allowNegative}
|
||||
.negative=${!!data?.negative}
|
||||
format="24"
|
||||
.days=${this._days}
|
||||
.hours=${this._hours}
|
||||
.minutes=${this._minutes}
|
||||
.seconds=${this._seconds}
|
||||
.milliseconds=${this._milliseconds}
|
||||
.days=${this._component(data, "days")}
|
||||
.hours=${this._component(data, "hours")}
|
||||
.minutes=${this._component(data, "minutes")}
|
||||
.seconds=${this._component(data, "seconds")}
|
||||
.milliseconds=${this._component(data, "milliseconds")}
|
||||
@value-changed=${this._durationChanged}
|
||||
no-hours-limit
|
||||
day-label="dd"
|
||||
@@ -103,77 +90,40 @@ export class HaDurationInput extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private get _negative() {
|
||||
return (
|
||||
this._toggleNegative ||
|
||||
(this.data?.days
|
||||
? this.data.days < 0
|
||||
: this.data?.hours
|
||||
? this.data.hours < 0
|
||||
: this.data?.minutes
|
||||
? this.data.minutes < 0
|
||||
: this.data?.seconds
|
||||
? this.data.seconds < 0
|
||||
: this.data?.milliseconds
|
||||
? this.data.milliseconds < 0
|
||||
: false)
|
||||
);
|
||||
// Fold units we do not render into the smallest unit we do, so a value the
|
||||
// user cannot see is not silently dropped on the next edit.
|
||||
private get _data(): HaDurationData | undefined {
|
||||
if (!this.data) {
|
||||
return this.data;
|
||||
}
|
||||
const data = { ...this.data };
|
||||
if (!this.enableDay && data.days) {
|
||||
data.hours = (data.hours || 0) + data.days * 24;
|
||||
delete data.days;
|
||||
}
|
||||
if (!this.enableMillisecond && data.milliseconds) {
|
||||
data.seconds = (data.seconds || 0) + data.milliseconds / 1000;
|
||||
delete data.milliseconds;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private get _days() {
|
||||
return this.data?.days
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.days))
|
||||
: Number(this.data.days)
|
||||
: this.required || this.data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _hours() {
|
||||
return this.data?.hours
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.hours))
|
||||
: Number(this.data.hours)
|
||||
: this.required || this.data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _minutes() {
|
||||
return this.data?.minutes
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.minutes))
|
||||
: Number(this.data.minutes)
|
||||
: this.required || this.data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _seconds() {
|
||||
return this.data?.seconds
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.seconds))
|
||||
: Number(this.data.seconds)
|
||||
: this.required || this.data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _milliseconds() {
|
||||
return this.data?.milliseconds
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.milliseconds))
|
||||
: Number(this.data.milliseconds)
|
||||
: this.required || this.data
|
||||
? 0
|
||||
: NaN;
|
||||
private _component(
|
||||
data: HaDurationData | undefined,
|
||||
field: keyof HaDurationData
|
||||
): number {
|
||||
const amount = data?.[field];
|
||||
if (amount) {
|
||||
return Number(amount);
|
||||
}
|
||||
return this.required || data ? 0 : NaN;
|
||||
}
|
||||
|
||||
private _durationChanged(
|
||||
ev: ValueChangedEvent<TimeChangedEvent | undefined>
|
||||
) {
|
||||
ev.stopPropagation();
|
||||
const negative = ev.detail.value?.negative ?? false;
|
||||
const value = ev.detail.value ? { ...ev.detail.value } : undefined;
|
||||
|
||||
if (value) {
|
||||
@@ -217,36 +167,17 @@ export class HaDurationInput extends LitElement {
|
||||
value.days = (value.days ?? 0) + Math.floor(value.hours / 24);
|
||||
value.hours %= 24;
|
||||
}
|
||||
|
||||
if (this._negative) {
|
||||
FIELDS.forEach((t) => {
|
||||
if (value[t]) {
|
||||
value[t] = -Math.abs(value[t]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value,
|
||||
value:
|
||||
value && this.allowNegative ? this._withSign(value, negative) : value,
|
||||
});
|
||||
}
|
||||
|
||||
private _negativeChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const negative = (ev.detail?.value || ev.target.value) === "-";
|
||||
this._toggleNegative = negative;
|
||||
if (this.data) {
|
||||
const value = { ...this.data };
|
||||
FIELDS.forEach((t) => {
|
||||
if (value[t]) {
|
||||
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
|
||||
}
|
||||
});
|
||||
fireEvent(this, "value-changed", {
|
||||
value,
|
||||
});
|
||||
}
|
||||
private _withSign(value: HaDurationData, negative: boolean): HaDurationData {
|
||||
const { negative: _negative, ...components } = normalizeDuration(value);
|
||||
return negative ? { negative: true, ...components } : components;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -254,9 +185,6 @@ export class HaDurationInput extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-button-toggle-group {
|
||||
margin: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export class HaFormString extends LitElement implements HaFormElement {
|
||||
.name=${this.schema.name}
|
||||
.autofocus=${!!this.schema.autofocus}
|
||||
.autocomplete=${this.schema.autocomplete}
|
||||
.inputId=${this.schema.name}
|
||||
.validationMessage=${
|
||||
this.schema.required
|
||||
? this.localize?.("ui.common.error_required")
|
||||
|
||||
@@ -28,6 +28,11 @@ const LAWN_MOWER_ACTIONS: Partial<
|
||||
service: "start_mowing",
|
||||
feature: LawnMowerEntityFeature.START_MOWING,
|
||||
},
|
||||
idle: {
|
||||
action: "start_mowing",
|
||||
service: "start_mowing",
|
||||
feature: LawnMowerEntityFeature.START_MOWING,
|
||||
},
|
||||
returning: {
|
||||
action: "pause",
|
||||
service: "pause",
|
||||
|
||||
@@ -145,7 +145,7 @@ export class HaSelect extends LitElement {
|
||||
type="button"
|
||||
class=${this._opened ? "opened" : ""}
|
||||
compact
|
||||
aria-label=${ifDefined(this.label)}
|
||||
aria-label=${ifDefined(this.ariaLabel || this.label)}
|
||||
@clear=${this._clearValue}
|
||||
.label=${this.label}
|
||||
.value=${valueLabel}
|
||||
|
||||
@@ -45,8 +45,11 @@ export class HaChooseSelector extends LitElement {
|
||||
}
|
||||
if (
|
||||
changedProperties.has("value") &&
|
||||
changedProperties.get("value")?.active_choice &&
|
||||
changedProperties.get("value")?.active_choice !== this._activeChoice
|
||||
typeof this.value === "object" &&
|
||||
this.value !== null &&
|
||||
"active_choice" in this.value &&
|
||||
this.value.active_choice in this.selector.choose.choices &&
|
||||
this.value.active_choice !== this._activeChoice
|
||||
) {
|
||||
this._setActiveChoice();
|
||||
}
|
||||
@@ -150,14 +153,14 @@ export class HaChooseSelector extends LitElement {
|
||||
if (this.value === null || this.value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof this.value === "object"
|
||||
return typeof this.value === "object" && "active_choice" in this.value
|
||||
? this.value[choice || this.value.active_choice]
|
||||
: this.value;
|
||||
}
|
||||
|
||||
private _setActiveChoice() {
|
||||
if (this.value) {
|
||||
if (typeof this.value === "object") {
|
||||
if (typeof this.value === "object" && "active_choice" in this.value) {
|
||||
if (this.value.active_choice in this.selector.choose.choices) {
|
||||
this._activeChoice = this.value.active_choice;
|
||||
return;
|
||||
@@ -199,6 +202,22 @@ export class HaChooseSelector extends LitElement {
|
||||
];
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeofValue === "object" &&
|
||||
!Array.isArray(this.value) &&
|
||||
Object.keys(this.value).length > 0 &&
|
||||
Object.keys(this.value).every((key) =>
|
||||
["days", "hours", "minutes", "seconds", "milliseconds"].includes(
|
||||
key
|
||||
)
|
||||
) &&
|
||||
selectorTypes.includes("duration")
|
||||
) {
|
||||
this._activeChoice = Object.keys(this.selector.choose.choices)[
|
||||
selectorTypes.indexOf("duration")
|
||||
];
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._activeChoice = Object.keys(this.selector.choose.choices)[0];
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import {
|
||||
mdiClockMinusOutline,
|
||||
mdiClockOutline,
|
||||
mdiClockPlusOutline,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
|
||||
import { normalizeDuration } from "../../common/datetime/normalize_duration";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { DurationSelector } from "../../data/selector";
|
||||
import { getDurationSelectorMode } from "../../data/selector";
|
||||
import type { ValueChangedEvent } from "../../types";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-select";
|
||||
import type { HaSelectSelectEvent } from "../ha-select";
|
||||
|
||||
type OffsetType = "none" | "before" | "after";
|
||||
|
||||
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
|
||||
{ value: "none", iconPath: mdiClockOutline },
|
||||
{ value: "before", iconPath: mdiClockMinusOutline },
|
||||
{ value: "after", iconPath: mdiClockPlusOutline },
|
||||
];
|
||||
|
||||
@customElement("ha-selector-duration")
|
||||
export class HaTimeDuration extends LitElement {
|
||||
@@ -20,57 +45,207 @@ export class HaTimeDuration extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@query("ha-duration-input", true) private _input?: HaDurationInput;
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@query("ha-duration-input") private _input?: HaDurationInput;
|
||||
|
||||
@state() private _offsetType?: OffsetType;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private _data = memoizeOne(
|
||||
(value?: HaDurationData | string | number): HaDurationData | undefined => {
|
||||
if (typeof value === "number") {
|
||||
return { seconds: value };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const negative = value.trim()[0] === "-";
|
||||
const parts = value
|
||||
.split(":")
|
||||
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
|
||||
private _data = memoizeOne(durationValueToData);
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { seconds: parts[0] };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { hours: parts[0], minutes: parts[1] };
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
hours: parts[0],
|
||||
minutes: parts[1],
|
||||
seconds: parts[2],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
private _offsetTypeOptions = memoizeOne((localize: LocalizeFunc) =>
|
||||
OFFSET_TYPES.map(({ value, iconPath }) => ({
|
||||
value,
|
||||
iconPath,
|
||||
label: localize(`ui.components.selectors.duration.offset.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const mode = getDurationSelectorMode(this.selector.duration);
|
||||
const data = this._data(this.value);
|
||||
|
||||
if (mode !== "offset") {
|
||||
return this._renderInput(
|
||||
data,
|
||||
this.label,
|
||||
this.helper,
|
||||
mode === "signed"
|
||||
);
|
||||
}
|
||||
|
||||
const offsetType = this._getOffsetType(data);
|
||||
return html`
|
||||
<div class="container">
|
||||
${
|
||||
this.label
|
||||
? html`<label id="label"
|
||||
>${this.label}${this.required ? "*" : ""}</label
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<div
|
||||
class="inputs"
|
||||
role="group"
|
||||
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
|
||||
>
|
||||
<ha-select
|
||||
aria-label=${ifDefined(this.label)}
|
||||
.value=${offsetType}
|
||||
.options=${this._offsetTypeOptions(this._localize)}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._offsetTypeChanged}
|
||||
></ha-select>
|
||||
${
|
||||
offsetType === "none"
|
||||
? nothing
|
||||
: html`<div
|
||||
class="value-row"
|
||||
role="group"
|
||||
aria-labelledby="duration-label"
|
||||
>
|
||||
<span id="duration-label" class="value-label"
|
||||
>${this._localize(
|
||||
"ui.components.selectors.duration.duration"
|
||||
)}${this.required ? "*" : ""}</span
|
||||
>
|
||||
${this._renderInput(
|
||||
data && this._components(data),
|
||||
undefined
|
||||
)}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderInput(
|
||||
data: HaDurationData | undefined,
|
||||
label: string | undefined,
|
||||
helper?: string,
|
||||
allowNegative = false
|
||||
) {
|
||||
return html`
|
||||
<ha-duration-input
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.data=${this._data(this.value)}
|
||||
.label=${label}
|
||||
.helper=${helper}
|
||||
.data=${data}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.enableDay=${this.selector.duration?.enable_day}
|
||||
.enableMillisecond=${this.selector.duration?.enable_millisecond}
|
||||
.allowNegative=${this.selector.duration?.allow_negative}
|
||||
.allowNegative=${allowNegative}
|
||||
.enableSecond=${this.selector.duration?.enable_second ?? true}
|
||||
@value-changed=${this._durationChanged}
|
||||
></ha-duration-input>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getOffsetType(data?: HaDurationData): OffsetType {
|
||||
if (!data) {
|
||||
return this._offsetType ?? "none";
|
||||
}
|
||||
const { negative, ...components } = normalizeDuration(data);
|
||||
if (durationDataToSeconds(components) === 0) {
|
||||
return this._offsetType ?? "none";
|
||||
}
|
||||
return negative ? "before" : "after";
|
||||
}
|
||||
|
||||
private _components(data: HaDurationData): HaDurationData {
|
||||
const { negative: _negative, ...components } = normalizeDuration(data);
|
||||
return components;
|
||||
}
|
||||
|
||||
private _zeroDuration(): HaDurationData {
|
||||
const config = this.selector.duration;
|
||||
const value: HaDurationData = { hours: 0, minutes: 0 };
|
||||
if (config?.enable_day) value.days = 0;
|
||||
if (config?.enable_second ?? true) value.seconds = 0;
|
||||
if (config?.enable_millisecond) value.milliseconds = 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
private _withOffsetType(
|
||||
type: OffsetType,
|
||||
data?: HaDurationData
|
||||
): HaDurationData {
|
||||
if (type === "none") {
|
||||
return this._zeroDuration();
|
||||
}
|
||||
const components = this._components(data ?? this._zeroDuration());
|
||||
return type === "before" ? { negative: true, ...components } : components;
|
||||
}
|
||||
|
||||
private _durationChanged(ev: ValueChangedEvent<HaDurationData | undefined>) {
|
||||
if (getDurationSelectorMode(this.selector.duration) !== "offset") {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
const type = this._getOffsetType(this._data(this.value));
|
||||
this._offsetType = type;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: this._withOffsetType(type, ev.detail.value),
|
||||
});
|
||||
}
|
||||
|
||||
private _offsetTypeChanged(ev: HaSelectSelectEvent<OffsetType>) {
|
||||
ev.stopPropagation();
|
||||
const type = ev.detail.value;
|
||||
const data = this._data(this.value);
|
||||
if (!type || type === this._getOffsetType(data)) {
|
||||
return;
|
||||
}
|
||||
this._offsetType = type;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: this._withOffsetType(type, data),
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: var(--ha-font-size-s);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
color: var(--ha-color-text-primary);
|
||||
padding-inline-start: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.inputs,
|
||||
.value-row {
|
||||
--ha-input-padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-select {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -72,8 +72,13 @@ const SELECTOR_SCHEMAS = {
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "allow_negative",
|
||||
selector: { boolean: {} },
|
||||
name: "mode",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["positive", "signed", "offset"],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const,
|
||||
entity: [
|
||||
@@ -253,6 +258,13 @@ export class HaSelectorSelector extends LitElement {
|
||||
type,
|
||||
...(typeof value0 === "object" ? value0 : []),
|
||||
};
|
||||
if (
|
||||
type === "duration" &&
|
||||
data.mode === undefined &&
|
||||
data.allow_negative
|
||||
) {
|
||||
data.mode = "signed";
|
||||
}
|
||||
}
|
||||
|
||||
const schema = this._schema(type, this._localize!);
|
||||
@@ -293,6 +305,9 @@ export class HaSelectorSelector extends LitElement {
|
||||
this._yamlMode = false;
|
||||
}
|
||||
delete value.type;
|
||||
if (type === "duration" && value.mode !== undefined) {
|
||||
delete value.allow_negative;
|
||||
}
|
||||
|
||||
let newValue;
|
||||
if (type === "manual") {
|
||||
|
||||
@@ -77,6 +77,7 @@ export type InputType =
|
||||
* @attr {boolean} invalid - Marks the input as invalid.
|
||||
* @attr {boolean} inset-label - Uses an inset label style where the label stays inside the input.
|
||||
* @attr {string} validation-message - Custom validation message shown when the input is invalid.
|
||||
* @attr {string} input-id - Sets the native input's `id` (and associated label `for`) on the inner `wa-input`.
|
||||
*/
|
||||
@customElement("ha-input")
|
||||
export class HaInput extends WaInputMixin(LitElement) {
|
||||
@@ -124,6 +125,10 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
@property({ type: Boolean, attribute: "inset-label" })
|
||||
public insetLabel = false;
|
||||
|
||||
/** Sets the native input id (and label `for`) on the inner `wa-input`. */
|
||||
@property({ attribute: "input-id" })
|
||||
public inputId?: string;
|
||||
|
||||
@query("wa-input")
|
||||
private _input?: WaInput;
|
||||
|
||||
@@ -160,6 +165,39 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
this._input?.stepDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a native input value that was written without `input`/`change` events
|
||||
* (password-manager autofill) into the component so labels and form data update.
|
||||
*/
|
||||
public syncFromNativeInput(): boolean {
|
||||
const native = this._getNativeInput();
|
||||
if (!native) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nativeValue = native.value;
|
||||
if ((this.value ?? "") === nativeValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._input) {
|
||||
this._input.value = nativeValue;
|
||||
}
|
||||
this._handleInput();
|
||||
this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
public override reportValidity(): boolean {
|
||||
this.syncFromNativeInput();
|
||||
return super.reportValidity();
|
||||
}
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("focusin", this._syncFromNativeInput);
|
||||
}
|
||||
|
||||
protected override async firstUpdated(
|
||||
changedProperties: PropertyValues<this>
|
||||
): Promise<void> {
|
||||
@@ -175,6 +213,7 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("focusin", this._syncFromNativeInput);
|
||||
this._startSlotResizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
@@ -215,6 +254,7 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
.inputmode=${this.inputmode || undefined}
|
||||
.name=${this.name}
|
||||
.disabled=${this.disabled}
|
||||
.inputId=${this.inputId || "input"}
|
||||
class=${classMap({
|
||||
input: true,
|
||||
invalid: this.invalid || this._invalid,
|
||||
@@ -346,6 +386,14 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
this.passwordVisible = !this.passwordVisible;
|
||||
}
|
||||
|
||||
private _getNativeInput(): HTMLInputElement | undefined {
|
||||
return this._input?.input ?? undefined;
|
||||
}
|
||||
|
||||
private _syncFromNativeInput = (): void => {
|
||||
this.syncFromNativeInput();
|
||||
};
|
||||
|
||||
static styles = [
|
||||
waInputStyles,
|
||||
css`
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { getDeviceAreaId } from "../../common/entity/context/get_device_context";
|
||||
import type {
|
||||
ExtractFromTargetResultReferenced,
|
||||
TargetType,
|
||||
} from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export interface TargetSubRows {
|
||||
nextType: TargetType;
|
||||
rows: string[];
|
||||
rowEntries?: ExtractFromTargetResultReferenced[];
|
||||
deviceRows: string[];
|
||||
deviceRowEntries?: ExtractFromTargetResultReferenced[];
|
||||
entityRows: string[];
|
||||
}
|
||||
|
||||
const emptyEntries = (): ExtractFromTargetResultReferenced => ({
|
||||
referenced_areas: [],
|
||||
referenced_devices: [],
|
||||
referenced_entities: [],
|
||||
});
|
||||
|
||||
export const computeTargetSubRows = (
|
||||
type: TargetType,
|
||||
itemId: string,
|
||||
entries: ExtractFromTargetResultReferenced,
|
||||
entityRegistry: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"]
|
||||
): TargetSubRows => {
|
||||
let nextType: TargetType =
|
||||
type === "floor" ? "area" : type === "area" ? "device" : "entity";
|
||||
|
||||
if (type === "label") {
|
||||
if (entries.referenced_areas.length) {
|
||||
nextType = "area";
|
||||
} else if (entries.referenced_devices.length) {
|
||||
nextType = "device";
|
||||
}
|
||||
}
|
||||
|
||||
const deviceOf = (entityId: string) => entityRegistry[entityId]?.device_id;
|
||||
const parentOf = (deviceId: string) => devices[deviceId]?.parent_device_id;
|
||||
// An entity belongs to a device row when it is on that device or on one of
|
||||
// its child devices, so the row can nest the children below it.
|
||||
const belongsTo = (entityId: string, deviceId: string) => {
|
||||
const entityDevice = deviceOf(entityId);
|
||||
return (
|
||||
entityDevice === deviceId ||
|
||||
(!!entityDevice && parentOf(entityDevice) === deviceId)
|
||||
);
|
||||
};
|
||||
const entitiesOf = (deviceId: string) =>
|
||||
entries.referenced_entities.filter((entityId) =>
|
||||
belongsTo(entityId, deviceId)
|
||||
);
|
||||
const rootsOf = (deviceIds: string[]) =>
|
||||
deviceIds.filter((deviceId) => {
|
||||
const parentId = parentOf(deviceId);
|
||||
return !parentId || !deviceIds.includes(parentId);
|
||||
});
|
||||
|
||||
const childDevices =
|
||||
type === "device"
|
||||
? [
|
||||
...new Set(
|
||||
entries.referenced_entities
|
||||
.map(deviceOf)
|
||||
.filter(
|
||||
(deviceId): deviceId is string =>
|
||||
!!deviceId &&
|
||||
deviceId !== itemId &&
|
||||
parentOf(deviceId) === itemId
|
||||
)
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
const rows =
|
||||
nextType === "area"
|
||||
? entries.referenced_areas
|
||||
: nextType === "device" && type !== "label"
|
||||
? rootsOf(entries.referenced_devices)
|
||||
: type === "device"
|
||||
? entries.referenced_entities.filter(
|
||||
(entityId) => !childDevices.includes(deviceOf(entityId) || "")
|
||||
)
|
||||
: type !== "label"
|
||||
? entries.referenced_entities
|
||||
: [];
|
||||
|
||||
const devicesInAreas: string[] = [];
|
||||
|
||||
const rowEntries =
|
||||
nextType === "entity"
|
||||
? undefined
|
||||
: rows.map((rowItem) => {
|
||||
const nextEntries = emptyEntries();
|
||||
|
||||
if (nextType === "area") {
|
||||
const areaDevices = entries.referenced_devices.filter(
|
||||
(deviceId) => {
|
||||
const device = devices[deviceId];
|
||||
return (
|
||||
!!device &&
|
||||
getDeviceAreaId(device, devices) === rowItem &&
|
||||
entries.referenced_entities.some((entityId) =>
|
||||
belongsTo(entityId, deviceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
devicesInAreas.push(...areaDevices);
|
||||
|
||||
nextEntries.referenced_devices = rootsOf(areaDevices);
|
||||
nextEntries.referenced_entities =
|
||||
entries.referenced_entities.filter((entityId) => {
|
||||
const entity = entityRegistry[entityId];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.area_id === rowItem ||
|
||||
!entity.device_id ||
|
||||
areaDevices.includes(entity.device_id)
|
||||
);
|
||||
});
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
nextEntries.referenced_entities = entitiesOf(rowItem);
|
||||
|
||||
return nextEntries;
|
||||
});
|
||||
|
||||
const entityRows =
|
||||
type === "label"
|
||||
? entries.referenced_entities.filter((entityId) => {
|
||||
const entity = entityRegistry[entityId];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.labels.includes(itemId) &&
|
||||
!entries.referenced_devices.includes(entity.device_id || "")
|
||||
);
|
||||
})
|
||||
: nextType === "device"
|
||||
? entries.referenced_entities.filter(
|
||||
(entityId) => entityRegistry[entityId]?.area_id === itemId
|
||||
)
|
||||
: [];
|
||||
|
||||
const deviceRows =
|
||||
type === "label"
|
||||
? rootsOf(
|
||||
entries.referenced_devices.filter(
|
||||
(deviceId) =>
|
||||
!devicesInAreas.includes(deviceId) &&
|
||||
devices[deviceId]?.labels.includes(itemId)
|
||||
)
|
||||
)
|
||||
: childDevices;
|
||||
|
||||
const deviceRowEntries = deviceRows.length
|
||||
? deviceRows.map((deviceId) => ({
|
||||
...emptyEntries(),
|
||||
referenced_entities: entitiesOf(deviceId),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
nextType,
|
||||
rows,
|
||||
rowEntries,
|
||||
deviceRows,
|
||||
deviceRowEntries,
|
||||
entityRows,
|
||||
};
|
||||
};
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
} from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import {
|
||||
getDeviceArea,
|
||||
getDeviceAreaId,
|
||||
} from "../../common/entity/context/get_device_context";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { AreaRegistryEntry } from "../../data/area/area_registry";
|
||||
@@ -63,6 +66,7 @@ import "../ha-state-icon";
|
||||
import "../ha-svg-icon";
|
||||
import "../item/ha-list-item-base";
|
||||
import "../item/ha-list-item-button";
|
||||
import { computeTargetSubRows } from "./compute-target-sub-rows";
|
||||
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
|
||||
|
||||
@customElement("ha-target-picker-item-row")
|
||||
@@ -401,125 +405,25 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
return this._renderEmptyEntries();
|
||||
}
|
||||
|
||||
let nextType: TargetType =
|
||||
this.type === "floor"
|
||||
? "area"
|
||||
: this.type === "area"
|
||||
? "device"
|
||||
: "entity";
|
||||
|
||||
if (this.type === "label") {
|
||||
if (entries?.referenced_areas.length) {
|
||||
nextType = "area";
|
||||
} else if (entries?.referenced_devices.length) {
|
||||
nextType = "device";
|
||||
}
|
||||
}
|
||||
|
||||
const rows1 =
|
||||
(nextType === "area"
|
||||
? entries?.referenced_areas
|
||||
: nextType === "device" && this.type !== "label"
|
||||
? entries?.referenced_devices
|
||||
: this.type !== "label"
|
||||
? entries?.referenced_entities
|
||||
: []) || [];
|
||||
|
||||
const devicesInAreas = [] as string[];
|
||||
|
||||
const rows1Entries =
|
||||
nextType === "entity"
|
||||
? undefined
|
||||
: rows1.map((rowItem) => {
|
||||
const nextEntries = {
|
||||
referenced_areas: [] as string[],
|
||||
referenced_devices: [] as string[],
|
||||
referenced_entities: [] as string[],
|
||||
};
|
||||
|
||||
if (nextType === "area") {
|
||||
nextEntries.referenced_devices =
|
||||
entries?.referenced_devices.filter(
|
||||
(device_id) =>
|
||||
this.hass.devices?.[device_id]?.area_id === rowItem &&
|
||||
entries?.referenced_entities.some(
|
||||
(entity_id) =>
|
||||
this.hass.entities?.[entity_id]?.device_id === device_id
|
||||
)
|
||||
) || ([] as string[]);
|
||||
|
||||
devicesInAreas.push(...nextEntries.referenced_devices);
|
||||
|
||||
nextEntries.referenced_entities =
|
||||
entries?.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.area_id === rowItem ||
|
||||
!entity.device_id ||
|
||||
nextEntries.referenced_devices.includes(entity.device_id)
|
||||
);
|
||||
}) || ([] as string[]);
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
nextEntries.referenced_entities =
|
||||
entries?.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities?.[entity_id]?.device_id === rowItem
|
||||
) || ([] as string[]);
|
||||
|
||||
return nextEntries;
|
||||
});
|
||||
|
||||
const entityRows =
|
||||
this.type === "label" && entries
|
||||
? entries.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.labels.includes(this.itemId) &&
|
||||
!entries.referenced_devices.includes(entity.device_id || "")
|
||||
);
|
||||
})
|
||||
: nextType === "device" && entries
|
||||
? entries.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities[entity_id]?.area_id === this.itemId
|
||||
)
|
||||
: [];
|
||||
|
||||
const deviceRows =
|
||||
this.type === "label" && entries
|
||||
? entries.referenced_devices.filter(
|
||||
(device_id) =>
|
||||
!devicesInAreas.includes(device_id) &&
|
||||
this.hass.devices[device_id]?.labels.includes(this.itemId)
|
||||
)
|
||||
: [];
|
||||
|
||||
const deviceRowsEntries =
|
||||
deviceRows.length === 0
|
||||
? undefined
|
||||
: deviceRows.map((device_id) => ({
|
||||
referenced_areas: [] as string[],
|
||||
referenced_devices: [] as string[],
|
||||
referenced_entities:
|
||||
entries?.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities?.[entity_id]?.device_id === device_id
|
||||
) || ([] as string[]),
|
||||
}));
|
||||
const {
|
||||
nextType,
|
||||
rows,
|
||||
rowEntries,
|
||||
deviceRows,
|
||||
deviceRowEntries,
|
||||
entityRows,
|
||||
} = computeTargetSubRows(
|
||||
this.type,
|
||||
this.itemId,
|
||||
entries,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
);
|
||||
|
||||
const nextSubLevel = this.subLevel + 1;
|
||||
|
||||
return html`
|
||||
${rows1.map(
|
||||
${rows.map(
|
||||
(itemId, index) => html`
|
||||
<ha-target-picker-item-row
|
||||
sub-entry
|
||||
@@ -528,7 +432,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.type=${nextType}
|
||||
.itemId=${itemId}
|
||||
.parentEntries=${rows1Entries?.[index]}
|
||||
.parentEntries=${rowEntries?.[index]}
|
||||
.hideContext=${this.hideContext || this.type !== "label"}
|
||||
expand
|
||||
></ha-target-picker-item-row>
|
||||
@@ -543,7 +447,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
.hass=${this.hass}
|
||||
type="device"
|
||||
.itemId=${itemId}
|
||||
.parentEntries=${deviceRowsEntries?.[index]}
|
||||
.parentEntries=${deviceRowEntries?.[index]}
|
||||
.hideContext=${this.hideContext || this.type !== "label"}
|
||||
expand
|
||||
></ha-target-picker-item-row>
|
||||
@@ -624,32 +528,53 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
|
||||
let referencedDevices = entries.referenced_devices;
|
||||
const hiddenDeviceIds: string[] = [];
|
||||
// Parents kept only so a matching child device can nest under them;
|
||||
// their own entities stay filtered out like a hidden device's.
|
||||
const groupOnlyDeviceIds = new Set<string>();
|
||||
if (
|
||||
this.type === "floor" ||
|
||||
this.type === "area" ||
|
||||
this.type === "label"
|
||||
) {
|
||||
const matchingDeviceIds = new Set(
|
||||
referencedDevices.filter((device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
return (
|
||||
!!device &&
|
||||
!hiddenAreaIds.includes(
|
||||
getDeviceAreaId(device, this.hass.devices) || ""
|
||||
) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
const parentsOfMatching = new Set(
|
||||
[...matchingDeviceIds].map(
|
||||
(device_id) => this.hass.devices[device_id].parent_device_id
|
||||
)
|
||||
);
|
||||
referencedDevices = referencedDevices.filter((device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, like the area filtering above.
|
||||
if (!this.hass.devices[device_id]) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
if (matchingDeviceIds.has(device_id)) {
|
||||
return true;
|
||||
}
|
||||
if (parentsOfMatching.has(device_id)) {
|
||||
groupOnlyDeviceIds.add(device_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
return false;
|
||||
});
|
||||
@@ -663,7 +588,10 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
if (hiddenDeviceIds.includes(entity.device_id || "")) {
|
||||
if (
|
||||
hiddenDeviceIds.includes(entity.device_id || "") ||
|
||||
groupOnlyDeviceIds.has(entity.device_id || "")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface BlueprintAutomationConfig extends ManualAutomationConfig {
|
||||
}
|
||||
|
||||
export interface ForDict {
|
||||
negative?: boolean;
|
||||
days?: number;
|
||||
hours?: number;
|
||||
minutes?: number;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
} from "../common/datetime/format_time";
|
||||
import { normalizeDuration } from "../common/datetime/normalize_duration";
|
||||
import secondsToDuration from "../common/datetime/seconds_to_duration";
|
||||
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
@@ -871,8 +872,9 @@ const formatSunOffset = (
|
||||
return offset;
|
||||
}
|
||||
try {
|
||||
const formatted = formatDurationDigital(hass.locale, offset);
|
||||
return formatted.startsWith("-") ? formatted : `+${formatted}`;
|
||||
const { negative, ...components } = normalizeDuration(offset);
|
||||
const formatted = formatDurationDigital(hass.locale, components);
|
||||
return `${negative ? "-" : "+"}${formatted}`;
|
||||
} catch (_e) {
|
||||
return JSON.stringify(offset);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export type EntityIdPart = "area" | "device" | "entity" | "floor";
|
||||
import type { EntityNameType } from "../common/entity/compute_entity_name_display";
|
||||
|
||||
export type EntityIdPart = EntityNameType;
|
||||
|
||||
export type EntityIdFormat = EntityIdPart[];
|
||||
|
||||
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
|
||||
"area",
|
||||
"parent_device",
|
||||
"device",
|
||||
"entity",
|
||||
];
|
||||
|
||||
@@ -5,12 +5,13 @@ import type {
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
|
||||
export type LawnMowerEntityState =
|
||||
"paused" | "mowing" | "returning" | "docked" | "error";
|
||||
"paused" | "mowing" | "returning" | "docked" | "idle" | "error";
|
||||
|
||||
export enum LawnMowerEntityFeature {
|
||||
START_MOWING = 1,
|
||||
PAUSE = 2,
|
||||
DOCK = 4,
|
||||
STOP = 8,
|
||||
}
|
||||
|
||||
interface LawnMowerEntityAttributes
|
||||
@@ -38,6 +39,13 @@ export function canPause(stateObj: LawnMowerEntity): boolean {
|
||||
return stateObj.state !== "paused";
|
||||
}
|
||||
|
||||
export function canStop(stateObj: LawnMowerEntity): boolean {
|
||||
if (stateObj.state === UNAVAILABLE) {
|
||||
return false;
|
||||
}
|
||||
return !["docked", "idle"].includes(stateObj.state);
|
||||
}
|
||||
|
||||
export function canDock(stateObj: LawnMowerEntity): boolean {
|
||||
if (stateObj.state === UNAVAILABLE) {
|
||||
return false;
|
||||
|
||||
@@ -69,6 +69,13 @@ export const isLocalMediaSourceContentId = (mediaId: string) =>
|
||||
export const isImageUploadMediaSourceContentId = (mediaId: string) =>
|
||||
mediaId.startsWith("media-source://image_upload");
|
||||
|
||||
export const getImageEntityIdFromMediaSourceContentId = (
|
||||
mediaId: string
|
||||
): string | undefined =>
|
||||
mediaId.startsWith("media-source://image/image.")
|
||||
? mediaId.slice("media-source://image/".length)
|
||||
: undefined;
|
||||
|
||||
export const uploadLocalMedia = async (
|
||||
hass: HomeAssistant,
|
||||
media_content_id: string,
|
||||
|
||||
@@ -266,15 +266,23 @@ export interface LegacyDeviceSelector {
|
||||
};
|
||||
}
|
||||
|
||||
export type DurationSelectorMode = "positive" | "signed" | "offset";
|
||||
|
||||
export interface DurationSelector {
|
||||
duration: {
|
||||
enable_day?: boolean;
|
||||
enable_millisecond?: boolean;
|
||||
allow_negative?: boolean;
|
||||
enable_second?: boolean;
|
||||
mode?: DurationSelectorMode;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const getDurationSelectorMode = (
|
||||
config: DurationSelector["duration"]
|
||||
): DurationSelectorMode =>
|
||||
config?.mode ?? (config?.allow_negative ? "signed" : "positive");
|
||||
|
||||
interface EntitySelectorFilter {
|
||||
integration?: string;
|
||||
domain?: string | readonly string[];
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import { normalizeDuration } from "../../common/datetime/normalize_duration";
|
||||
import { formatDurationLong } from "../../common/datetime/format_duration";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
import type { DurationSelector } from "../selector";
|
||||
import { getDurationSelectorMode } from "../selector";
|
||||
import type { FrontendLocaleData } from "../translation";
|
||||
|
||||
export const formatDurationSelectorValue = (
|
||||
localize: LocalizeFunc,
|
||||
locale: FrontendLocaleData,
|
||||
value: HaDurationData | string | number | undefined,
|
||||
config: DurationSelector["duration"],
|
||||
formatDuration: (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => string = formatDurationLong
|
||||
): string => {
|
||||
const data = durationValueToData(value);
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
const { negative, ...components } = normalizeDuration(data);
|
||||
const mode = getDurationSelectorMode(config);
|
||||
if (mode === "offset" && durationDataToSeconds(components) === 0) {
|
||||
return "";
|
||||
}
|
||||
const duration = formatDuration(locale, components);
|
||||
if (!duration || mode === "positive") {
|
||||
return duration;
|
||||
}
|
||||
const sign = negative ? "negative" : "positive";
|
||||
return localize(`ui.components.selectors.duration.summary.${mode}_${sign}`, {
|
||||
duration,
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_dis
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Selector } from "../selector";
|
||||
import { formatDurationSelectorValue } from "./format_duration_selector_value";
|
||||
|
||||
export const formatSelectorValue = (
|
||||
hass: HomeAssistant,
|
||||
@@ -102,6 +103,13 @@ export const formatSelectorValue = (
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
if ("media" in selector) {
|
||||
const media = ensureArray(value);
|
||||
return media
|
||||
.map((item) => item.metadata?.title || item.media_content_id)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
if ("object" in selector) {
|
||||
const { fields } = selector.object ?? {};
|
||||
const items = ensureArray(value);
|
||||
@@ -123,6 +131,15 @@ export const formatSelectorValue = (
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
if ("duration" in selector) {
|
||||
return formatDurationSelectorValue(
|
||||
hass.localize,
|
||||
hass.locale,
|
||||
value,
|
||||
selector.duration
|
||||
);
|
||||
}
|
||||
|
||||
return ensureArray(value)
|
||||
.map((v) =>
|
||||
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiHomeImportOutline, mdiPause, mdiPlay } from "@mdi/js";
|
||||
import { mdiHomeImportOutline, mdiPause, mdiPlay, mdiStop } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
LawnMowerEntityFeature,
|
||||
canDock,
|
||||
canStartMowing,
|
||||
canStop,
|
||||
isMowing,
|
||||
} from "../../../data/lawn_mower";
|
||||
import "../../../state-control/lawn_mower/ha-state-control-lawn_mower-status";
|
||||
@@ -173,6 +174,14 @@ class MoreInfoLawnMower extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleStop() {
|
||||
if (!this.stateObj) return;
|
||||
forwardHaptic(this, "light");
|
||||
this._api.callService("lawn_mower", "stop", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleDock() {
|
||||
if (!this.stateObj) return;
|
||||
forwardHaptic(this, "light");
|
||||
@@ -188,9 +197,11 @@ class MoreInfoLawnMower extends LitElement {
|
||||
|
||||
const stateObj = this.stateObj;
|
||||
const isUnavailable = stateObj.state === UNAVAILABLE;
|
||||
const supportsStop = supportsFeature(stateObj, LawnMowerEntityFeature.STOP);
|
||||
const supportsDock = supportsFeature(stateObj, LawnMowerEntityFeature.DOCK);
|
||||
|
||||
const hasAnyCommand = this._supportsStartPause || supportsDock;
|
||||
const hasAnyCommand =
|
||||
this._supportsStartPause || supportsStop || supportsDock;
|
||||
|
||||
return html`
|
||||
<ha-more-info-state-header .stateObj=${this.stateObj}>
|
||||
@@ -222,6 +233,21 @@ class MoreInfoLawnMower extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
supportsStop
|
||||
? html`
|
||||
<ha-control-button
|
||||
.label=${this._i18n.localize(
|
||||
"ui.dialogs.more_info_control.lawn_mower.stop"
|
||||
)}
|
||||
@click=${this._handleStop}
|
||||
.disabled=${isUnavailable || !canStop(stateObj)}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiStop}></ha-svg-icon>
|
||||
</ha-control-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
supportsDock
|
||||
? html`
|
||||
|
||||
@@ -24,6 +24,11 @@ export class MockLawnMowerEntity extends MockBaseEntity {
|
||||
return;
|
||||
}
|
||||
|
||||
if (service === "stop") {
|
||||
this.update({ state: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (service === "dock") {
|
||||
this._transition("returning", "docked", TRANSITION_MS);
|
||||
return;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { buildLiteInternationalization } from "../common/translations/lite-internationalization";
|
||||
import {
|
||||
addSearchParam,
|
||||
extractSearchParam,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from "../common/url/search-params";
|
||||
import { subscribeOne } from "../common/util/subscribe-one";
|
||||
import "../components/ha-card";
|
||||
import "../components/progress/ha-progress-bar";
|
||||
import type { AuthUrlSearchParams } from "../data/auth";
|
||||
import { hassUrl } from "../data/auth";
|
||||
import { saveFrontendSystemData } from "../data/frontend";
|
||||
@@ -40,7 +42,6 @@ import { HassElement } from "../state/hass-element";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import { storeState } from "../util/ha-pref-storage";
|
||||
import { registerServiceWorker } from "../util/register-service-worker";
|
||||
import "../components/progress/ha-progress-bar";
|
||||
import "./onboarding-analytics";
|
||||
import "./onboarding-create-user";
|
||||
import "./onboarding-loading";
|
||||
@@ -165,9 +166,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
|
||||
}
|
||||
|
||||
if (this._init) {
|
||||
return html`<onboarding-welcome
|
||||
.localize=${this.localize}
|
||||
></onboarding-welcome>`;
|
||||
return html`<onboarding-welcome></onboarding-welcome>`;
|
||||
}
|
||||
|
||||
const step = this._curStep()!;
|
||||
@@ -230,6 +229,20 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
|
||||
import("../components/ha-language-picker");
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Before `hass` connects, feed the context providers from the lite localize
|
||||
// state so context-consuming components render on the onboarding screens.
|
||||
if (
|
||||
!this.hass &&
|
||||
(changedProps.has("localize") || changedProps.has("language"))
|
||||
) {
|
||||
this._provideLiteInternationalization(
|
||||
buildLiteInternationalization(this.language, this.localize)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_page")) {
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-icon-next";
|
||||
import "../components/item/ha-list-item-button";
|
||||
import "../components/list/ha-list-base";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { onBoardingStyles } from "./styles";
|
||||
|
||||
@customElement("onboarding-welcome")
|
||||
class OnboardingWelcome extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public localize!: LocalizeFunc;
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<h1>${this.localize("ui.panel.page-onboarding.welcome.header")}</h1>
|
||||
<p>${this.localize("ui.panel.page-onboarding.intro")}</p>
|
||||
<h1>${this._localize("ui.panel.page-onboarding.welcome.header")}</h1>
|
||||
<p>${this._localize("ui.panel.page-onboarding.intro")}</p>
|
||||
|
||||
<ha-button @click=${this._start} class="start">
|
||||
${this.localize("ui.panel.page-onboarding.welcome.start")}
|
||||
${this._localize("ui.panel.page-onboarding.welcome.start")}
|
||||
</ha-button>
|
||||
|
||||
<div class="divider">
|
||||
<wa-divider></wa-divider>
|
||||
<div>
|
||||
<span
|
||||
>${this.localize(
|
||||
>${this._localize(
|
||||
"ui.panel.page-onboarding.welcome.or_restore"
|
||||
)}</span
|
||||
>
|
||||
@@ -40,10 +40,10 @@ class OnboardingWelcome extends LitElement {
|
||||
<ha-list-base>
|
||||
<ha-list-item-button @click=${this._restoreBackupUpload}>
|
||||
<div slot="headline">
|
||||
${this.localize("ui.panel.page-onboarding.restore.upload_backup")}
|
||||
${this._localize("ui.panel.page-onboarding.restore.upload_backup")}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.localize(
|
||||
${this._localize(
|
||||
"ui.panel.page-onboarding.restore.options.upload_description"
|
||||
)}
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@ class OnboardingWelcome extends LitElement {
|
||||
<ha-list-item-button @click=${this._restoreBackupCloud}>
|
||||
<div slot="headline">Home Assistant Cloud</div>
|
||||
<div slot="supporting-text">
|
||||
${this.localize(
|
||||
${this._localize(
|
||||
"ui.panel.page-onboarding.restore.ha-cloud.description"
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -175,8 +175,6 @@ class SupervisorAppsCardContent extends LitElement {
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
.footer {
|
||||
border-top: var(--ha-border-width-sm) solid
|
||||
var(--ha-color-border-neutral-quiet);
|
||||
padding-top: var(--ha-space-2);
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
|
||||
+237
-186
@@ -20,6 +20,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
|
||||
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
|
||||
import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import "../../../../components/ha-floor-icon";
|
||||
import "../../../../components/ha-icon";
|
||||
@@ -30,10 +31,7 @@ import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/item/ha-list-item-button";
|
||||
import "../../../../components/item/ha-row-item";
|
||||
import "../../../../components/list/ha-list-base";
|
||||
import {
|
||||
getAreaDeviceLookup,
|
||||
getAreaEntityLookup,
|
||||
} from "../../../../data/area/area_registry";
|
||||
import { getAreaEntityLookup } from "../../../../data/area/area_registry";
|
||||
import {
|
||||
getAreasNestedInFloors,
|
||||
type AreaFloorValue,
|
||||
@@ -51,7 +49,10 @@ import {
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../../data/context";
|
||||
import { getDeviceEntityLookup } from "../../../../data/device/device_registry";
|
||||
import {
|
||||
getDeviceEntityLookup,
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../../../data/device/device_registry";
|
||||
import {
|
||||
domainToName,
|
||||
type DomainManifestLookup,
|
||||
@@ -83,6 +84,7 @@ interface Level2Entries {
|
||||
interface Level3Entries {
|
||||
open: boolean;
|
||||
entities: string[];
|
||||
devices?: Record<string, Level3Entries>;
|
||||
}
|
||||
|
||||
@customElement("ha-automation-add-from-target")
|
||||
@@ -281,23 +283,19 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
|
||||
if (valueId && valueType === "device") {
|
||||
const areaId = this._registries.devices[valueId]?.area_id;
|
||||
if (areaId) {
|
||||
const floorId = this._registries.areas[areaId]?.floor_id || "";
|
||||
const { entities } =
|
||||
entries[`floor${TARGET_SEPARATOR}${floorId}`].areas![
|
||||
`area${TARGET_SEPARATOR}${areaId}`
|
||||
].devices![valueId];
|
||||
|
||||
return entities.length ? this._renderEntities(entities) : nothing;
|
||||
const entry = this._getDeviceEntry(entries, valueId);
|
||||
if (!entry) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const device = this._registries.devices[valueId];
|
||||
const isService = device.entry_type === "service";
|
||||
const { entities } =
|
||||
entries[`${isService ? "service" : "area"}${TARGET_SEPARATOR}`]
|
||||
.devices![valueId];
|
||||
return entities.length ? this._renderEntities(entities) : nothing;
|
||||
const numberOfDevices = Object.keys(entry.devices ?? {}).length;
|
||||
return html`
|
||||
${numberOfDevices ? this._renderDevices(entry.devices!) : nothing}
|
||||
${
|
||||
entry.entities.length
|
||||
? this._renderEntities(entry.entities)
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (valueType === "device" || valueType === "helper") {
|
||||
@@ -680,7 +678,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
stringCompare(deviceNameA, deviceNameB, this.hass.locale.language)
|
||||
)
|
||||
.map(([deviceId, deviceName, domain]) => {
|
||||
const { open, entities } = devices[deviceId];
|
||||
const { open, entities, devices: children } = devices[deviceId];
|
||||
const numberOfChildren = Object.keys(children ?? {}).length;
|
||||
|
||||
return this._renderItem(
|
||||
deviceName || deviceId,
|
||||
@@ -688,10 +687,15 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
false,
|
||||
this._getSelectedTargetId(this.value) ===
|
||||
`device${TARGET_SEPARATOR}${deviceId}`,
|
||||
!open && !!entities.length,
|
||||
!open && !!(entities.length || numberOfChildren),
|
||||
open,
|
||||
domain ? this._renderDomainIcon(domain) : undefined,
|
||||
open ? this._renderEntities(entities) : undefined
|
||||
open
|
||||
? html`
|
||||
${numberOfChildren ? this._renderDevices(children!) : nothing}
|
||||
${this._renderEntities(entities)}
|
||||
`
|
||||
: undefined
|
||||
);
|
||||
});
|
||||
|
||||
@@ -899,8 +903,16 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
// #region memoized data helpers
|
||||
|
||||
private _getAreaDeviceLookupMemoized = memoizeOne(
|
||||
(devices: HomeAssistant["devices"]) =>
|
||||
getAreaDeviceLookup(Object.values(devices))
|
||||
(devices: HomeAssistant["devices"]) => {
|
||||
const lookup: Record<string, DeviceRegistryEntry[]> = {};
|
||||
for (const device of Object.values(devices)) {
|
||||
const areaId = getDeviceAreaId(device, devices);
|
||||
if (areaId) {
|
||||
(lookup[areaId] ??= []).push(device);
|
||||
}
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
);
|
||||
|
||||
private _getAreaEntityLookupMemoized = memoizeOne(
|
||||
@@ -974,32 +986,18 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
|
||||
private _loadUnassignedDevices() {
|
||||
const unassignedDevices = Object.values(this._registries.devices).filter(
|
||||
(device) => !device.area_id
|
||||
(device) =>
|
||||
!device.disabled_by &&
|
||||
!getDeviceAreaId(device, this._registries.devices)
|
||||
);
|
||||
|
||||
const devices: Record<string, Level3Entries> = {};
|
||||
const devices = this._buildDeviceEntries(
|
||||
unassignedDevices.filter((device) => device.entry_type !== "service")
|
||||
);
|
||||
|
||||
const services: Record<string, Level3Entries> = {};
|
||||
|
||||
unassignedDevices.forEach(({ id: deviceId, entry_type }) => {
|
||||
const device = this._registries.devices[deviceId];
|
||||
if (!device || device.disabled_by) {
|
||||
return;
|
||||
}
|
||||
const deviceEntry = {
|
||||
open: false,
|
||||
entities:
|
||||
this._getDeviceEntityLookupMemoized(this._registries.entities)[
|
||||
deviceId
|
||||
]?.map((entity) => entity.entity_id) || [],
|
||||
};
|
||||
if (entry_type === "service") {
|
||||
services[deviceId] = deviceEntry;
|
||||
return;
|
||||
}
|
||||
|
||||
devices[deviceId] = deviceEntry;
|
||||
});
|
||||
const services = this._buildDeviceEntries(
|
||||
unassignedDevices.filter((device) => device.entry_type === "service")
|
||||
);
|
||||
|
||||
if (Object.keys(devices).length) {
|
||||
this._entries = {
|
||||
@@ -1079,31 +1077,21 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
|
||||
private _loadArea(area: FloorComboBoxItem) {
|
||||
const [, id] = area.id.split(TARGET_SEPARATOR, 2);
|
||||
const referenced_devices =
|
||||
this._getAreaDeviceLookupMemoized(this._registries.devices)[id] || [];
|
||||
const referenced_devices = (
|
||||
this._getAreaDeviceLookupMemoized(this._registries.devices)[id] || []
|
||||
).filter((device) => !device.disabled_by);
|
||||
const referenced_entities =
|
||||
this._getAreaEntityLookupMemoized(this._registries.entities)[id] || [];
|
||||
|
||||
const devices: Record<string, Level3Entries> = {};
|
||||
|
||||
referenced_devices.forEach(({ id: deviceId }) => {
|
||||
const device = this._registries.devices[deviceId];
|
||||
if (!device || device.disabled_by) {
|
||||
return;
|
||||
}
|
||||
devices[deviceId] = {
|
||||
open: false,
|
||||
entities:
|
||||
this._getDeviceEntityLookupMemoized(this._registries.entities)[
|
||||
deviceId
|
||||
]?.map((entity) => entity.entity_id) || [],
|
||||
};
|
||||
});
|
||||
const devices = this._buildDeviceEntries(referenced_devices);
|
||||
const areaDeviceIds = new Set(
|
||||
referenced_devices.map((device) => device.id)
|
||||
);
|
||||
|
||||
const entities: string[] = [];
|
||||
|
||||
referenced_entities.forEach((entity) => {
|
||||
if (!entity.device_id || !devices[entity.device_id]) {
|
||||
if (!entity.device_id || !areaDeviceIds.has(entity.device_id)) {
|
||||
entities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
@@ -1115,6 +1103,160 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _buildDeviceEntries(
|
||||
devices: DeviceRegistryEntry[]
|
||||
): Record<string, Level3Entries> {
|
||||
const deviceEntityLookup = this._getDeviceEntityLookupMemoized(
|
||||
this._registries.entities
|
||||
);
|
||||
const entryFor = (deviceId: string): Level3Entries => ({
|
||||
open: false,
|
||||
entities:
|
||||
deviceEntityLookup[deviceId]?.map((entity) => entity.entity_id) || [],
|
||||
});
|
||||
|
||||
const deviceIds = new Set(devices.map((device) => device.id));
|
||||
const nested = (device: DeviceRegistryEntry) =>
|
||||
!!device.parent_device_id && deviceIds.has(device.parent_device_id);
|
||||
|
||||
const roots = devices.filter((device) => !nested(device));
|
||||
const children = devices.filter(nested);
|
||||
|
||||
const entries: Record<string, Level3Entries> = {};
|
||||
for (const device of roots) {
|
||||
entries[device.id] = entryFor(device.id);
|
||||
}
|
||||
for (const device of children) {
|
||||
(entries[device.parent_device_id!].devices ??= {})[device.id] = entryFor(
|
||||
device.id
|
||||
);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private _deviceGroupPath(
|
||||
device: DeviceRegistryEntry
|
||||
): [level1: string, level2?: string] {
|
||||
const areaId = getDeviceAreaId(device, this._registries.devices);
|
||||
if (areaId) {
|
||||
return [
|
||||
`floor${TARGET_SEPARATOR}${this._registries.areas[areaId]?.floor_id || ""}`,
|
||||
`area${TARGET_SEPARATOR}${areaId}`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`${device.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`,
|
||||
];
|
||||
}
|
||||
|
||||
private _deviceGroup(
|
||||
entries: Record<string, Level1Entries>,
|
||||
device: DeviceRegistryEntry
|
||||
): Record<string, Level3Entries> | undefined {
|
||||
const [level1, level2] = this._deviceGroupPath(device);
|
||||
const entry = entries[level1];
|
||||
return level2 ? entry?.areas?.[level2]?.devices : entry?.devices;
|
||||
}
|
||||
|
||||
private _deviceChain(
|
||||
group: Record<string, Level3Entries> | undefined,
|
||||
device: DeviceRegistryEntry
|
||||
): string[] {
|
||||
const parentId = device.parent_device_id;
|
||||
return parentId && group?.[parentId]?.devices?.[device.id]
|
||||
? [parentId, device.id]
|
||||
: [device.id];
|
||||
}
|
||||
|
||||
private _getDeviceEntry(
|
||||
entries: Record<string, Level1Entries>,
|
||||
deviceId: string
|
||||
): Level3Entries | undefined {
|
||||
const device = this._registries.devices[deviceId];
|
||||
if (!device) {
|
||||
return undefined;
|
||||
}
|
||||
const group = this._deviceGroup(entries, device);
|
||||
let level = group;
|
||||
let entry: Level3Entries | undefined;
|
||||
for (const key of this._deviceChain(group, device)) {
|
||||
entry = level?.[key];
|
||||
level = entry?.devices;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private _setDeviceOpen(
|
||||
deviceId: string,
|
||||
deviceOpen: boolean | undefined,
|
||||
openAncestors: boolean
|
||||
) {
|
||||
const device = this._registries.devices[deviceId];
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const [level1, level2] = this._deviceGroupPath(device);
|
||||
const group = this._deviceGroup(this._entries, device);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
const chain = this._deviceChain(group, device);
|
||||
|
||||
const updateGroup = (
|
||||
devices: Record<string, Level3Entries>,
|
||||
[key, ...rest]: string[]
|
||||
): Record<string, Level3Entries> => {
|
||||
const entry = devices[key];
|
||||
if (!entry) {
|
||||
return devices;
|
||||
}
|
||||
const isTarget = !rest.length;
|
||||
return {
|
||||
...devices,
|
||||
[key]: {
|
||||
...entry,
|
||||
open: isTarget
|
||||
? (deviceOpen ?? entry.open)
|
||||
: openAncestors || entry.open,
|
||||
devices:
|
||||
isTarget || !entry.devices
|
||||
? entry.devices
|
||||
: updateGroup(entry.devices, rest),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const level1Entry = this._entries[level1];
|
||||
if (!level2) {
|
||||
this._entries = {
|
||||
...this._entries,
|
||||
[level1]: {
|
||||
...level1Entry,
|
||||
open: openAncestors || level1Entry.open,
|
||||
devices: updateGroup(group, chain),
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const level2Entry = level1Entry.areas![level2];
|
||||
this._entries = {
|
||||
...this._entries,
|
||||
[level1]: {
|
||||
...level1Entry,
|
||||
open: openAncestors || level1Entry.open,
|
||||
areas: {
|
||||
...level1Entry.areas,
|
||||
[level2]: {
|
||||
...level2Entry,
|
||||
open: openAncestors || level2Entry.open,
|
||||
devices: updateGroup(group, chain),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _expandTreeToItem(type: string, id: string) {
|
||||
if (type === "floor" || type === "label") {
|
||||
return;
|
||||
@@ -1122,39 +1264,26 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
|
||||
if (type === "entity") {
|
||||
const deviceId = this._registries.entities[id]?.device_id;
|
||||
const device = deviceId ? this._registries.devices[deviceId] : undefined;
|
||||
const deviceAreaId = (deviceId && device?.area_id) || undefined;
|
||||
if (deviceId && this._registries.devices[deviceId]) {
|
||||
this._setDeviceOpen(deviceId, true, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deviceAreaId) {
|
||||
let floor: string;
|
||||
let area: string;
|
||||
const entity = this._registries.entities[id];
|
||||
const entity = this._registries.entities[id];
|
||||
|
||||
if (!deviceId && entity.area_id) {
|
||||
floor = `floor${TARGET_SEPARATOR}${this._registries.areas[entity.area_id]?.floor_id || ""}`;
|
||||
area = `area${TARGET_SEPARATOR}${entity.area_id}`;
|
||||
} else if (!deviceId) {
|
||||
const domain = id.split(".", 1)[0];
|
||||
const isHelper =
|
||||
this.manifests![domain]?.integration_type === "helper";
|
||||
|
||||
floor = isHelper
|
||||
? `helper${TARGET_SEPARATOR}`
|
||||
: `device${TARGET_SEPARATOR}`;
|
||||
area = `${isHelper ? "helper_" : "entity_"}${domain}${TARGET_SEPARATOR}`;
|
||||
} else {
|
||||
floor = `${device!.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`;
|
||||
area = deviceId;
|
||||
}
|
||||
if (entity?.area_id) {
|
||||
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[entity.area_id]?.floor_id || ""}`;
|
||||
const area = `area${TARGET_SEPARATOR}${entity.area_id}`;
|
||||
const floorEntry = this._entries[floor];
|
||||
this._entries = {
|
||||
...this._entries,
|
||||
[floor]: {
|
||||
...this._entries[floor],
|
||||
...floorEntry,
|
||||
open: true,
|
||||
devices: {
|
||||
...this._entries[floor].devices!,
|
||||
areas: {
|
||||
...floorEntry.areas,
|
||||
[area]: {
|
||||
...this._entries[floor].devices![area],
|
||||
...floorEntry.areas![area],
|
||||
open: true,
|
||||
},
|
||||
},
|
||||
@@ -1163,26 +1292,23 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[deviceAreaId]?.floor_id || ""}`;
|
||||
const area = `area${TARGET_SEPARATOR}${deviceAreaId}`;
|
||||
|
||||
const domain = id.split(".", 1)[0];
|
||||
const isHelper = this.manifests![domain]?.integration_type === "helper";
|
||||
const group = isHelper
|
||||
? `helper${TARGET_SEPARATOR}`
|
||||
: `device${TARGET_SEPARATOR}`;
|
||||
const domainGroup = `${isHelper ? "helper_" : "entity_"}${domain}${TARGET_SEPARATOR}`;
|
||||
const groupEntry = this._entries[group];
|
||||
this._entries = {
|
||||
...this._entries,
|
||||
[floor]: {
|
||||
...this._entries[floor],
|
||||
[group]: {
|
||||
...groupEntry,
|
||||
open: true,
|
||||
areas: {
|
||||
...this._entries[floor].areas!,
|
||||
[area]: {
|
||||
...this._entries[floor].areas![area],
|
||||
devices: {
|
||||
...groupEntry.devices,
|
||||
[domainGroup]: {
|
||||
...groupEntry.devices![domainGroup],
|
||||
open: true,
|
||||
devices: {
|
||||
...this._entries[floor].areas![area].devices,
|
||||
[deviceId!]: {
|
||||
...this._entries[floor].areas![area].devices![deviceId!],
|
||||
open: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1191,38 +1317,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
|
||||
if (type === "device") {
|
||||
const deviceAreaId = this._registries.devices[id]?.area_id;
|
||||
|
||||
if (!deviceAreaId) {
|
||||
const device = this._registries.devices[id];
|
||||
const floor = `${device.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`;
|
||||
this._entries = {
|
||||
...this._entries,
|
||||
[floor]: {
|
||||
...this._entries[floor],
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[deviceAreaId]?.floor_id || ""}`;
|
||||
const area = `area${TARGET_SEPARATOR}${deviceAreaId}`;
|
||||
|
||||
this._entries = {
|
||||
...this._entries,
|
||||
[floor]: {
|
||||
...this._entries[floor],
|
||||
open: true,
|
||||
areas: {
|
||||
...this._entries[floor].areas!,
|
||||
[area]: {
|
||||
...this._entries[floor].areas![area],
|
||||
open: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
this._setDeviceOpen(id, undefined, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1348,51 +1443,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
}
|
||||
|
||||
if (type === "device" && id) {
|
||||
const areaId = this._registries.devices[id]?.area_id;
|
||||
if (areaId) {
|
||||
const areaTargetId = `area${TARGET_SEPARATOR}${this._registries.devices[id]?.area_id ?? ""}`;
|
||||
const floorId = `floor${TARGET_SEPARATOR}${(areaId && this._registries.areas[areaId]?.floor_id) || ""}`;
|
||||
|
||||
this._entries = {
|
||||
...this._entries,
|
||||
[floorId]: {
|
||||
...this._entries[floorId],
|
||||
areas: {
|
||||
...this._entries[floorId].areas,
|
||||
[areaTargetId]: {
|
||||
...this._entries[floorId].areas![areaTargetId],
|
||||
devices: {
|
||||
...this._entries[floorId].areas![areaTargetId].devices,
|
||||
[id]: {
|
||||
...this._entries[floorId].areas![areaTargetId].devices[id],
|
||||
open,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceType =
|
||||
this._registries.devices[id]?.entry_type === "service"
|
||||
? "service"
|
||||
: "area";
|
||||
const floorId = `${deviceType}${TARGET_SEPARATOR}`;
|
||||
this._entries = {
|
||||
...this._entries,
|
||||
[floorId]: {
|
||||
...this._entries[floorId],
|
||||
devices: {
|
||||
...this._entries[floorId].devices,
|
||||
[id]: {
|
||||
...this._entries[floorId].devices![id],
|
||||
open,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
this._setDeviceOpen(id, open, false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
import type { HaDurationData } from "../../../../../components/ha-duration-input";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
@@ -82,13 +83,21 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
const schema = this._schema(this.hass.localize);
|
||||
// Convert from string representation to ha form duration representation
|
||||
const trigger_offset = this.trigger.offset;
|
||||
const duration: HaDurationData = createDurationData(trigger_offset)!;
|
||||
// Copy, `createDurationData` returns the input object as-is for dict values.
|
||||
const duration: HaDurationData = { ...createDurationData(trigger_offset)! };
|
||||
let offset_type = "after";
|
||||
if (
|
||||
(typeof trigger_offset === "object" && duration!.hours! < 0) ||
|
||||
(typeof trigger_offset === "string" && trigger_offset.startsWith("-"))
|
||||
) {
|
||||
duration.hours = Math.abs(duration.hours!);
|
||||
if (durationDataToSeconds(duration) < 0) {
|
||||
// A negative offset negates the whole period, and the sign is shown by
|
||||
// the separate before/after select instead.
|
||||
if (duration.days) {
|
||||
duration.days = Math.abs(duration.days);
|
||||
}
|
||||
duration.hours = Math.abs(duration.hours ?? 0);
|
||||
duration.minutes = Math.abs(duration.minutes ?? 0);
|
||||
duration.seconds = Math.abs(duration.seconds ?? 0);
|
||||
if (duration.milliseconds) {
|
||||
duration.milliseconds = Math.abs(duration.milliseconds);
|
||||
}
|
||||
offset_type = "before";
|
||||
}
|
||||
const data = {
|
||||
@@ -113,11 +122,12 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
// Convert back to duration string representation
|
||||
const duration = ev.detail.value.offset;
|
||||
const offsetType = ev.detail.value.offset_type === "before" ? "-" : "";
|
||||
const h = (duration.days ?? 0) * 24 + (duration.hours ?? 0);
|
||||
const m = duration.minutes ?? 0;
|
||||
const s = (duration.seconds ?? 0) + (duration.milliseconds ?? 0) / 1000;
|
||||
const newTrigger = {
|
||||
...ev.detail.value,
|
||||
offset: `${offsetType}${duration.hours ?? 0}:${duration.minutes ?? 0}:${
|
||||
duration.seconds ?? 0
|
||||
}`,
|
||||
offset: `${offsetType}${h}:${m}:${s}`,
|
||||
};
|
||||
delete newTrigger.offset_type;
|
||||
fireEvent(this, "value-changed", { value: newTrigger });
|
||||
|
||||
@@ -2,9 +2,7 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { hasTemplate } from "../../../../../common/string/has-template";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { NumericStateTrigger } from "../../../../../data/automation";
|
||||
@@ -198,21 +196,6 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _wrapForValue(
|
||||
forValue: NumericStateTrigger["for"]
|
||||
): Record<string, unknown> | undefined {
|
||||
if (forValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof forValue === "string" && hasTemplate(forValue)) {
|
||||
return { active_choice: "template", template: forValue };
|
||||
}
|
||||
return {
|
||||
active_choice: "duration",
|
||||
duration: createDurationData(forValue),
|
||||
};
|
||||
}
|
||||
|
||||
private _unwrapForValue(
|
||||
forValue: Record<string, unknown> | undefined
|
||||
): NumericStateTrigger["for"] {
|
||||
@@ -240,7 +223,6 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
private _data = memoizeOne((trigger: NumericStateTrigger) => ({
|
||||
...trigger,
|
||||
entity_id: ensureArray(trigger.entity_id),
|
||||
for: this._wrapForValue(trigger.for),
|
||||
}));
|
||||
|
||||
public render() {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
union,
|
||||
} from "superstruct";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { hasTemplate } from "../../../../../common/string/has-template";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
@@ -220,21 +219,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
private _wrapForValue(
|
||||
forValue: StateTrigger["for"]
|
||||
): Record<string, unknown> | undefined {
|
||||
if (forValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof forValue === "string" && hasTemplate(forValue)) {
|
||||
return { active_choice: "template", template: forValue };
|
||||
}
|
||||
return {
|
||||
active_choice: "duration",
|
||||
duration: createDurationData(forValue),
|
||||
};
|
||||
}
|
||||
|
||||
private _unwrapForValue(
|
||||
forValue: Record<string, unknown> | undefined
|
||||
): StateTrigger["for"] {
|
||||
@@ -251,7 +235,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
const data = {
|
||||
...this.trigger,
|
||||
entity_id: ensureArray(this.trigger.entity_id),
|
||||
for: this._wrapForValue(this.trigger.for),
|
||||
};
|
||||
|
||||
data.to = this._normalizeStates(this.trigger.to, data.attribute);
|
||||
|
||||
@@ -3,9 +3,7 @@ import { customElement, property } from "lit/decorators";
|
||||
import type { TemplateTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { hasTemplate } from "../../../../../common/string/has-template";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
const SCHEMA = [
|
||||
@@ -37,21 +35,6 @@ export class HaTemplateTrigger extends LitElement {
|
||||
return { trigger: "template", value_template: "" };
|
||||
}
|
||||
|
||||
private _wrapForValue(
|
||||
forValue: TemplateTrigger["for"]
|
||||
): Record<string, unknown> | undefined {
|
||||
if (forValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof forValue === "string" && hasTemplate(forValue)) {
|
||||
return { active_choice: "template", template: forValue };
|
||||
}
|
||||
return {
|
||||
active_choice: "duration",
|
||||
duration: createDurationData(forValue),
|
||||
};
|
||||
}
|
||||
|
||||
private _unwrapForValue(
|
||||
forValue: Record<string, unknown> | undefined
|
||||
): TemplateTrigger["for"] {
|
||||
@@ -67,7 +50,6 @@ export class HaTemplateTrigger extends LitElement {
|
||||
protected render() {
|
||||
const data = {
|
||||
...this.trigger,
|
||||
for: this._wrapForValue(this.trigger.for),
|
||||
};
|
||||
|
||||
return html`
|
||||
|
||||
@@ -5,10 +5,13 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { ENTITY_NAME_TYPES } from "../../../common/entity/compute_entity_name_display";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/chips/ha-chip-set";
|
||||
import "../../../components/chips/ha-filter-chip";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
@@ -32,6 +35,11 @@ import "./ha-entity-id-format-editor";
|
||||
const EXAMPLE_DOMAIN = "sensor";
|
||||
const PREVIEW_DEBOUNCE_MS = 200;
|
||||
|
||||
interface EntityIdExample {
|
||||
name: string;
|
||||
parts: Partial<Record<EntityIdPart, string>>;
|
||||
}
|
||||
|
||||
@customElement("ha-config-entity-id-format")
|
||||
export class HaConfigEntityIdFormat extends LitElement {
|
||||
@state()
|
||||
@@ -50,10 +58,12 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _preview?: string;
|
||||
@state() private _previews?: string[];
|
||||
|
||||
@state() private _previewError = false;
|
||||
|
||||
@state() private _selectedExample = 0;
|
||||
|
||||
protected async firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
try {
|
||||
@@ -79,12 +89,49 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
}
|
||||
|
||||
private _examples = memoizeOne(
|
||||
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
|
||||
area: localize("ui.panel.config.entity_id_format.card.examples.area"),
|
||||
device: localize("ui.panel.config.entity_id_format.card.examples.device"),
|
||||
entity: localize("ui.panel.config.entity_id_format.card.examples.entity"),
|
||||
floor: localize("ui.panel.config.entity_id_format.card.examples.floor"),
|
||||
})
|
||||
(localize: LocalizeFunc): EntityIdExample[] => [
|
||||
{
|
||||
name: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.thermostat.name"
|
||||
),
|
||||
parts: {
|
||||
floor: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.thermostat.floor"
|
||||
),
|
||||
area: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.thermostat.area"
|
||||
),
|
||||
device: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.thermostat.device"
|
||||
),
|
||||
entity: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.thermostat.entity"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.power_strip.name"
|
||||
),
|
||||
parts: {
|
||||
floor: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.power_strip.floor"
|
||||
),
|
||||
area: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.power_strip.area"
|
||||
),
|
||||
parent_device: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.power_strip.parent_device"
|
||||
),
|
||||
device: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.power_strip.device"
|
||||
),
|
||||
entity: localize(
|
||||
"ui.panel.config.entity_id_format.card.examples.power_strip.entity"
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
private _debouncedUpdatePreview = debounce(
|
||||
@@ -94,12 +141,16 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
|
||||
private async _updatePreview() {
|
||||
const examples = this._examples(this._localize);
|
||||
const fullName = this._format!.map((part) => examples[part])
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
try {
|
||||
const { slug } = await fetchSlug(this._api, fullName);
|
||||
this._preview = slug;
|
||||
this._previews = await Promise.all(
|
||||
examples.map(async (example) => {
|
||||
const fullName = this._format!.map((part) => example.parts[part])
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const { slug } = await fetchSlug(this._api, fullName);
|
||||
return slug;
|
||||
})
|
||||
);
|
||||
this._previewError = false;
|
||||
} catch (_err: any) {
|
||||
this._previewError = true;
|
||||
@@ -146,7 +197,6 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
)}
|
||||
@value-changed=${this._formatChanged}
|
||||
></ha-entity-id-format-editor>
|
||||
${this._renderPreview()}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
@@ -169,28 +219,82 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
</ha-progress-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
${this._format ? this._renderExamples() : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPreview() {
|
||||
private _renderExamples() {
|
||||
const examples = this._examples(this._localize);
|
||||
const example = examples[this._selectedExample];
|
||||
const parts = ENTITY_NAME_TYPES.filter((part) => example.parts[part]);
|
||||
return html`
|
||||
<div class="preview">
|
||||
<span class="preview-label">
|
||||
${this._localize("ui.panel.config.entity_id_format.card.preview")}
|
||||
</span>
|
||||
${
|
||||
this._previewError
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.preview_error"
|
||||
)}
|
||||
</ha-alert>`
|
||||
: html`<code>${EXAMPLE_DOMAIN}.${this._preview ?? "…"}</code>`
|
||||
}
|
||||
</div>
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.preview"
|
||||
)}
|
||||
>
|
||||
<div class="card-content examples">
|
||||
<ha-chip-set>
|
||||
${examples.map(
|
||||
(item, index) => html`
|
||||
<ha-filter-chip
|
||||
no-leading-icon
|
||||
data-index=${index}
|
||||
.selected=${index === this._selectedExample}
|
||||
.label=${item.name}
|
||||
@click=${this._selectExample}
|
||||
></ha-filter-chip>
|
||||
`
|
||||
)}
|
||||
</ha-chip-set>
|
||||
${
|
||||
this._previewError
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this._localize(
|
||||
"ui.panel.config.entity_id_format.card.preview_error"
|
||||
)}
|
||||
</ha-alert>`
|
||||
: html`<code
|
||||
>${EXAMPLE_DOMAIN}.${
|
||||
this._previews?.[this._selectedExample] ?? "…"
|
||||
}</code
|
||||
>`
|
||||
}
|
||||
<dl>
|
||||
${parts.map((part) => {
|
||||
const used = this._format!.includes(part);
|
||||
return html`
|
||||
<dt>
|
||||
${this._localize(
|
||||
`ui.components.entity.entity-name-picker.types.${part}`
|
||||
)}
|
||||
</dt>
|
||||
<dd class=${used ? "" : "unused"}>
|
||||
${
|
||||
used
|
||||
? example.parts[part]
|
||||
: this._localize(
|
||||
"ui.panel.config.entity_id_format.card.unused_part",
|
||||
{ name: example.parts[part] }
|
||||
)
|
||||
}
|
||||
</dd>
|
||||
`;
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _selectExample(ev: Event) {
|
||||
ev.preventDefault();
|
||||
this._selectedExample = Number(
|
||||
(ev.currentTarget as HTMLElement).dataset.index
|
||||
);
|
||||
}
|
||||
|
||||
private _formatChanged(ev: CustomEvent) {
|
||||
this._format = ev.detail.value;
|
||||
}
|
||||
@@ -225,24 +329,36 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
}
|
||||
.description {
|
||||
margin-top: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.preview {
|
||||
.examples {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-1);
|
||||
margin-top: var(--ha-space-5);
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
.preview-label {
|
||||
font-weight: 500;
|
||||
.examples dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||
column-gap: var(--ha-space-3);
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.preview code {
|
||||
.examples dt,
|
||||
.examples .unused {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.examples dd {
|
||||
margin: 0;
|
||||
}
|
||||
.examples code {
|
||||
display: block;
|
||||
padding: var(--ha-space-3);
|
||||
padding: var(--ha-space-2);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
background-color: var(--secondary-background-color);
|
||||
font-family: var(--ha-font-family-code);
|
||||
@@ -252,6 +368,7 @@ export class HaConfigEntityIdFormat extends LitElement {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-block-end: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { ENTITY_NAME_TYPES } from "../../../common/entity/compute_entity_name_display";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/chips/ha-assist-chip";
|
||||
import "../../../components/chips/ha-chip-set";
|
||||
@@ -22,8 +23,6 @@ import type {
|
||||
} from "../../../data/entity_id_format";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
|
||||
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
|
||||
|
||||
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
|
||||
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
@@ -39,7 +38,7 @@ const decodeType = (value: string): EntityIdPart | undefined => {
|
||||
value.startsWith("___") && value.endsWith("___")
|
||||
? value.slice(3, -3)
|
||||
: value;
|
||||
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
|
||||
return (ENTITY_NAME_TYPES as readonly string[]).includes(type)
|
||||
? (type as EntityIdPart)
|
||||
: undefined;
|
||||
};
|
||||
@@ -65,6 +64,7 @@ export class HaEntityIdFormatEditor extends LitElement {
|
||||
<div class="container">
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
no-sort
|
||||
.disabled=${this.disabled}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
@@ -221,7 +221,7 @@ export class HaEntityIdFormatEditor extends LitElement {
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(localize: LocalizeFunc): PickerComboBoxItem[] =>
|
||||
STRUCTURAL_TYPES.map((type) => {
|
||||
ENTITY_NAME_TYPES.map((type) => {
|
||||
const primary = localize(
|
||||
`ui.components.entity.entity-name-picker.types.${type}`
|
||||
);
|
||||
|
||||
@@ -678,7 +678,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
([domain, items]) =>
|
||||
html`<ha-integration-card
|
||||
data-domain=${domain}
|
||||
.hass=${this.hass}
|
||||
.domain=${domain}
|
||||
.items=${items}
|
||||
.manifest=${this._manifests[domain]}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type IntegrationManifest,
|
||||
} from "../../../data/integration";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-integration-header";
|
||||
import "../../../components/ha-card";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { mdiFileCodeOutline, mdiPackageVariant, mdiWeb } from "@mdi/js";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import {
|
||||
devicesContext,
|
||||
internationalizationContext,
|
||||
} from "../../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import type { HomeAssistantInternationalization } from "../../../types";
|
||||
import type { ConfigEntryExtended } from "./ha-config-integrations";
|
||||
|
||||
@customElement("ha-integration-card-footer")
|
||||
export class HaIntegrationCardFooter extends LitElement {
|
||||
@property({ attribute: false }) public manifest?: IntegrationManifest;
|
||||
|
||||
@property({ attribute: false }) public items!: ConfigEntryExtended[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityRegistryEntries!: EntityRegistryEntry[];
|
||||
|
||||
@property({ attribute: false }) public domainEntities: string[] = [];
|
||||
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
private _devices!: ContextType<typeof devicesContext>;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@transform<HomeAssistantInternationalization, boolean>({
|
||||
transformer: ({ language, translationMetadata }) =>
|
||||
computeRTL(language, translationMetadata.translations),
|
||||
})
|
||||
private _isRTL!: boolean;
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
const devices = this._getDevices(this.items, this._devices);
|
||||
const entitiesCount = devices.length
|
||||
? 0
|
||||
: this._getEntityCount(
|
||||
this.items,
|
||||
this.entityRegistryEntries,
|
||||
this.domainEntities
|
||||
);
|
||||
|
||||
const services = !devices.some((device) => device.entry_type !== "service");
|
||||
|
||||
const hasIcons =
|
||||
Boolean(this.manifest && !this.manifest.is_built_in) ||
|
||||
Boolean(this.manifest?.iot_class?.startsWith("cloud_")) ||
|
||||
Boolean(
|
||||
this.manifest &&
|
||||
!this.manifest.config_flow &&
|
||||
!this.items.every((itm) => itm.source === "system")
|
||||
);
|
||||
|
||||
let countBadge: TemplateResult | typeof nothing = nothing;
|
||||
if (devices.length > 0) {
|
||||
countBadge = html`<span class="count-badge"
|
||||
>${this._localize(
|
||||
`ui.panel.config.integrations.config_entry.${services ? "services" : "devices"}`,
|
||||
{ count: devices.length }
|
||||
)}</span
|
||||
>`;
|
||||
} else if (entitiesCount > 0) {
|
||||
countBadge = html`<span class="count-badge"
|
||||
>${this._localize(
|
||||
`ui.panel.config.integrations.config_entry.entities`,
|
||||
{ count: entitiesCount }
|
||||
)}</span
|
||||
>`;
|
||||
} else if (this.items.find((itm) => itm.source !== "yaml")) {
|
||||
countBadge = html`<span class="count-badge"
|
||||
>${this._localize(`ui.panel.config.integrations.config_entry.entries`, {
|
||||
count: this.items.filter((itm) => itm.source !== "yaml").length,
|
||||
})}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
if (countBadge === nothing && !hasIcons) return nothing;
|
||||
|
||||
const placement = this._isRTL ? "right" : "left";
|
||||
|
||||
return html`
|
||||
<div class="footer">
|
||||
${countBadge}
|
||||
<div class="icons">
|
||||
${
|
||||
this.manifest && !this.manifest.is_built_in
|
||||
? html`<span
|
||||
class="icon ${
|
||||
this.manifest.overwrites_built_in ? "overwrites" : "custom"
|
||||
}"
|
||||
>
|
||||
<ha-svg-icon
|
||||
id="icon-custom"
|
||||
.path=${mdiPackageVariant}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="icon-custom" .placement=${placement}>
|
||||
${this._localize(
|
||||
this.manifest.overwrites_built_in
|
||||
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
|
||||
: "ui.panel.config.integrations.config_entry.custom_integration"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.manifest && this.manifest.iot_class?.startsWith("cloud_")
|
||||
? html`<span class="icon cloud">
|
||||
<ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
|
||||
<ha-tooltip for="icon-cloud" .placement=${placement}>
|
||||
${this._localize(
|
||||
"ui.panel.config.integrations.config_entry.depends_on_cloud"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.manifest &&
|
||||
!this.manifest?.config_flow &&
|
||||
!this.items.every((itm) => itm.source === "system")
|
||||
? html`<span class="icon yaml">
|
||||
<ha-svg-icon
|
||||
id="icon-yaml"
|
||||
.path=${mdiFileCodeOutline}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="icon-yaml" .placement=${placement}>
|
||||
${this._localize(
|
||||
"ui.panel.config.integrations.config_entry.no_config_flow"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getEntityCount = memoizeOne(
|
||||
(
|
||||
configEntry: ConfigEntryExtended[],
|
||||
entityRegistryEntries: EntityRegistryEntry[],
|
||||
domainEntities: string[]
|
||||
): number => {
|
||||
if (!entityRegistryEntries) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entryIds = configEntry
|
||||
.map((entry) => entry.entry_id)
|
||||
.filter(Boolean);
|
||||
|
||||
if (!entryIds.length) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entityRegEntities = entityRegistryEntries.filter(
|
||||
(entity) =>
|
||||
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
|
||||
);
|
||||
|
||||
if (entityRegEntities.length === domainEntities.length) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entityIds = new Set<string>(
|
||||
entityRegEntities.map((reg) => reg.entity_id)
|
||||
);
|
||||
|
||||
for (const entity of domainEntities) {
|
||||
entityIds.add(entity);
|
||||
}
|
||||
|
||||
return entityIds.size;
|
||||
}
|
||||
);
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(
|
||||
configEntry: ConfigEntryExtended[],
|
||||
deviceRegistryEntries: ContextType<typeof devicesContext>
|
||||
): DeviceRegistryEntry[] => {
|
||||
if (!deviceRegistryEntries) {
|
||||
return [];
|
||||
}
|
||||
const entryIds = configEntry.map((entry) => entry.entry_id);
|
||||
return Object.values(deviceRegistryEntries).filter((device) =>
|
||||
device.config_entries.some((entryId) => entryIds.includes(entryId))
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: var(--ha-space-2);
|
||||
min-height: var(--ha-space-8);
|
||||
}
|
||||
.count-badge {
|
||||
color: var(--ha-color-text-secondary);
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
.icons {
|
||||
display: flex;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.icon {
|
||||
color: var(--label-badge-grey);
|
||||
padding: var(--ha-space-1);
|
||||
margin-inline-start: var(--ha-space-2);
|
||||
}
|
||||
.icon.custom {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.icon.overwrites {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.icon ha-svg-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-integration-card-footer": HaIntegrationCardFooter;
|
||||
}
|
||||
}
|
||||
+40
-33
@@ -1,18 +1,20 @@
|
||||
import { mdiAlertCircleOutline, mdiAlertOutline } from "@mdi/js";
|
||||
import { consume } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-icon-next";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { configContext, uiContext } from "../../../data/context";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HomeAssistantConfig, HomeAssistantUI } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
|
||||
@customElement("ha-integration-header")
|
||||
export class HaIntegrationHeader extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@customElement("ha-integration-card-header")
|
||||
export class HaIntegrationCardHeader extends LitElement {
|
||||
@property() public error?: string;
|
||||
|
||||
@property() public warning?: string;
|
||||
@@ -23,10 +25,28 @@ export class HaIntegrationHeader extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public manifest?: IntegrationManifest;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, boolean | undefined>({
|
||||
transformer: ({ themes }) => themes?.darkMode,
|
||||
})
|
||||
private _darkMode?: boolean;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, string>({
|
||||
transformer: ({ auth }) => auth.data.hassUrl,
|
||||
})
|
||||
private _hassUrl!: string;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const domainName =
|
||||
this.localizedDomainName ||
|
||||
domainToName(this.hass.localize, this.domain, this.manifest);
|
||||
domainToName(this._localize, this.domain, this.manifest);
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
@@ -36,9 +56,9 @@ export class HaIntegrationHeader extends LitElement {
|
||||
{
|
||||
domain: this.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
darkOptimized: this._darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
this._hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
@@ -71,12 +91,6 @@ export class HaIntegrationHeader extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-icon-next
|
||||
class="header-button"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.configure"
|
||||
)}
|
||||
></ha-icon-next>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -90,36 +104,31 @@ export class HaIntegrationHeader extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
flex: 1;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-end: 8px;
|
||||
direction: var(--direction);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
.header img {
|
||||
margin-inline-start: initial;
|
||||
margin-inline-end: 16px;
|
||||
margin-inline-end: var(--ha-space-4);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.header .info {
|
||||
position: relative;
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
}
|
||||
ha-icon-next {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.primary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -137,10 +146,10 @@ export class HaIntegrationHeader extends LitElement {
|
||||
.secondary {
|
||||
min-width: 0;
|
||||
--mdc-icon-size: 20px;
|
||||
-webkit-line-clamp: 1;
|
||||
font-size: var(--ha-font-size-s);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.secondary > span {
|
||||
position: relative;
|
||||
@@ -150,9 +159,7 @@ export class HaIntegrationHeader extends LitElement {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.secondary > ha-svg-icon {
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
margin-inline-start: initial;
|
||||
margin-inline-end: var(--ha-space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.error ha-svg-icon {
|
||||
@@ -166,6 +173,6 @@ export class HaIntegrationHeader extends LitElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-integration-header": HaIntegrationHeader;
|
||||
"ha-integration-card-header": HaIntegrationCardHeader;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
import { mdiFileCodeOutline, mdiPackageVariant, mdiWeb } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { PROTOCOL_INTEGRATIONS } from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import "../../../components/ha-button";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-ripple";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import type { ConfigEntry } from "../../../data/config_entries";
|
||||
import { ERROR_STATES } from "../../../data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type {
|
||||
IntegrationLogInfo,
|
||||
@@ -21,13 +16,15 @@ import type {
|
||||
} from "../../../data/integration";
|
||||
import { LogSeverity } from "../../../data/integration";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { ConfigEntryExtended } from "./ha-config-integrations";
|
||||
import "./ha-integration-header";
|
||||
import "./ha-integration-card-footer";
|
||||
import "./ha-integration-card-header";
|
||||
|
||||
@customElement("ha-integration-card")
|
||||
export class HaIntegrationCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property() public domain!: string;
|
||||
|
||||
@@ -65,183 +62,42 @@ export class HaIntegrationCard extends LitElement {
|
||||
class="ripple-anchor"
|
||||
>
|
||||
<ha-ripple></ha-ripple>
|
||||
<ha-integration-header
|
||||
.hass=${this.hass}
|
||||
.domain=${this.domain}
|
||||
.localizedDomainName=${this.items[0].localized_domain_name}
|
||||
.error=${
|
||||
ERROR_STATES.includes(entryState)
|
||||
? this.hass.localize(
|
||||
`ui.panel.config.integrations.config_entry.state.${entryState}`
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
.warning=${
|
||||
entryState !== "loaded" && !ERROR_STATES.includes(entryState)
|
||||
? this.hass.localize(
|
||||
`ui.panel.config.integrations.config_entry.state.${entryState}`
|
||||
)
|
||||
: debugLoggingEnabled
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.debug_logging_enabled"
|
||||
<div class="card-content">
|
||||
<ha-integration-card-header
|
||||
.domain=${this.domain}
|
||||
.localizedDomainName=${this.items[0].localized_domain_name}
|
||||
.error=${
|
||||
ERROR_STATES.includes(entryState)
|
||||
? this._localize(
|
||||
`ui.panel.config.integrations.config_entry.state.${entryState}`
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
.manifest=${this.manifest}
|
||||
>
|
||||
</ha-integration-header>
|
||||
}
|
||||
.warning=${
|
||||
entryState !== "loaded" && !ERROR_STATES.includes(entryState)
|
||||
? this._localize(
|
||||
`ui.panel.config.integrations.config_entry.state.${entryState}`
|
||||
)
|
||||
: debugLoggingEnabled
|
||||
? this._localize(
|
||||
"ui.panel.config.integrations.config_entry.debug_logging_enabled"
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
.manifest=${this.manifest}
|
||||
></ha-integration-card-header>
|
||||
<ha-integration-card-footer
|
||||
.manifest=${this.manifest}
|
||||
.items=${this.items}
|
||||
.entityRegistryEntries=${this.entityRegistryEntries}
|
||||
.domainEntities=${this.domainEntities}
|
||||
></ha-integration-card-footer>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
${this._renderSingleEntry()}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSingleEntry(): TemplateResult {
|
||||
const devices = this._getDevices(this.items, this.hass.devices);
|
||||
const entitiesCount = devices.length
|
||||
? 0
|
||||
: this._getEntityCount(
|
||||
this.items,
|
||||
this.entityRegistryEntries,
|
||||
this.domainEntities
|
||||
);
|
||||
|
||||
const services = !devices.some((device) => device.entry_type !== "service");
|
||||
|
||||
return html`
|
||||
<div class="card-actions">
|
||||
${
|
||||
devices.length > 0
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
href=${
|
||||
devices.length === 1 &&
|
||||
// Always link to device page for protocol integrations to show Add Device button
|
||||
// @ts-expect-error
|
||||
!PROTOCOL_INTEGRATIONS.includes(this.domain)
|
||||
? `/config/devices/device/${devices[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.integrations.config_entry.${
|
||||
services ? "services" : "devices"
|
||||
}`,
|
||||
{ count: devices.length }
|
||||
)}
|
||||
</ha-button>`
|
||||
: entitiesCount > 0
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
href=${`/config/entities?historyBack=1&domain=${this.domain}`}
|
||||
>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.integrations.config_entry.entities`,
|
||||
{ count: entitiesCount }
|
||||
)}
|
||||
</ha-button>`
|
||||
: this.items.find((itm) => itm.source !== "yaml")
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
href=${`/config/integrations/integration/${this.domain}`}
|
||||
>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.integrations.config_entry.entries`,
|
||||
{
|
||||
count: this.items.filter((itm) => itm.source !== "yaml")
|
||||
.length,
|
||||
}
|
||||
)}
|
||||
</ha-button>`
|
||||
: html`<div class="spacer"></div>`
|
||||
}
|
||||
<div class="icons">
|
||||
${
|
||||
this.manifest && !this.manifest.is_built_in
|
||||
? html`<span
|
||||
class="icon ${
|
||||
this.manifest.overwrites_built_in ? "overwrites" : "custom"
|
||||
}"
|
||||
>
|
||||
<ha-svg-icon
|
||||
id="icon-custom"
|
||||
.path=${mdiPackageVariant}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip
|
||||
for="icon-custom"
|
||||
.placement=${
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "right"
|
||||
: "left"
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
this.manifest.overwrites_built_in
|
||||
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
|
||||
: "ui.panel.config.integrations.config_entry.custom_integration"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.manifest && this.manifest.iot_class?.startsWith("cloud_")
|
||||
? html`<div class="icon cloud">
|
||||
<ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
|
||||
<ha-tooltip
|
||||
for="icon-cloud"
|
||||
.placement=${
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "right"
|
||||
: "left"
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.depends_on_cloud"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.manifest &&
|
||||
!this.manifest?.config_flow &&
|
||||
!this.items.every((itm) => itm.source === "system")
|
||||
? html`<div class="icon yaml">
|
||||
<ha-svg-icon
|
||||
id="icon-yaml"
|
||||
.path=${mdiFileCodeOutline}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip
|
||||
for="icon-yaml"
|
||||
.placement=${
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "right"
|
||||
: "left"
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.no_config_flow"
|
||||
)}
|
||||
</ha-tooltip>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getState = memoizeOne(
|
||||
(configEntry: ConfigEntry[]): ConfigEntry["state"] => {
|
||||
if (configEntry.length === 1) {
|
||||
@@ -258,60 +114,6 @@ export class HaIntegrationCard extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _getEntityCount = memoizeOne(
|
||||
(
|
||||
configEntry: ConfigEntry[],
|
||||
entityRegistryEntries: EntityRegistryEntry[],
|
||||
domainEntities: string[]
|
||||
): number => {
|
||||
if (!entityRegistryEntries) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entryIds = configEntry
|
||||
.map((entry) => entry.entry_id)
|
||||
.filter(Boolean);
|
||||
|
||||
if (!entryIds.length) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entityRegEntities = entityRegistryEntries.filter(
|
||||
(entity) =>
|
||||
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
|
||||
);
|
||||
|
||||
if (entityRegEntities.length === domainEntities.length) {
|
||||
return domainEntities.length;
|
||||
}
|
||||
|
||||
const entityIds = new Set<string>(
|
||||
entityRegEntities.map((reg) => reg.entity_id)
|
||||
);
|
||||
|
||||
for (const entity of domainEntities) {
|
||||
entityIds.add(entity);
|
||||
}
|
||||
|
||||
return entityIds.size;
|
||||
}
|
||||
);
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(
|
||||
configEntry: ConfigEntry[],
|
||||
deviceRegistryEntries: HomeAssistant["devices"]
|
||||
): DeviceRegistryEntry[] => {
|
||||
if (!deviceRegistryEntries) {
|
||||
return [];
|
||||
}
|
||||
const entryIds = configEntry.map((entry) => entry.entry_id);
|
||||
return Object.values(deviceRegistryEntries).filter((device) =>
|
||||
device.config_entries.some((entryId) => entryIds.includes(entryId))
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
@@ -319,16 +121,24 @@ export class HaIntegrationCard extends LitElement {
|
||||
ha-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
--state-color: var(--divider-color, #e0e0e0);
|
||||
--state-message-color: var(--state-color);
|
||||
}
|
||||
ha-card:hover {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
}
|
||||
.ripple-anchor {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
outline: none;
|
||||
/* ha-ripple adds a hover overlay that conflicts with ha-card:hover background change;
|
||||
neutralize it so hover matches the apps card (background-color only) */
|
||||
--ha-ripple-hover-color: transparent;
|
||||
}
|
||||
.ripple-anchor:focus-visible:before {
|
||||
position: absolute;
|
||||
@@ -338,13 +148,11 @@ export class HaIntegrationCard extends LitElement {
|
||||
background-color: var(--secondary-text-color);
|
||||
opacity: 0.08;
|
||||
}
|
||||
ha-integration-header {
|
||||
height: 100%;
|
||||
}
|
||||
.card-actions {
|
||||
.card-content {
|
||||
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
}
|
||||
.debug-logging {
|
||||
--state-color: var(--warning-color);
|
||||
@@ -376,40 +184,10 @@ export class HaIntegrationCard extends LitElement {
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--text-on-state-color: var(--text-primary-color);
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
--mdc-list-side-padding-right: 20px;
|
||||
--mdc-list-side-padding-left: 24px;
|
||||
--mdc-list-item-graphic-margin: 24px;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.icons {
|
||||
display: flex;
|
||||
}
|
||||
.icon {
|
||||
color: var(--label-badge-grey);
|
||||
padding: 4px;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
.icon.custom {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.icon.overwrites {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.icon ha-svg-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
}
|
||||
.spacer {
|
||||
height: 36px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { styleMap } from "lit/directives/style-map";
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-svg-icon";
|
||||
import { updateAreaRegistryEntry } from "../../data/area/area_registry";
|
||||
@@ -14,9 +15,12 @@ import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
subscribeFrontendSystemData,
|
||||
type HomeFrontendSystemData,
|
||||
type SecurityFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import { mdiHomeAssistant } from "../../resources/home-assistant-logo-svg";
|
||||
import type { HomeAssistant, PanelInfo, Route } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
@@ -35,7 +39,7 @@ import { showNewOverviewDialog } from "./dialogs/show-dialog-new-overview";
|
||||
import { hasLegacyOverviewPanel } from "../../data/panel";
|
||||
|
||||
@customElement("ha-panel-home")
|
||||
class PanelHome extends LitElement {
|
||||
class PanelHome extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
@@ -48,12 +52,36 @@ class PanelHome extends LitElement {
|
||||
|
||||
@state() private _config: FrontendSystemData["home"] = {};
|
||||
|
||||
@state() private _securityConfig: SecurityFrontendSystemData = {};
|
||||
|
||||
@state() private _extraActionItems?: ExtraActionItem[];
|
||||
|
||||
@query(".banner") private _banner?: HTMLElement;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _securityConfigRevision = 0;
|
||||
|
||||
public hassSubscribe() {
|
||||
return [
|
||||
subscribeFrontendSystemData(
|
||||
this.hass.connection,
|
||||
"security",
|
||||
({ value }) => {
|
||||
this._securityConfigRevision++;
|
||||
const config = value || {};
|
||||
if (deepEqual(config, this._securityConfig)) {
|
||||
return;
|
||||
}
|
||||
this._securityConfig = config;
|
||||
if (this.hasUpdated) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private get _showBanner(): boolean {
|
||||
// Don't show if already dismissed
|
||||
if (this._config.welcome_banner_dismissed) {
|
||||
@@ -150,16 +178,41 @@ class PanelHome extends LitElement {
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
try {
|
||||
const [_, data] = await Promise.all([
|
||||
const securityConfigRevision = this._securityConfigRevision;
|
||||
const [translationsResult, homeResult, securityResult] =
|
||||
await Promise.allSettled([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
fetchFrontendSystemData(this.hass.connection, "home"),
|
||||
fetchFrontendSystemData(this.hass.connection, "security"),
|
||||
]);
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
|
||||
if (translationsResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load favorites:", err);
|
||||
console.error(
|
||||
"Failed to load Lovelace translations:",
|
||||
translationsResult.reason
|
||||
);
|
||||
}
|
||||
|
||||
if (homeResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load home configuration:", homeResult.reason);
|
||||
this._config = {};
|
||||
} else {
|
||||
this._config = homeResult.value || {};
|
||||
}
|
||||
|
||||
if (securityResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Failed to load security configuration:",
|
||||
securityResult.reason
|
||||
);
|
||||
if (securityConfigRevision === this._securityConfigRevision) {
|
||||
this._securityConfig = {};
|
||||
}
|
||||
} else if (securityConfigRevision === this._securityConfigRevision) {
|
||||
this._securityConfig = securityResult.value || {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +396,7 @@ class PanelHome extends LitElement {
|
||||
return {
|
||||
strategy: {
|
||||
type: "home",
|
||||
alert_entities: this._securityConfig.alert_entities,
|
||||
favorite_entities: this._config.favorite_entities,
|
||||
home_panel: true,
|
||||
hide_welcome_message: this._config.hide_welcome_message,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiHomeImportOutline, mdiPause, mdiPlay } from "@mdi/js";
|
||||
import { mdiHomeImportOutline, mdiPause, mdiPlay, mdiStop } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
LawnMowerEntityFeature,
|
||||
canDock,
|
||||
canStartMowing,
|
||||
canStop,
|
||||
} from "../../../data/lawn_mower";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../../../types";
|
||||
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
|
||||
@@ -46,6 +47,7 @@ export const LAWN_MOWER_COMMANDS_FEATURES: Record<
|
||||
LawnMowerEntityFeature.PAUSE,
|
||||
LawnMowerEntityFeature.START_MOWING,
|
||||
],
|
||||
stop: [LawnMowerEntityFeature.STOP],
|
||||
dock: [LawnMowerEntityFeature.DOCK],
|
||||
};
|
||||
|
||||
@@ -81,6 +83,12 @@ export const LAWN_MOWER_COMMANDS_BUTTONS: Record<
|
||||
!canStartMowing(stateObj),
|
||||
};
|
||||
},
|
||||
stop: (stateObj) => ({
|
||||
translationKey: "stop",
|
||||
icon: mdiStop,
|
||||
serviceName: "stop",
|
||||
disabled: !canStop(stateObj),
|
||||
}),
|
||||
dock: (stateObj) => ({
|
||||
translationKey: "dock",
|
||||
icon: mdiHomeImportOutline,
|
||||
|
||||
@@ -26,12 +26,14 @@ import type {
|
||||
HomeAssistantFormatters,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../../types";
|
||||
import type { LovelaceCardFeature } from "../types";
|
||||
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
|
||||
import { cardFeatureStyles } from "./common/card-feature-styles";
|
||||
import type {
|
||||
LovelaceCardFeatureContext,
|
||||
TargetHumidityCardFeatureConfig,
|
||||
} from "./types";
|
||||
import "../../../components/ha-control-button-group";
|
||||
import "../../../components/ha-control-number-buttons";
|
||||
|
||||
const supportsTargetHumidityCardFeatureFromState = (stateObj: HassEntity) => {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
@@ -86,9 +88,15 @@ class HuiTargetHumidityCardFeature
|
||||
static getStubConfig(): TargetHumidityCardFeatureConfig {
|
||||
return {
|
||||
type: "target-humidity",
|
||||
style: "slider",
|
||||
};
|
||||
}
|
||||
|
||||
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
|
||||
await import("../editor/config-elements/hui-target-humidity-card-feature-editor");
|
||||
return document.createElement("hui-target-humidity-card-feature-editor");
|
||||
}
|
||||
|
||||
public setConfig(config: TargetHumidityCardFeatureConfig): void {
|
||||
if (!config) {
|
||||
throw new Error("Invalid configuration");
|
||||
@@ -103,10 +111,6 @@ class HuiTargetHumidityCardFeature
|
||||
}
|
||||
}
|
||||
|
||||
private get _step() {
|
||||
return this._stateObj!.attributes.target_humidity_step ?? 1;
|
||||
}
|
||||
|
||||
private get _min() {
|
||||
return this._stateObj!.attributes.min_humidity ?? 0;
|
||||
}
|
||||
@@ -140,13 +144,34 @@ class HuiTargetHumidityCardFeature
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (this._config.style === "buttons") {
|
||||
return html`
|
||||
<ha-control-button-group>
|
||||
<ha-control-number-buttons
|
||||
.value=${this._stateObj.attributes.humidity}
|
||||
.min=${this._min}
|
||||
.max=${this._max}
|
||||
.disabled=${this._stateObj.state === UNAVAILABLE}
|
||||
.step=${this._stateObj.attributes.target_humidity_step ?? 1}
|
||||
@value-changed=${this._valueChanged}
|
||||
.label=${this._formatters.formatEntityAttributeName(
|
||||
this._stateObj,
|
||||
"humidity"
|
||||
)}
|
||||
unit="%"
|
||||
.locale=${this._locale}
|
||||
></ha-control-number-buttons>
|
||||
</ha-control-button-group>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-control-slider
|
||||
.value=${this._stateObj.attributes.humidity}
|
||||
.min=${this._min}
|
||||
.max=${this._max}
|
||||
.step=${this._step}
|
||||
.disabled=${this._stateObj!.state === UNAVAILABLE}
|
||||
.step=${this._stateObj.attributes.target_humidity_step ?? 1}
|
||||
@value-changed=${this._valueChanged}
|
||||
.label=${this._formatters.formatEntityAttributeName(
|
||||
this._stateObj,
|
||||
|
||||
@@ -186,6 +186,7 @@ export interface NumericInputCardFeatureConfig {
|
||||
|
||||
export interface TargetHumidityCardFeatureConfig {
|
||||
type: "target-humidity";
|
||||
style?: "buttons" | "slider";
|
||||
}
|
||||
|
||||
export interface TargetTemperatureCardFeatureConfig {
|
||||
@@ -264,7 +265,7 @@ export interface ValvePositionFavoriteCardFeatureConfig {
|
||||
type: "valve-position-favorite";
|
||||
}
|
||||
|
||||
export const LAWN_MOWER_COMMANDS = ["start_pause", "dock"] as const;
|
||||
export const LAWN_MOWER_COMMANDS = ["start_pause", "stop", "dock"] as const;
|
||||
|
||||
export type LawnMowerCommand = (typeof LAWN_MOWER_COMMANDS)[number];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { theme2hex } from "../../../common/color/convert-color";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
@@ -105,10 +106,22 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
this._names = {};
|
||||
this._entities.forEach((entity) => {
|
||||
const stateObj = this.hass!.states[entity.entity];
|
||||
this._names[entity.entity] = stateObj
|
||||
? this.hass!.formatEntityName(stateObj, entity.name)
|
||||
: entity.entity;
|
||||
// Leave unset so timeline/line charts use computeHistory's Device ▸ Entity
|
||||
// labels. Only YAML `name` overrides that default.
|
||||
if (entity.name === undefined) {
|
||||
return;
|
||||
}
|
||||
const stateObj =
|
||||
this.hass!.states[entity.entity] ??
|
||||
({
|
||||
entity_id: entity.entity,
|
||||
state: "unavailable",
|
||||
attributes: {},
|
||||
} as HassEntity);
|
||||
this._names[entity.entity] = this.hass!.formatEntityName(
|
||||
stateObj,
|
||||
entity.name
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -315,6 +328,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
||||
const columns = this._config.grid_options?.columns ?? 12;
|
||||
const narrow = typeof columns === "number" && columns <= 12;
|
||||
const hasFixedHeight = typeof this._config.grid_options?.rows === "number";
|
||||
const showNames = this._config.show_names !== false;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
@@ -360,11 +374,8 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
||||
.names=${this._names}
|
||||
up-to-now
|
||||
.hoursToShow=${this._hoursToShow}
|
||||
.showNames=${
|
||||
this._config.show_names !== undefined
|
||||
? this._config.show_names
|
||||
: true
|
||||
}
|
||||
.showNames=${showNames}
|
||||
?inside-labels=${showNames}
|
||||
.logarithmicScale=${this._config.logarithmic_scale || false}
|
||||
.minYAxis=${this._config.min_y_axis}
|
||||
.maxYAxis=${this._config.max_y_axis}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { endOfDay, startOfDay } from "date-fns";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
@@ -39,6 +40,10 @@ import {
|
||||
filterLowBatteryEntities,
|
||||
filterUnavailableBatteryEntities,
|
||||
} from "../../maintenance/strategies/maintenance-view-strategy";
|
||||
import {
|
||||
isSecurityAlertActive,
|
||||
resolveSecurityAlertSeverity,
|
||||
} from "../../security/strategies/security-alerts";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import { tileCardStyle } from "./tile/tile-card-style";
|
||||
import type { HomeSummaryCard } from "./types";
|
||||
@@ -179,6 +184,16 @@ export class HuiHomeSummaryCard
|
||||
? `${formattedMinTemp}°`
|
||||
: `${formattedMinTemp} - ${formattedMaxTemp}°`;
|
||||
}
|
||||
case "alerts": {
|
||||
const count = (this._config.alert_entities ?? []).filter(
|
||||
(alertEntity) => isSecurityAlertActive(this.hass!, alertEntity.entity)
|
||||
).length;
|
||||
return count
|
||||
? this.hass.localize("ui.card.home-summary.count_active_alerts", {
|
||||
count,
|
||||
})
|
||||
: "";
|
||||
}
|
||||
case "security": {
|
||||
// Alarm and lock status
|
||||
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
|
||||
@@ -202,8 +217,22 @@ export class HuiHomeSummaryCard
|
||||
return s === "disarmed";
|
||||
});
|
||||
|
||||
const warningCount = (this._config.alert_entities ?? []).filter(
|
||||
(alertEntity) =>
|
||||
resolveSecurityAlertSeverity(
|
||||
alertEntity,
|
||||
this.hass!.states[alertEntity.entity]
|
||||
) === "warning" &&
|
||||
isSecurityAlertActive(this.hass!, alertEntity.entity)
|
||||
).length;
|
||||
const warningText = warningCount
|
||||
? this.hass.localize("ui.card.home-summary.count_warnings", {
|
||||
count: warningCount,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!locks.length && !alarms.length) {
|
||||
return "";
|
||||
return warningText ?? "";
|
||||
}
|
||||
|
||||
const unlockedLocks = locks.filter((entityId) => {
|
||||
@@ -211,23 +240,18 @@ export class HuiHomeSummaryCard
|
||||
return s === "unlocked" || s === "jammed" || s === "open";
|
||||
});
|
||||
|
||||
if (unlockedLocks.length) {
|
||||
return this.hass.localize(
|
||||
"ui.card.home-summary.count_locks_unlocked",
|
||||
{
|
||||
const statusText = unlockedLocks.length
|
||||
? this.hass.localize("ui.card.home-summary.count_locks_unlocked", {
|
||||
count: unlockedLocks.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (disarmedAlarms.length) {
|
||||
return this.hass.localize(
|
||||
"ui.card.home-summary.count_alarms_disarmed",
|
||||
{
|
||||
count: disarmedAlarms.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
return this.hass.localize("ui.card.home-summary.all_secure");
|
||||
})
|
||||
: disarmedAlarms.length
|
||||
? this.hass.localize("ui.card.home-summary.count_alarms_disarmed", {
|
||||
count: disarmedAlarms.length,
|
||||
})
|
||||
: warningText
|
||||
? undefined
|
||||
: this.hass.localize("ui.card.home-summary.all_secure");
|
||||
return [warningText, statusText].filter(Boolean).join(", ");
|
||||
}
|
||||
case "media_players": {
|
||||
// Playing media
|
||||
@@ -338,23 +362,29 @@ export class HuiHomeSummaryCard
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const color = computeCssColor(HOME_SUMMARIES_COLORS[this._config.summary]);
|
||||
const summary = this._config.summary;
|
||||
const isAlertsSummary = summary === "alerts";
|
||||
const color = computeCssColor(HOME_SUMMARIES_COLORS[summary]);
|
||||
|
||||
const style = {
|
||||
"--tile-color": color,
|
||||
"--ha-alert-color": isAlertsSummary ? color : undefined,
|
||||
};
|
||||
|
||||
const secondary = this._computeSummaryState();
|
||||
const secondaryLoading = this._computeSecondaryLoading(
|
||||
this._config.summary,
|
||||
summary,
|
||||
this._energyData
|
||||
);
|
||||
|
||||
const label = getSummaryLabel(this.hass.localize, this._config.summary);
|
||||
const icon = HOME_SUMMARIES_ICONS[this._config.summary];
|
||||
const label = getSummaryLabel(this.hass.localize, summary);
|
||||
const icon = HOME_SUMMARIES_ICONS[summary];
|
||||
|
||||
return html`
|
||||
<ha-card style=${styleMap(style)}>
|
||||
<ha-card
|
||||
class=${classMap({ alert: isAlertsSummary })}
|
||||
style=${styleMap(style)}
|
||||
>
|
||||
<ha-tile-container
|
||||
.vertical=${Boolean(this._config.vertical)}
|
||||
.interactive=${this._hasCardAction}
|
||||
@@ -381,6 +411,24 @@ export class HuiHomeSummaryCard
|
||||
css`
|
||||
:host {
|
||||
--tile-color: var(--state-inactive-color);
|
||||
--ha-alert-pulse-opacity: 0.3;
|
||||
}
|
||||
ha-card.alert {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
--tile-color: var(--ha-alert-color);
|
||||
}
|
||||
ha-card.alert::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
background-color: var(--ha-alert-color);
|
||||
content: "";
|
||||
opacity: var(--ha-alert-pulse-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-card.alert ha-tile-container {
|
||||
position: relative;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { mdiDotsVertical } from "@mdi/js";
|
||||
import "@thomasloven/round-slider";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { stateColorBrightness } from "../../../common/entity/state_color";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-control-circular-slider";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-state-icon";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
@@ -108,12 +109,14 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
|
||||
<div class="content">
|
||||
<div id="controls">
|
||||
<div id="slider">
|
||||
<!-- @ts-ignore Round-slider has no tag definition or exported type -->
|
||||
<round-slider
|
||||
min="1"
|
||||
max="100"
|
||||
<ha-control-circular-slider
|
||||
mode="start"
|
||||
.min=${1}
|
||||
.max=${100}
|
||||
.step=${1}
|
||||
.value=${brightness}
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
prevent-interaction-on-scroll
|
||||
@value-changing=${this._dragEvent}
|
||||
@value-changed=${this._setBrightness}
|
||||
style=${styleMap({
|
||||
@@ -121,7 +124,7 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
|
||||
? "visible"
|
||||
: "hidden",
|
||||
})}
|
||||
></round-slider>
|
||||
></ha-control-circular-slider>
|
||||
<ha-icon-button
|
||||
class="light-button ${classMap({
|
||||
"slider-center": lightSupportsBrightness(stateObj),
|
||||
@@ -191,9 +194,10 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
}
|
||||
|
||||
private _dragEvent(e: any): void {
|
||||
this.shadowRoot!.querySelector(".brightness")!.innerHTML =
|
||||
`${e.detail.value} %`;
|
||||
private _dragEvent(ev: HASSDomEvent<HASSDomEvents["value-changing"]>): void {
|
||||
const { value } = ev.detail;
|
||||
if (typeof value !== "number" || isNaN(value)) return;
|
||||
this.shadowRoot!.querySelector(".brightness")!.innerHTML = `${value} %`;
|
||||
this._showBrightness();
|
||||
this._hideBrightness();
|
||||
}
|
||||
@@ -213,10 +217,14 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private _setBrightness(e: any): void {
|
||||
private _setBrightness(
|
||||
ev: HASSDomEvent<HASSDomEvents["value-changed"]>
|
||||
): void {
|
||||
const { value } = ev.detail;
|
||||
if (typeof value !== "number" || isNaN(value)) return;
|
||||
this.hass!.callService("light", "turn_on", {
|
||||
entity_id: this._config!.entity,
|
||||
brightness_pct: e.detail.value,
|
||||
brightness_pct: value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,10 +300,11 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
round-slider {
|
||||
--round-slider-path-color: var(--slider-track-color);
|
||||
--round-slider-bar-color: var(--primary-color);
|
||||
padding-bottom: 10%;
|
||||
ha-control-circular-slider {
|
||||
width: 100%;
|
||||
--control-circular-slider-color: var(--primary-color);
|
||||
--control-circular-slider-background: var(--slider-track-color);
|
||||
--control-circular-slider-background-opacity: 1;
|
||||
}
|
||||
|
||||
.light-button {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { EntityNameItem } from "../../../common/entity/compute_entity_name_
|
||||
import type { HaDurationData } from "../../../components/ha-duration-input";
|
||||
import type { MapCardMarkerLabelMode } from "../../../components/map/ha-map";
|
||||
import type { EnergySourceByType } from "../../../data/energy";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import type { ActionConfig } from "../../../data/lovelace/config/action";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type {
|
||||
@@ -714,6 +715,7 @@ export interface HeadingCardConfig extends LovelaceCardConfig {
|
||||
|
||||
export interface HomeSummaryCard extends LovelaceCardConfig {
|
||||
summary: HomeSummary;
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
vertical?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
|
||||
@@ -12,9 +12,11 @@ import { computeDeviceName } from "../../../../common/entity/compute_device_name
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import { entityComboBoxKeys } from "../../../../data/entity/entity_picker";
|
||||
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
import { multiTermSortedSearch } from "../../../../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -24,6 +26,7 @@ export interface DeviceNode {
|
||||
id: string;
|
||||
name: string;
|
||||
entityIds: string[];
|
||||
children: DeviceNode[];
|
||||
}
|
||||
|
||||
export interface AreaNode {
|
||||
@@ -132,6 +135,30 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
|
||||
const unassignedEntityByDomain = new Map<string, string[]>();
|
||||
const searchableEntities: SearchableEntity[] = [];
|
||||
|
||||
const addDeviceEntity = (
|
||||
bucket: Map<string, string[]>,
|
||||
device: DeviceRegistryEntry,
|
||||
entityId: string,
|
||||
areaId: string | undefined
|
||||
) => {
|
||||
const list = bucket.get(device.id) ?? [];
|
||||
list.push(entityId);
|
||||
bucket.set(device.id, list);
|
||||
// A child device nests under its parent when both land in the same
|
||||
// bucket, so the parent needs a row even without entities of its own.
|
||||
const parent = device.parent_device_id
|
||||
? deviceReg[device.parent_device_id]
|
||||
: undefined;
|
||||
if (
|
||||
parent &&
|
||||
getDeviceAreaId(parent, deviceReg) === areaId &&
|
||||
(parent.entry_type === "service") === (device.entry_type === "service") &&
|
||||
!bucket.has(parent.id)
|
||||
) {
|
||||
bucket.set(parent.id, []);
|
||||
}
|
||||
};
|
||||
|
||||
for (const entityId of Object.keys(states)) {
|
||||
const stateObj = states[entityId];
|
||||
if (!stateObj) continue;
|
||||
@@ -182,9 +209,7 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
|
||||
const target = isService
|
||||
? unassignedServiceEntities
|
||||
: unassignedDeviceEntities;
|
||||
const list = target.get(device.id) ?? [];
|
||||
list.push(entityId);
|
||||
target.set(device.id, list);
|
||||
addDeviceEntity(target, device, entityId, undefined);
|
||||
} else if (isHelperDomain(domain)) {
|
||||
const list = unassignedHelperByDomain.get(domain) ?? [];
|
||||
list.push(entityId);
|
||||
@@ -200,9 +225,7 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
|
||||
const groupUnderDevice = device && !entry?.area_id;
|
||||
if (groupUnderDevice) {
|
||||
const byDevice = areaDeviceEntities.get(areaId) ?? new Map();
|
||||
const list = byDevice.get(device!.id) ?? [];
|
||||
list.push(entityId);
|
||||
byDevice.set(device!.id, list);
|
||||
addDeviceEntity(byDevice, device!, entityId, areaId);
|
||||
areaDeviceEntities.set(areaId, byDevice);
|
||||
} else {
|
||||
const list = areaDirectEntities.get(areaId) ?? [];
|
||||
@@ -217,17 +240,35 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
|
||||
return stringCompare(an, bn, language);
|
||||
};
|
||||
|
||||
const buildDeviceNodes = (source: Map<string, string[]>): DeviceNode[] =>
|
||||
[...source.entries()]
|
||||
.map(([id, ids]) => {
|
||||
const device = deviceReg[id];
|
||||
return {
|
||||
id,
|
||||
name: (device ? computeDeviceName(device) : undefined) ?? id,
|
||||
entityIds: ids.sort(sortByName),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
const sortDeviceNodes = (nodes: DeviceNode[]) => {
|
||||
nodes.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
nodes.forEach((node) => sortDeviceNodes(node.children));
|
||||
};
|
||||
|
||||
const buildDeviceNodes = (source: Map<string, string[]>): DeviceNode[] => {
|
||||
const nodes = new Map<string, DeviceNode>();
|
||||
for (const [id, ids] of source) {
|
||||
const device = deviceReg[id];
|
||||
nodes.set(id, {
|
||||
id,
|
||||
name: (device ? computeDeviceName(device) : undefined) ?? id,
|
||||
entityIds: ids.sort(sortByName),
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
const roots: DeviceNode[] = [];
|
||||
for (const node of nodes.values()) {
|
||||
const parentId = deviceReg[node.id]?.parent_device_id;
|
||||
const parent = parentId ? nodes.get(parentId) : undefined;
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
sortDeviceNodes(roots);
|
||||
return roots;
|
||||
};
|
||||
|
||||
const buildAreaNode = (areaId: string): AreaNode | undefined => {
|
||||
const area = areaReg[areaId];
|
||||
@@ -325,17 +366,28 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
|
||||
};
|
||||
}
|
||||
|
||||
const devicePathToEntity = (
|
||||
devices: DeviceNode[],
|
||||
parentKey: string,
|
||||
entityId: string
|
||||
): string[] | undefined => {
|
||||
for (const device of devices) {
|
||||
const key = deviceKey(parentKey, device.id);
|
||||
if (device.entityIds.includes(entityId)) return [key];
|
||||
const nested = devicePathToEntity(device.children, key, entityId);
|
||||
if (nested) return [key, ...nested];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export function pathToEntity(tree: EntityTree, entityId: string): string[] {
|
||||
for (const floor of tree.floors) {
|
||||
const fKey = floorKey(floor.id);
|
||||
for (const area of floor.areas) {
|
||||
const aKey = areaKey(fKey, area.id);
|
||||
if (area.directEntityIds.includes(entityId)) return [fKey, aKey];
|
||||
for (const device of area.devices) {
|
||||
if (device.entityIds.includes(entityId)) {
|
||||
return [fKey, aKey, deviceKey(aKey, device.id)];
|
||||
}
|
||||
}
|
||||
const devicePath = devicePathToEntity(area.devices, aKey, entityId);
|
||||
if (devicePath) return [fKey, aKey, ...devicePath];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,21 +397,15 @@ export function pathToEntity(tree: EntityTree, entityId: string): string[] {
|
||||
if (area.directEntityIds.includes(entityId)) {
|
||||
return [otherAreasFloor, aKey];
|
||||
}
|
||||
for (const device of area.devices) {
|
||||
if (device.entityIds.includes(entityId)) {
|
||||
return [otherAreasFloor, aKey, deviceKey(aKey, device.id)];
|
||||
}
|
||||
}
|
||||
const devicePath = devicePathToEntity(area.devices, aKey, entityId);
|
||||
if (devicePath) return [otherAreasFloor, aKey, ...devicePath];
|
||||
}
|
||||
|
||||
for (const section of tree.unassignedSections) {
|
||||
const sKey = unassignedKey(section.id);
|
||||
if (section.devices) {
|
||||
for (const device of section.devices) {
|
||||
if (device.entityIds.includes(entityId)) {
|
||||
return [sKey, deviceKey(sKey, device.id)];
|
||||
}
|
||||
}
|
||||
const devicePath = devicePathToEntity(section.devices, sKey, entityId);
|
||||
if (devicePath) return [sKey, ...devicePath];
|
||||
}
|
||||
if (section.domains) {
|
||||
for (const group of section.domains) {
|
||||
|
||||
@@ -416,14 +416,18 @@ export class HuiSuggestionEntityTree extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderDevice(device: DeviceNode, parentKey: string): TemplateResult {
|
||||
private _renderDevice(
|
||||
device: DeviceNode,
|
||||
parentKey: string,
|
||||
nested = false
|
||||
): TemplateResult {
|
||||
const key = deviceKey(parentKey, device.id);
|
||||
const expanded = this._isExpanded(key);
|
||||
const domain = this._deviceDomain(device.id);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
class="branch depth-device device-item"
|
||||
class="branch ${nested ? "depth-device-nested" : "depth-device"} device-item"
|
||||
aria-expanded=${expanded}
|
||||
data-node-key=${key}
|
||||
@click=${this._toggleNode}
|
||||
@@ -444,11 +448,24 @@ export class HuiSuggestionEntityTree extends LitElement {
|
||||
</ha-combo-box-item>
|
||||
${
|
||||
expanded
|
||||
? repeat(
|
||||
device.entityIds,
|
||||
(id: string) => id,
|
||||
(id: string) => this._renderEntity(id, "depth-entity-device")
|
||||
)
|
||||
? html`
|
||||
${repeat(
|
||||
device.children,
|
||||
(child: DeviceNode) => child.id,
|
||||
(child: DeviceNode) => this._renderDevice(child, key, true)
|
||||
)}
|
||||
${repeat(
|
||||
device.entityIds,
|
||||
(id: string) => id,
|
||||
(id: string) =>
|
||||
this._renderEntity(
|
||||
id,
|
||||
nested
|
||||
? "depth-entity-device-nested"
|
||||
: "depth-entity-device"
|
||||
)
|
||||
)}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
@@ -650,9 +667,13 @@ export class HuiSuggestionEntityTree extends LitElement {
|
||||
ha-combo-box-item.depth-entity-area {
|
||||
--md-list-item-leading-space: var(--ha-space-12);
|
||||
}
|
||||
ha-combo-box-item.depth-entity-device {
|
||||
ha-combo-box-item.depth-entity-device,
|
||||
ha-combo-box-item.depth-device-nested {
|
||||
--md-list-item-leading-space: var(--ha-space-16);
|
||||
}
|
||||
ha-combo-box-item.depth-entity-device-nested {
|
||||
--md-list-item-leading-space: var(--ha-space-20);
|
||||
}
|
||||
.leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -173,6 +173,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
|
||||
"media-player-volume-slider",
|
||||
"numeric-input",
|
||||
"select-options",
|
||||
"target-humidity",
|
||||
"timer-actions",
|
||||
"timer-presets",
|
||||
"trend-graph",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { html, nothing, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
TargetHumidityCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "../../card-features/types";
|
||||
import type { LovelaceCardFeatureEditor } from "../../types";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
|
||||
@customElement("hui-target-humidity-card-feature-editor")
|
||||
export class HuiTargetHumidityCardFeatureEditor
|
||||
extends LitElement
|
||||
implements LovelaceCardFeatureEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
|
||||
|
||||
@state() private _config?: TargetHumidityCardFeatureConfig;
|
||||
|
||||
public setConfig(config: TargetHumidityCardFeatureConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{
|
||||
name: "style",
|
||||
selector: {
|
||||
select: {
|
||||
multiple: false,
|
||||
mode: "list",
|
||||
options: ["slider", "buttons"].map((mode) => ({
|
||||
value: mode,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.features.types.target-humidity.style_list.${mode}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const data: TargetHumidityCardFeatureConfig = {
|
||||
style: "slider",
|
||||
...this._config,
|
||||
};
|
||||
|
||||
const schema = this._schema(this.hass.localize);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.target-humidity.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-target-humidity-card-feature-editor": HuiTargetHumidityCardFeatureEditor;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { lightEntityFilters } from "../../../../light/strategies/light-view-stra
|
||||
import { securityEntityFilters } from "../../../../security/strategies/security-view-strategy";
|
||||
|
||||
export const HOME_SUMMARIES = [
|
||||
"alerts",
|
||||
"light",
|
||||
"climate",
|
||||
"security",
|
||||
@@ -18,6 +19,7 @@ export const HOME_SUMMARIES = [
|
||||
export type HomeSummary = (typeof HOME_SUMMARIES)[number];
|
||||
|
||||
export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
|
||||
alerts: "mdi:alarm-plus",
|
||||
light: "mdi:lamps",
|
||||
climate: "mdi:home-thermometer",
|
||||
security: "mdi:security",
|
||||
@@ -28,6 +30,7 @@ export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
|
||||
};
|
||||
|
||||
export const HOME_SUMMARIES_COLORS: Record<HomeSummary, string> = {
|
||||
alerts: "red",
|
||||
light: "amber",
|
||||
climate: "deep-orange",
|
||||
security: "blue-grey",
|
||||
@@ -38,6 +41,7 @@ export const HOME_SUMMARIES_COLORS: Record<HomeSummary, string> = {
|
||||
};
|
||||
|
||||
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
|
||||
alerts: [],
|
||||
light: lightEntityFilters,
|
||||
climate: climateEntityFilters,
|
||||
security: securityEntityFilters,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { STATE_NOT_RUNNING } from "home-assistant-js-websocket";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { SecurityAlertEntityConfig } from "../../../../data/frontend";
|
||||
import type { ShortcutItem } from "../../../../data/home_shortcuts";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -14,11 +16,11 @@ import {
|
||||
} from "./helpers/home-summaries";
|
||||
import type { HomeAreaViewStrategyConfig } from "./home-area-view-strategy";
|
||||
import type { HomeOtherDevicesViewStrategyConfig } from "./home-other-devices-view-strategy";
|
||||
import type { ShortcutItem } from "../../../../data/home_shortcuts";
|
||||
import type { HomeOverviewViewStrategyConfig } from "./home-overview-view-strategy";
|
||||
|
||||
export interface HomeDashboardStrategyConfig {
|
||||
type: "home";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
hide_welcome_message?: boolean;
|
||||
@@ -103,6 +105,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
|
||||
path: "overview",
|
||||
strategy: {
|
||||
type: "home-overview",
|
||||
alert_entities: config.alert_entities,
|
||||
favorite_entities: config.favorite_entities,
|
||||
home_panel: config.home_panel,
|
||||
hide_welcome_message: config.hide_welcome_message,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
|
||||
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
|
||||
import type { EnergyPreferences } from "../../../../data/energy";
|
||||
import { getEnergyPreferences } from "../../../../data/energy";
|
||||
import type { SecurityAlertEntityConfig } from "../../../../data/frontend";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type {
|
||||
LovelaceSectionConfig,
|
||||
@@ -24,6 +25,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import { hasClimateEntities } from "../../../climate/strategies/climate-view-strategy";
|
||||
import type {
|
||||
AreaCardConfig,
|
||||
ConditionalCardConfig,
|
||||
DiscoveredDevicesCardConfig,
|
||||
EmptyStateCardConfig,
|
||||
HeadingCardConfig,
|
||||
@@ -35,17 +37,24 @@ import type {
|
||||
UpdatesCardConfig,
|
||||
} from "../../cards/types";
|
||||
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
|
||||
import {
|
||||
computeDefaultSecurityAlertVisibility,
|
||||
filterSecurityAlertEntities,
|
||||
resolveSecurityAlertSeverity,
|
||||
} from "../../../security/strategies/security-alerts";
|
||||
import {
|
||||
LARGE_SCREEN_CONDITION,
|
||||
SMALL_SCREEN_CONDITION,
|
||||
} from "../helpers/view-columns-conditions";
|
||||
import type { LovelaceStrategyDependency } from "../types";
|
||||
import type { CommonControlsSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
|
||||
import { generateLovelaceSectionStrategy } from "../get-strategy";
|
||||
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
|
||||
import { OTHER_DEVICES_FILTERS } from "./helpers/other-devices-filters";
|
||||
|
||||
export interface HomeOverviewViewStrategyConfig {
|
||||
type: "home-overview";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
hide_welcome_message?: boolean;
|
||||
@@ -112,6 +121,28 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
"panels",
|
||||
];
|
||||
|
||||
static shouldRegenerate(
|
||||
config: HomeOverviewViewStrategyConfig,
|
||||
oldHass: HomeAssistant,
|
||||
newHass: HomeAssistant
|
||||
) {
|
||||
return (
|
||||
this.registryDependencies.some((key) => oldHass[key] !== newHass[key]) ||
|
||||
(config.alert_entities?.some(
|
||||
(alertEntity) =>
|
||||
resolveSecurityAlertSeverity(
|
||||
alertEntity,
|
||||
oldHass.states[alertEntity.entity]
|
||||
) !==
|
||||
resolveSecurityAlertSeverity(
|
||||
alertEntity,
|
||||
newHass.states[alertEntity.entity]
|
||||
)
|
||||
) ??
|
||||
false)
|
||||
);
|
||||
}
|
||||
|
||||
static async generate(
|
||||
config: HomeOverviewViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
@@ -256,16 +287,25 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
|
||||
let favoritesSection: LovelaceSectionRawConfig | undefined;
|
||||
if (!config.hide_suggested_entities) {
|
||||
favoritesSection = {
|
||||
strategy: {
|
||||
type: "common-controls",
|
||||
limit: maxCommonControls,
|
||||
include_entities: favoriteEntities,
|
||||
hide_empty: true,
|
||||
heading: favoritesHeadingCard,
|
||||
} satisfies CommonControlsSectionStrategyConfig,
|
||||
column_span: maxColumns,
|
||||
} satisfies LovelaceStrategySectionConfig;
|
||||
const generatedFavoritesSection = await generateLovelaceSectionStrategy(
|
||||
{
|
||||
strategy: {
|
||||
type: "common-controls",
|
||||
limit: maxCommonControls,
|
||||
include_entities: favoriteEntities,
|
||||
hide_empty: true,
|
||||
heading: favoritesHeadingCard,
|
||||
} satisfies CommonControlsSectionStrategyConfig,
|
||||
column_span: maxColumns,
|
||||
} satisfies LovelaceStrategySectionConfig,
|
||||
hass
|
||||
);
|
||||
if (!generatedFavoritesSection.disabled) {
|
||||
favoritesSection = {
|
||||
...generatedFavoritesSection,
|
||||
column_span: maxColumns,
|
||||
};
|
||||
}
|
||||
} else if (favoriteEntities.length > 0) {
|
||||
favoritesSection = {
|
||||
type: "grid",
|
||||
@@ -305,6 +345,17 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
hass.panels.maintenance &&
|
||||
findEntities(allEntities, maintenanceFilters).length > 0;
|
||||
|
||||
const alertEntities = config.alert_entities ?? [];
|
||||
const alertSeverityEntities = filterSecurityAlertEntities(
|
||||
alertEntities,
|
||||
hass,
|
||||
"alert"
|
||||
);
|
||||
const alertActiveConditions = alertSeverityEntities.map((alertEntity) => ({
|
||||
condition: "and" as const,
|
||||
conditions: computeDefaultSecurityAlertVisibility(alertEntity.entity),
|
||||
}));
|
||||
|
||||
const weatherFilter = generateEntityFilter(hass, {
|
||||
domain: "weather",
|
||||
entity_category: "none",
|
||||
@@ -350,17 +401,23 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
},
|
||||
} satisfies HomeSummaryCard)
|
||||
: undefined,
|
||||
security: () =>
|
||||
hasSecurity
|
||||
? ({
|
||||
type: "home-summary",
|
||||
summary: "security",
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/security?historyBack=1",
|
||||
},
|
||||
} satisfies HomeSummaryCard)
|
||||
: undefined,
|
||||
security: () => {
|
||||
if (!hasSecurity) {
|
||||
return undefined;
|
||||
}
|
||||
const card: HomeSummaryCard = {
|
||||
type: "home-summary",
|
||||
summary: "security",
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/security?historyBack=1",
|
||||
},
|
||||
};
|
||||
if (alertEntities.length) {
|
||||
card.alert_entities = alertEntities;
|
||||
}
|
||||
return card;
|
||||
},
|
||||
media_players: () =>
|
||||
hasMediaPlayers
|
||||
? ({
|
||||
@@ -454,6 +511,10 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
const hasVisibleSummaryCards = summaryCards.some(
|
||||
(card) => !("hide_empty" in card && card.hide_empty)
|
||||
);
|
||||
|
||||
// Build summary cards for sidebar (full width: columns 12)
|
||||
const sidebarSummaryCards = summaryCards.map((card) => ({
|
||||
...card,
|
||||
@@ -474,6 +535,33 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
heading_style: "title",
|
||||
};
|
||||
|
||||
const alertsCard: HomeSummaryCard | undefined = alertSeverityEntities.length
|
||||
? ({
|
||||
type: "home-summary",
|
||||
summary: "alerts",
|
||||
alert_entities: alertSeverityEntities,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/security?historyBack=1",
|
||||
},
|
||||
visibility: [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: alertActiveConditions,
|
||||
},
|
||||
],
|
||||
} satisfies HomeSummaryCard)
|
||||
: undefined;
|
||||
|
||||
const mobileAlertsSection: LovelaceSectionConfig | undefined = alertsCard
|
||||
? {
|
||||
type: "grid",
|
||||
column_span: maxColumns,
|
||||
visibility: [SMALL_SCREEN_CONDITION],
|
||||
cards: [{ ...alertsCard, grid_options: { columns: 6 } }],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Mobile summary section (visible on small screens only)
|
||||
const mobileSummarySection: LovelaceSectionConfig | undefined =
|
||||
mobileSummaryCards.length > 0
|
||||
@@ -487,7 +575,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
|
||||
// Sidebar section
|
||||
const sidebarSection: LovelaceSectionConfig | undefined =
|
||||
sidebarSummaryCards.length > 0
|
||||
sidebarSummaryCards.length > 0 || alertsCard
|
||||
? {
|
||||
type: "grid",
|
||||
cards: [
|
||||
@@ -495,67 +583,103 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
...summaryHeadingCard,
|
||||
grid_options: { rows: "auto" }, // Compact style
|
||||
},
|
||||
...(alertsCard
|
||||
? [{ ...alertsCard, grid_options: { columns: 12 } }]
|
||||
: []),
|
||||
...sidebarSummaryCards,
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const emptyStateCard = {
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
content_only: true,
|
||||
title: hass.localize("ui.panel.lovelace.strategy.home.welcome_title"),
|
||||
content: hass.localize("ui.panel.lovelace.strategy.home.welcome_content"),
|
||||
...(config.home_panel && hass.user?.is_admin
|
||||
? {
|
||||
buttons: [
|
||||
{
|
||||
icon: "mdi:plus",
|
||||
text: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_add_device"
|
||||
),
|
||||
appearance: "filled" as const,
|
||||
variant: "brand" as const,
|
||||
tap_action: {
|
||||
action: "fire-dom-event" as const,
|
||||
home_panel: {
|
||||
type: "add_integration",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "mdi:home-edit",
|
||||
text: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
|
||||
),
|
||||
appearance: "plain" as const,
|
||||
variant: "brand" as const,
|
||||
tap_action: {
|
||||
action: "navigate" as const,
|
||||
navigation_path: "/config/areas/dashboard",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} as EmptyStateCardConfig;
|
||||
|
||||
// No sections, show empty state
|
||||
if (floorsSections.length === 0) {
|
||||
if (
|
||||
floorsSections.length === 0 &&
|
||||
!alertsCard &&
|
||||
!favoritesSection &&
|
||||
!hasVisibleSummaryCards
|
||||
) {
|
||||
return {
|
||||
type: "panel",
|
||||
cards: [
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_title"
|
||||
),
|
||||
content: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_content"
|
||||
),
|
||||
...(config.home_panel && hass.user?.is_admin
|
||||
? {
|
||||
buttons: [
|
||||
{
|
||||
icon: "mdi:plus",
|
||||
text: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_add_device"
|
||||
),
|
||||
appearance: "filled",
|
||||
variant: "brand",
|
||||
tap_action: {
|
||||
action: "fire-dom-event",
|
||||
home_panel: {
|
||||
type: "add_integration",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "mdi:home-edit",
|
||||
text: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
|
||||
),
|
||||
appearance: "plain",
|
||||
variant: "brand",
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/config/areas/dashboard",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} as EmptyStateCardConfig,
|
||||
],
|
||||
cards: [emptyStateCard],
|
||||
};
|
||||
}
|
||||
|
||||
const emptyStateSection: LovelaceSectionConfig | undefined =
|
||||
floorsSections.length === 0 &&
|
||||
!favoritesSection &&
|
||||
!hasVisibleSummaryCards &&
|
||||
alertSeverityEntities.length
|
||||
? {
|
||||
type: "grid",
|
||||
column_span: maxColumns,
|
||||
cards: [
|
||||
{
|
||||
type: "conditional",
|
||||
conditions: [
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: alertActiveConditions,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
card: emptyStateCard,
|
||||
} satisfies ConditionalCardConfig,
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const sections = (
|
||||
[favoritesSection, mobileSummarySection, ...floorsSections] satisfies (
|
||||
LovelaceSectionRawConfig | undefined
|
||||
)[]
|
||||
[
|
||||
emptyStateSection,
|
||||
mobileAlertsSection,
|
||||
favoritesSection,
|
||||
mobileSummarySection,
|
||||
...floorsSections,
|
||||
] satisfies (LovelaceSectionRawConfig | undefined)[]
|
||||
).filter(Boolean) as LovelaceSectionRawConfig[];
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,9 +4,11 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
|
||||
import {
|
||||
getImageEntityIdFromMediaSourceContentId,
|
||||
isMediaSourceContentId,
|
||||
resolveMediaSourceWithCache,
|
||||
} from "../../../data/media_source";
|
||||
import { computeImageUrl } from "../../../data/image";
|
||||
|
||||
@customElement("hui-view-background")
|
||||
export class HUIViewBackground extends LitElement {
|
||||
@@ -17,6 +19,8 @@ export class HUIViewBackground extends LitElement {
|
||||
|
||||
@state({ attribute: false }) resolvedImage?: string;
|
||||
|
||||
private _entityId: string | undefined = undefined;
|
||||
|
||||
protected render() {
|
||||
return nothing;
|
||||
}
|
||||
@@ -37,17 +41,41 @@ export class HUIViewBackground extends LitElement {
|
||||
const backgroundImage = this._getBackgroundImage(this.background);
|
||||
|
||||
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
|
||||
this._entityId = undefined;
|
||||
this.resolvedImage = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedUrl: string | undefined;
|
||||
try {
|
||||
resolvedUrl = (
|
||||
await resolveMediaSourceWithCache(this.hass, backgroundImage)
|
||||
).url;
|
||||
} catch {
|
||||
resolvedUrl = undefined;
|
||||
this._entityId = getImageEntityIdFromMediaSourceContentId(backgroundImage);
|
||||
if (this._entityId) {
|
||||
const stateObj = this.hass.states[this._entityId];
|
||||
if (stateObj) {
|
||||
const url = computeImageUrl(stateObj);
|
||||
if (url) {
|
||||
const image = new Image();
|
||||
image.src = this.hass.hassUrl(url);
|
||||
try {
|
||||
await image.decode();
|
||||
const newStateObj = this.hass.states[this._entityId];
|
||||
// Discard if stale
|
||||
if (url !== computeImageUrl(newStateObj)) {
|
||||
return;
|
||||
}
|
||||
resolvedUrl = url;
|
||||
} catch {
|
||||
resolvedUrl = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
resolvedUrl = (
|
||||
await resolveMediaSourceWithCache(this.hass, backgroundImage)
|
||||
).url;
|
||||
} catch {
|
||||
resolvedUrl = undefined;
|
||||
}
|
||||
}
|
||||
// Discard if the background changed while resolving
|
||||
if (this._getBackgroundImage(this.background) === backgroundImage) {
|
||||
@@ -131,6 +159,13 @@ export class HUIViewBackground extends LitElement {
|
||||
) {
|
||||
applyTheme = true;
|
||||
}
|
||||
if (
|
||||
this._entityId &&
|
||||
this.hass.states[this._entityId] !== oldHass?.states[this._entityId]
|
||||
) {
|
||||
this._fetchMedia();
|
||||
applyTheme = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has("background")) {
|
||||
|
||||
@@ -346,6 +346,9 @@ export class HuiViewHeader extends LitElement {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
display: flex;
|
||||
min-height: calc(
|
||||
var(--ha-font-size-xl) * var(--ha-line-height-condensed) + 4px
|
||||
);
|
||||
}
|
||||
|
||||
.heading > * {
|
||||
@@ -353,6 +356,10 @@ export class HuiViewHeader extends LitElement {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container:not(.edit-mode) .heading:has(> *[hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.badges {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
||||
@@ -391,10 +391,19 @@ export const getMyRedirects = (): Redirects => ({
|
||||
component: "hassio",
|
||||
redirect: "/config/apps/available",
|
||||
},
|
||||
supervisor_apps: {
|
||||
component: "hassio",
|
||||
redirect: "/config/apps",
|
||||
},
|
||||
supervisor_addons: {
|
||||
component: "hassio",
|
||||
redirect: "/config/apps",
|
||||
},
|
||||
supervisor: {
|
||||
// Supervisor panel was removed in 2026.2, fallback to apps
|
||||
component: "hassio",
|
||||
redirect: "/config/apps",
|
||||
},
|
||||
supervisor_app: {
|
||||
component: "hassio",
|
||||
redirect: "/config/app",
|
||||
@@ -431,9 +440,6 @@ export const getMyRedirects = (): Redirects => ({
|
||||
category: "string?",
|
||||
},
|
||||
},
|
||||
lights: {
|
||||
redirect: "/lights",
|
||||
},
|
||||
});
|
||||
|
||||
const getRedirect = (path: string): Redirect | undefined =>
|
||||
|
||||
@@ -4,8 +4,12 @@ import type {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityAlertSeverity,
|
||||
} from "../../../data/frontend";
|
||||
import type { StateCondition } from "../../lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { AlertCardConfig } from "../../lovelace/cards/types";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
type StateCondition,
|
||||
} from "../../lovelace/common/validate-condition";
|
||||
|
||||
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
|
||||
"carbon_monoxide",
|
||||
@@ -74,12 +78,36 @@ export const computeDefaultSecurityAlertVisibility = (
|
||||
return [condition];
|
||||
};
|
||||
|
||||
export const resolveSecurityAlertSeverity = (
|
||||
alertEntity: SecurityAlertEntityConfig,
|
||||
stateObj?: HassEntity
|
||||
): SecurityAlertSeverity =>
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
|
||||
export const isSecurityAlertActive = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string
|
||||
): boolean =>
|
||||
checkConditionsMet(computeDefaultSecurityAlertVisibility(entityId), hass, {});
|
||||
|
||||
export const filterSecurityAlertEntities = (
|
||||
alertEntities: SecurityAlertEntityConfig[],
|
||||
hass: HomeAssistant,
|
||||
severity: SecurityAlertSeverity
|
||||
): SecurityAlertEntityConfig[] =>
|
||||
alertEntities.filter(
|
||||
(alertEntity) =>
|
||||
resolveSecurityAlertSeverity(
|
||||
alertEntity,
|
||||
hass.states[alertEntity.entity]
|
||||
) === severity
|
||||
);
|
||||
|
||||
export const computeSecurityAlertCardConfig = (
|
||||
stateObj: HassEntity | undefined,
|
||||
alertEntity: SecurityAlertEntityConfig
|
||||
): AlertCardConfig => {
|
||||
const severity =
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
const severity = resolveSecurityAlertSeverity(alertEntity, stateObj);
|
||||
return {
|
||||
type: "alert",
|
||||
entity: alertEntity.entity,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import type { ConditionDescriptions } from "../data/condition";
|
||||
import { subscribeConditions } from "../data/condition";
|
||||
import {
|
||||
ConfigEntryStream,
|
||||
type ConfigEntryUpdate,
|
||||
@@ -34,15 +36,17 @@ import {
|
||||
userContext,
|
||||
userDataContext,
|
||||
} from "../data/context";
|
||||
import type { ConditionDescriptions } from "../data/condition";
|
||||
import { subscribeConditions } from "../data/condition";
|
||||
import { updateHassGroups } from "../data/context/updateContext";
|
||||
import { subscribeEntityRegistry } from "../data/entity/entity_registry";
|
||||
import { fetchIntegrationManifestsCollection } from "../data/integration";
|
||||
import { subscribeLabelRegistry } from "../data/label/label_registry";
|
||||
import type { TriggerDescriptions } from "../data/trigger";
|
||||
import { subscribeTriggers } from "../data/trigger";
|
||||
import type { Constructor, HomeAssistant } from "../types";
|
||||
import type {
|
||||
Constructor,
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../types";
|
||||
import type { HassBaseEl } from "./hass-base-mixin";
|
||||
import { LazyContextProvider } from "./lazy-context-provider";
|
||||
import { RelatedContextProvider } from "./related-context-provider";
|
||||
@@ -274,6 +278,16 @@ export const contextMixin = <T extends Constructor<HassBaseEl>>(
|
||||
}
|
||||
}
|
||||
|
||||
// Publishes i18n contexts from a "lite" localize source before a full `hass` exists
|
||||
// Once `hass` connects it overwrites these values.
|
||||
protected _provideLiteInternationalization(
|
||||
value: HomeAssistantInternationalization
|
||||
) {
|
||||
this.__hassContextProviderGroups.internationalization!.setValue(value);
|
||||
this.__contextProviders.localize?.setValue(value.localize);
|
||||
this.__contextProviders.locale?.setValue(value.locale);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
for (const provider of Object.values(this.__lazyContextProviders)) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Auth, Connection } from "home-assistant-js-websocket";
|
||||
import { LitElement } from "lit";
|
||||
import { property } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../types";
|
||||
|
||||
export class HassBaseEl extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -39,6 +42,13 @@ export class HassBaseEl extends LitElement {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
protected checkHttpPendingConfig() {}
|
||||
|
||||
// Overridden by contextMixin to publish i18n contexts before `hass` exists.
|
||||
protected _provideLiteInternationalization(
|
||||
_value: HomeAssistantInternationalization
|
||||
): void {
|
||||
// no-op by default
|
||||
}
|
||||
|
||||
protected hassChanged(hass, _oldHass) {
|
||||
this.__provideHass.forEach((el) => {
|
||||
(el as any).hass = hass;
|
||||
|
||||
@@ -224,9 +224,11 @@
|
||||
},
|
||||
"home-summary": {
|
||||
"all_lights_off": "All off",
|
||||
"count_active_alerts": "{count} {count, plural,\n one {active alert}\n other {active alerts}\n}",
|
||||
"count_lights_on": "{count} {count, plural,\n one {on}\n other {on}\n}",
|
||||
"count_locks_unlocked": "{count} {count, plural,\n one {unlocked}\n other {unlocked}\n}",
|
||||
"count_alarms_disarmed": "{count} {count, plural,\n one {disarmed}\n other {disarmed}\n}",
|
||||
"count_warnings": "{count} {count, plural,\n one {warning}\n other {warnings}\n}",
|
||||
"all_secure": "All secure",
|
||||
"no_media_playing": "No media playing",
|
||||
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}",
|
||||
@@ -566,6 +568,20 @@
|
||||
"background": {
|
||||
"yaml_info": "Background image is set via YAML editor."
|
||||
},
|
||||
"duration": {
|
||||
"duration": "Duration",
|
||||
"offset": {
|
||||
"none": "No offset",
|
||||
"before": "Before",
|
||||
"after": "After"
|
||||
},
|
||||
"summary": {
|
||||
"offset_negative": "{duration} before",
|
||||
"offset_positive": "{duration} after",
|
||||
"signed_negative": "-{duration}",
|
||||
"signed_positive": "+{duration}"
|
||||
}
|
||||
},
|
||||
"location": {
|
||||
"latitude": "[%key:ui::panel::config::zone::detail::latitude%]",
|
||||
"longitude": "[%key:ui::panel::config::zone::detail::longitude%]",
|
||||
@@ -1893,6 +1909,7 @@
|
||||
"commands": "Lawn mower commands:",
|
||||
"start_mowing": "Start mowing",
|
||||
"pause": "Pause",
|
||||
"stop": "Stop",
|
||||
"dock": "Return to dock"
|
||||
},
|
||||
"valve": {
|
||||
@@ -8917,16 +8934,28 @@
|
||||
"description": "Manage the format of newly created entity IDs",
|
||||
"card": {
|
||||
"header": "Entity ID format for new entities",
|
||||
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings",
|
||||
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings. Parts without a value are omitted from the entity ID.",
|
||||
"learn_more": "Learn more",
|
||||
"preview": "Preview",
|
||||
"preview": "Examples",
|
||||
"preview_error": "Failed to generate preview",
|
||||
"unused_part": "{name} (not used in format)",
|
||||
"reset": "Reset to default",
|
||||
"examples": {
|
||||
"area": "Living room",
|
||||
"device": "Thermostat",
|
||||
"entity": "Temperature",
|
||||
"floor": "Ground floor"
|
||||
"thermostat": {
|
||||
"name": "Thermostat",
|
||||
"floor": "Ground floor",
|
||||
"area": "Living room",
|
||||
"device": "Thermostat",
|
||||
"entity": "Temperature"
|
||||
},
|
||||
"power_strip": {
|
||||
"name": "Power strip",
|
||||
"floor": "First floor",
|
||||
"area": "Office",
|
||||
"parent_device": "Power strip",
|
||||
"device": "Outlet 1",
|
||||
"entity": "Power"
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"label": "Format",
|
||||
@@ -9247,6 +9276,7 @@
|
||||
},
|
||||
"home": {
|
||||
"summary_list": {
|
||||
"alerts": "Alerts",
|
||||
"media_players": "Media players",
|
||||
"other_devices": "Other devices",
|
||||
"weather": "Weather",
|
||||
@@ -11072,7 +11102,12 @@
|
||||
"label": "Target temperature"
|
||||
},
|
||||
"target-humidity": {
|
||||
"label": "Target humidity"
|
||||
"label": "Target humidity",
|
||||
"style": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style%]",
|
||||
"style_list": {
|
||||
"buttons": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::buttons%]",
|
||||
"slider": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::slider%]"
|
||||
}
|
||||
},
|
||||
"water-heater-operation-modes": {
|
||||
"label": "Water heater operation modes",
|
||||
@@ -11090,6 +11125,7 @@
|
||||
"commands": "Commands",
|
||||
"commands_list": {
|
||||
"start_pause": "Start Pause",
|
||||
"stop": "[%key:ui::dialogs::more_info_control::lawn_mower::stop%]",
|
||||
"dock": "[%key:ui::dialogs::more_info_control::lawn_mower::dock%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDurationData } from "../../../src/common/datetime/create_duration_data";
|
||||
|
||||
describe("createDurationData", () => {
|
||||
@@ -17,6 +17,26 @@ describe("createDurationData", () => {
|
||||
expect(createDurationData("20")).toEqual({
|
||||
seconds: 20,
|
||||
});
|
||||
|
||||
expect(createDurationData("3:00")).toEqual({
|
||||
hours: 3,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should negate the whole period for a negative string duration", () => {
|
||||
expect(createDurationData("-1:30:15.001")).toEqual({
|
||||
hours: -1,
|
||||
minutes: -30,
|
||||
seconds: -15,
|
||||
milliseconds: -1,
|
||||
});
|
||||
|
||||
expect(createDurationData("-20")).toEqual({
|
||||
seconds: -20,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return undefined for invalid string duration", () => {
|
||||
@@ -33,19 +53,13 @@ describe("createDurationData", () => {
|
||||
expect(createDurationData(1.25)).toEqual({ seconds: 1.25 });
|
||||
});
|
||||
|
||||
it("should parse object duration without days correctly", () => {
|
||||
expect(createDurationData({ hours: 1, minutes: 30 })).toEqual({
|
||||
hours: 1,
|
||||
minutes: 30,
|
||||
});
|
||||
it("should return object duration unchanged", () => {
|
||||
const duration = { hours: 1, minutes: 30 };
|
||||
expect(createDurationData(duration)).toEqual(duration);
|
||||
});
|
||||
|
||||
it("should handle days in object duration correctly", () => {
|
||||
expect(createDurationData({ days: 1, hours: 1 })).toEqual({
|
||||
hours: 25,
|
||||
minutes: undefined,
|
||||
seconds: undefined,
|
||||
milliseconds: undefined,
|
||||
});
|
||||
it("should keep days in object duration", () => {
|
||||
const duration = { days: 1, hours: 1 };
|
||||
expect(createDurationData(duration)).toEqual(duration);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { durationValueToData } from "../../../src/common/datetime/duration_value_to_data";
|
||||
|
||||
describe("durationValueToData", () => {
|
||||
it("converts numbers to seconds with a flag when negative", () => {
|
||||
expect(durationValueToData(90)).toEqual({ seconds: 90 });
|
||||
expect(durationValueToData(-90)).toEqual({ negative: true, seconds: 90 });
|
||||
});
|
||||
|
||||
it("parses colon separated strings", () => {
|
||||
expect(durationValueToData("90")).toEqual({ seconds: 90 });
|
||||
expect(durationValueToData("1:30")).toEqual({ hours: 1, minutes: 30 });
|
||||
expect(durationValueToData("01:30:15")).toEqual({
|
||||
hours: 1,
|
||||
minutes: 30,
|
||||
seconds: 15,
|
||||
});
|
||||
expect(durationValueToData("1:2:3:4")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves a leading minus of a string into the flag", () => {
|
||||
expect(durationValueToData("-03:00:00")).toEqual({
|
||||
negative: true,
|
||||
hours: 3,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
expect(durationValueToData(" -60")).toEqual({
|
||||
negative: true,
|
||||
seconds: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes dicts through untouched", () => {
|
||||
const value = { negative: true, hours: 1 };
|
||||
expect(durationValueToData(value)).toBe(value);
|
||||
expect(durationValueToData(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeDuration } from "../../../src/common/datetime/normalize_duration";
|
||||
|
||||
describe("normalizeDuration", () => {
|
||||
it("keeps the flag and the components when the flag is present", () => {
|
||||
expect(
|
||||
normalizeDuration({ negative: true, hours: 1, minutes: 30 })
|
||||
).toEqual({ negative: true, hours: 1, minutes: 30 });
|
||||
expect(normalizeDuration({ negative: false, hours: 1 })).toEqual({
|
||||
negative: false,
|
||||
hours: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the flag from legacy components sharing a sign", () => {
|
||||
expect(normalizeDuration({ hours: -1, minutes: -30 })).toEqual({
|
||||
negative: true,
|
||||
hours: 1,
|
||||
minutes: 30,
|
||||
});
|
||||
expect(normalizeDuration({ hours: 0, minutes: 0 })).toEqual({
|
||||
negative: false,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import "../../../src/components/input/ha-input";
|
||||
import type { HaInput } from "../../../src/components/input/ha-input";
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn();
|
||||
|
||||
unobserve = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom's ElementInternals lacks the validity API used by wa-input.
|
||||
const internalsProto = window.ElementInternals.prototype as any;
|
||||
internalsProto.setValidity = vi.fn();
|
||||
internalsProto.setFormValue = vi.fn();
|
||||
internalsProto.checkValidity = () => true;
|
||||
internalsProto.reportValidity = () => true;
|
||||
internalsProto.states = new Set();
|
||||
Object.defineProperty(internalsProto, "validity", {
|
||||
get: () => ({ valid: true }),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
let inputs: HaInput[] = [];
|
||||
|
||||
const mountInput = async (props: Partial<HaInput> = {}): Promise<HaInput> => {
|
||||
const el = document.createElement("ha-input");
|
||||
Object.assign(el, props);
|
||||
document.body.append(el);
|
||||
inputs.push(el);
|
||||
await el.updateComplete;
|
||||
const waInput = el.shadowRoot!.querySelector("wa-input");
|
||||
await waInput?.updateComplete;
|
||||
return el;
|
||||
};
|
||||
|
||||
const nativeInput = (el: HaInput): HTMLInputElement => {
|
||||
const waInput = el.shadowRoot!.querySelector("wa-input")!;
|
||||
return waInput.shadowRoot!.querySelector('[part~="input"]')!;
|
||||
};
|
||||
|
||||
const nativeLabel = (el: HaInput): HTMLLabelElement => {
|
||||
const waInput = el.shadowRoot!.querySelector("wa-input")!;
|
||||
return waInput.shadowRoot!.querySelector("label")!;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
inputs.forEach((el) => el.remove());
|
||||
inputs = [];
|
||||
});
|
||||
|
||||
describe("ha-input password-manager autofill", () => {
|
||||
// Password managers often write the nested native input without firing
|
||||
// input/change, so login used to submit empty credentials (#51620).
|
||||
it("picks up a silent native fill on reportValidity", async () => {
|
||||
const el = await mountInput({ label: "Username", required: true });
|
||||
const received: string[] = [];
|
||||
el.addEventListener("input", () => {
|
||||
received.push(el.value ?? "");
|
||||
});
|
||||
|
||||
nativeInput(el).value = "admin";
|
||||
|
||||
expect(el.value).toBeUndefined();
|
||||
expect(el.reportValidity()).toBe(true);
|
||||
expect(el.value).toBe("admin");
|
||||
expect(received).toEqual(["admin"]);
|
||||
});
|
||||
|
||||
it("does not emit when the native value already matches", async () => {
|
||||
const el = await mountInput({ label: "Username", value: "admin" });
|
||||
const listener = vi.fn();
|
||||
el.addEventListener("input", listener);
|
||||
|
||||
expect(el.syncFromNativeInput()).toBe(false);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ha-input native ids", () => {
|
||||
// Unique native ids help password managers tell login fields apart (#51620 /
|
||||
// home-assistant/webawesome#52).
|
||||
it("remaps the native input id and label for from input-id", async () => {
|
||||
const el = await mountInput({
|
||||
label: "Username",
|
||||
inputId: "username",
|
||||
});
|
||||
|
||||
const waInput = el.shadowRoot!.querySelector("wa-input") as HTMLElement & {
|
||||
inputId?: string;
|
||||
};
|
||||
expect(waInput.inputId).toBe("username");
|
||||
expect(nativeInput(el).id).toBe("username");
|
||||
expect(nativeLabel(el).htmlFor).toBe("username");
|
||||
});
|
||||
|
||||
it("keeps the default native id when input-id is omitted", async () => {
|
||||
const el = await mountInput({ label: "Username" });
|
||||
|
||||
expect(nativeInput(el).id).toBe("input");
|
||||
expect(nativeLabel(el).htmlFor).toBe("input");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HaChooseSelector } from "../../../src/components/ha-selector/ha-selector-choose";
|
||||
|
||||
class HaSelectorStub extends HTMLElement {
|
||||
public hass?: unknown;
|
||||
public selector?: unknown;
|
||||
public value?: unknown;
|
||||
public disabled?: boolean;
|
||||
public required?: boolean;
|
||||
public helper?: string;
|
||||
public localizeValue?: (key: string) => string;
|
||||
}
|
||||
|
||||
// Mock the child selector so its real selector tree cannot start async imports.
|
||||
vi.mock("../../../src/components/ha-selector/ha-selector", () => {
|
||||
if (!customElements.get("ha-selector")) {
|
||||
customElements.define(
|
||||
"ha-selector",
|
||||
HaSelectorStub as unknown as CustomElementConstructor
|
||||
);
|
||||
}
|
||||
|
||||
return { HaSelector: HaSelectorStub };
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// Handle 'setValidity is not a function' on the button group.
|
||||
Object.defineProperty(HTMLElement.prototype, "attachInternals", {
|
||||
configurable: true,
|
||||
value() {
|
||||
return {
|
||||
setValidity: () => {
|
||||
/* empty */
|
||||
},
|
||||
setFormValue: () => {
|
||||
/* empty */
|
||||
},
|
||||
checkValidity: () => true,
|
||||
reportValidity: () => true,
|
||||
validity: {},
|
||||
validationMessage: "",
|
||||
willValidate: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await import("../../../src/components/ha-selector/ha-selector-choose");
|
||||
});
|
||||
|
||||
describe("<ha-selector-choose>", () => {
|
||||
const selector = {
|
||||
choose: {
|
||||
choices: {
|
||||
text: { selector: { text: {} } },
|
||||
entity: { selector: { entity: {} } },
|
||||
number: { selector: { number: {} } },
|
||||
duration: { selector: { duration: {} } },
|
||||
template: { selector: { template: {} } },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const waitForSelector = async (element: HaChooseSelector) => {
|
||||
await element.updateComplete;
|
||||
};
|
||||
|
||||
const createElement = async (value: unknown) => {
|
||||
const element = document.createElement(
|
||||
"ha-selector-choose"
|
||||
) as HaChooseSelector;
|
||||
|
||||
element.hass = {} as any;
|
||||
element.selector = selector;
|
||||
element.value = value;
|
||||
|
||||
document.body.append(element);
|
||||
await waitForSelector(element);
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
it("uses value.active_choice as the initial active choice when it exists", async () => {
|
||||
const element = await createElement({
|
||||
active_choice: "entity",
|
||||
entity: "light.kitchen",
|
||||
});
|
||||
|
||||
expect(element._activeChoice).toBe("entity");
|
||||
|
||||
const toggleGroup = element.shadowRoot!.querySelector(
|
||||
"ha-button-toggle-group"
|
||||
) as HTMLElementTagNameMap["ha-button-toggle-group"];
|
||||
expect(toggleGroup.active).toBe("entity");
|
||||
|
||||
const nestedSelector = element.shadowRoot!.querySelector(
|
||||
"ha-selector"
|
||||
) as HTMLElementTagNameMap["ha-selector"];
|
||||
expect(nestedSelector.selector).toEqual(
|
||||
selector.choose.choices.entity.selector
|
||||
);
|
||||
expect(nestedSelector.value).toBe("light.kitchen");
|
||||
});
|
||||
|
||||
it("falls back to the first choice for an undefined initial value", async () => {
|
||||
const element = await createElement(undefined);
|
||||
|
||||
expect(element._activeChoice).toBe("text");
|
||||
});
|
||||
|
||||
it("falls back to text for an initial text value without active_choice", async () => {
|
||||
const element = await createElement("hello world");
|
||||
|
||||
expect(element._activeChoice).toBe("text");
|
||||
});
|
||||
|
||||
it("falls back to entity for an initial entity_id value without active_choice", async () => {
|
||||
const element = await createElement("sun.sun");
|
||||
|
||||
expect(element._activeChoice).toBe("entity");
|
||||
});
|
||||
|
||||
it("changes active choice when a new value specifies a valid active_choice", async () => {
|
||||
const element = await createElement({
|
||||
active_choice: "text",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
expect(element._activeChoice).toBe("text");
|
||||
|
||||
element.value = {
|
||||
active_choice: "entity",
|
||||
entity: "light.kitchen",
|
||||
};
|
||||
await waitForSelector(element);
|
||||
|
||||
expect(element._activeChoice).toBe("entity");
|
||||
|
||||
const nestedSelector = element.shadowRoot!.querySelector(
|
||||
"ha-selector"
|
||||
) as HTMLElementTagNameMap["ha-selector"];
|
||||
expect(nestedSelector.selector).toEqual(
|
||||
selector.choose.choices.entity.selector
|
||||
);
|
||||
expect(nestedSelector.value).toBe("light.kitchen");
|
||||
});
|
||||
|
||||
it("keeps the active choice when the next value has no active_choice", async () => {
|
||||
const element = await createElement({
|
||||
active_choice: "entity",
|
||||
entity: "light.kitchen",
|
||||
});
|
||||
|
||||
element.value = "plain text";
|
||||
await waitForSelector(element);
|
||||
|
||||
expect(element._activeChoice).toBe("entity");
|
||||
|
||||
const nestedSelector = element.shadowRoot!.querySelector(
|
||||
"ha-selector"
|
||||
) as HTMLElementTagNameMap["ha-selector"];
|
||||
expect(nestedSelector.selector).toEqual(
|
||||
selector.choose.choices.entity.selector
|
||||
);
|
||||
expect(nestedSelector.value).toBe("plain text");
|
||||
});
|
||||
|
||||
it("does not switch choices when the updated value retains its active_choice", async () => {
|
||||
const element = await createElement({
|
||||
active_choice: "entity",
|
||||
entity: "light.kitchen",
|
||||
});
|
||||
|
||||
element.value = {
|
||||
active_choice: "entity",
|
||||
entity: "light.living_room",
|
||||
};
|
||||
await waitForSelector(element);
|
||||
|
||||
expect(element._activeChoice).toBe("entity");
|
||||
|
||||
const nestedSelector = element.shadowRoot!.querySelector(
|
||||
"ha-selector"
|
||||
) as HTMLElementTagNameMap["ha-selector"];
|
||||
expect(nestedSelector.value).toBe("light.living_room");
|
||||
});
|
||||
|
||||
it("passes an object without active_choice through to a duration selector", async () => {
|
||||
const duration = {
|
||||
hours: 0,
|
||||
minutes: 5,
|
||||
seconds: 0,
|
||||
};
|
||||
const element = await createElement(duration);
|
||||
|
||||
expect(element._activeChoice).toBe("duration");
|
||||
|
||||
const nestedSelector = element.shadowRoot!.querySelector(
|
||||
"ha-selector"
|
||||
) as HTMLElementTagNameMap["ha-selector"];
|
||||
|
||||
expect(nestedSelector.selector).toEqual(
|
||||
selector.choose.choices.duration.selector
|
||||
);
|
||||
expect(nestedSelector.value).toEqual(duration);
|
||||
});
|
||||
|
||||
it("passes a template without active_choice through to a template selector", async () => {
|
||||
const template = "{{ xyz }}";
|
||||
const element = await createElement(template);
|
||||
|
||||
expect(element._activeChoice).toBe("template");
|
||||
|
||||
const nestedSelector = element.shadowRoot!.querySelector(
|
||||
"ha-selector"
|
||||
) as HTMLElementTagNameMap["ha-selector"];
|
||||
|
||||
expect(nestedSelector.selector).toEqual(
|
||||
selector.choose.choices.template.selector
|
||||
);
|
||||
expect(nestedSelector.value).toEqual(template);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeTargetSubRows } from "../../../src/components/target-picker/compute-target-sub-rows";
|
||||
import type { ExtractFromTargetResultReferenced } from "../../../src/data/target";
|
||||
import {
|
||||
mockDevice,
|
||||
mockEntity,
|
||||
} from "../../common/entity/context/context-mock";
|
||||
|
||||
const entries = (
|
||||
referenced: Partial<ExtractFromTargetResultReferenced>
|
||||
): ExtractFromTargetResultReferenced => ({
|
||||
referenced_areas: [],
|
||||
referenced_devices: [],
|
||||
referenced_entities: [],
|
||||
...referenced,
|
||||
});
|
||||
|
||||
// A power strip in the garage with two outlets. The outlets have no area of
|
||||
// their own and inherit the garage from the strip.
|
||||
const entities = {
|
||||
"sensor.strip_power": mockEntity({
|
||||
entity_id: "sensor.strip_power",
|
||||
device_id: "strip",
|
||||
}),
|
||||
"sensor.outlet_1_power": mockEntity({
|
||||
entity_id: "sensor.outlet_1_power",
|
||||
device_id: "outlet_1",
|
||||
}),
|
||||
"sensor.outlet_2_power": mockEntity({
|
||||
entity_id: "sensor.outlet_2_power",
|
||||
device_id: "outlet_2",
|
||||
}),
|
||||
};
|
||||
const devices = {
|
||||
strip: mockDevice({ id: "strip", area_id: "garage" }),
|
||||
outlet_1: mockDevice({ id: "outlet_1", parent_device_id: "strip" }),
|
||||
outlet_2: mockDevice({ id: "outlet_2", parent_device_id: "strip" }),
|
||||
};
|
||||
const allDevices = Object.keys(devices);
|
||||
const allEntities = Object.keys(entities);
|
||||
|
||||
describe("computeTargetSubRows", () => {
|
||||
it("nests child devices under a device target and keeps its own entities as rows", () => {
|
||||
const subRows = computeTargetSubRows(
|
||||
"device",
|
||||
"strip",
|
||||
entries({
|
||||
referenced_devices: allDevices,
|
||||
referenced_entities: allEntities,
|
||||
}),
|
||||
entities,
|
||||
devices
|
||||
);
|
||||
|
||||
expect(subRows.nextType).toBe("entity");
|
||||
expect(subRows.rows).toEqual(["sensor.strip_power"]);
|
||||
expect(subRows.deviceRows).toEqual(["outlet_1", "outlet_2"]);
|
||||
expect(subRows.deviceRowEntries?.map((e) => e.referenced_entities)).toEqual(
|
||||
[["sensor.outlet_1_power"], ["sensor.outlet_2_power"]]
|
||||
);
|
||||
});
|
||||
|
||||
it("lists a child device only under its parent for an area target", () => {
|
||||
const subRows = computeTargetSubRows(
|
||||
"area",
|
||||
"garage",
|
||||
entries({
|
||||
referenced_devices: allDevices,
|
||||
referenced_entities: allEntities,
|
||||
}),
|
||||
entities,
|
||||
devices
|
||||
);
|
||||
|
||||
expect(subRows.nextType).toBe("device");
|
||||
expect(subRows.rows).toEqual(["strip"]);
|
||||
expect(subRows.rowEntries?.[0].referenced_entities).toEqual(allEntities);
|
||||
expect(subRows.deviceRows).toEqual([]);
|
||||
expect(subRows.entityRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts a child device in its parent's area when browsing from a floor", () => {
|
||||
const subRows = computeTargetSubRows(
|
||||
"floor",
|
||||
"ground",
|
||||
entries({
|
||||
referenced_areas: ["garage"],
|
||||
referenced_devices: allDevices,
|
||||
referenced_entities: allEntities,
|
||||
}),
|
||||
entities,
|
||||
devices
|
||||
);
|
||||
|
||||
expect(subRows.nextType).toBe("area");
|
||||
expect(subRows.rows).toEqual(["garage"]);
|
||||
expect(subRows.rowEntries?.[0].referenced_devices).toEqual(["strip"]);
|
||||
expect(subRows.rowEntries?.[0].referenced_entities).toEqual(allEntities);
|
||||
});
|
||||
|
||||
it("does not duplicate a labeled child device under its labeled parent", () => {
|
||||
const labeled = {
|
||||
strip: mockDevice({ id: "strip", labels: ["power"] }),
|
||||
outlet_1: mockDevice({
|
||||
id: "outlet_1",
|
||||
parent_device_id: "strip",
|
||||
labels: ["power"],
|
||||
}),
|
||||
};
|
||||
const subRows = computeTargetSubRows(
|
||||
"label",
|
||||
"power",
|
||||
entries({
|
||||
referenced_devices: ["strip", "outlet_1"],
|
||||
referenced_entities: ["sensor.strip_power", "sensor.outlet_1_power"],
|
||||
}),
|
||||
entities,
|
||||
labeled
|
||||
);
|
||||
|
||||
expect(subRows.nextType).toBe("device");
|
||||
expect(subRows.rows).toEqual([]);
|
||||
expect(subRows.deviceRows).toEqual(["strip"]);
|
||||
expect(subRows.deviceRowEntries?.[0].referenced_entities).toEqual([
|
||||
"sensor.strip_power",
|
||||
"sensor.outlet_1_power",
|
||||
]);
|
||||
expect(subRows.entityRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a labeled child device at the top level when its parent is not labeled", () => {
|
||||
const labeled = {
|
||||
strip: mockDevice({ id: "strip" }),
|
||||
outlet_1: mockDevice({
|
||||
id: "outlet_1",
|
||||
parent_device_id: "strip",
|
||||
labels: ["power"],
|
||||
}),
|
||||
};
|
||||
const subRows = computeTargetSubRows(
|
||||
"label",
|
||||
"power",
|
||||
entries({
|
||||
referenced_devices: ["outlet_1"],
|
||||
referenced_entities: ["sensor.outlet_1_power"],
|
||||
}),
|
||||
entities,
|
||||
labeled
|
||||
);
|
||||
|
||||
expect(subRows.deviceRows).toEqual(["outlet_1"]);
|
||||
expect(subRows.deviceRowEntries?.[0].referenced_entities).toEqual([
|
||||
"sensor.outlet_1_power",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -174,6 +174,30 @@ describe("ha-target-picker-item-row target extraction", () => {
|
||||
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
|
||||
});
|
||||
|
||||
it("keeps a parent device without entities when a child device matches", async () => {
|
||||
const entries = await extractedBy(
|
||||
extractResult({
|
||||
referenced_devices: ["strip", "outlet_1"],
|
||||
referenced_entities: ["sensor.outlet_1_power"],
|
||||
}),
|
||||
{
|
||||
entities: {
|
||||
"sensor.outlet_1_power": mkEntity("sensor.outlet_1_power", {
|
||||
device_id: "outlet_1",
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
strip: mkDevice("strip", { area_id: "area_1" }),
|
||||
outlet_1: mkDevice("outlet_1", { parent_device_id: "strip" }),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(entries).toBeDefined();
|
||||
expect(entries!.referenced_devices).toEqual(["strip", "outlet_1"]);
|
||||
expect(entries!.referenced_entities).toEqual(["sensor.outlet_1_power"]);
|
||||
});
|
||||
|
||||
it("does not mutate the extracted target result", async () => {
|
||||
const result = extractResult({
|
||||
referenced_areas: ["area_missing"],
|
||||
|
||||
@@ -61,3 +61,78 @@ describe("formatSelectorValue", () => {
|
||||
expect(result).toContain("••••••••");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSelectorValue duration selector", () => {
|
||||
const localizedHass = {
|
||||
locale: { language: "en" },
|
||||
localize: (key: string, values?: Record<string, unknown>) => {
|
||||
const suffix = key.split("ui.components.selectors.duration.summary.")[1];
|
||||
return {
|
||||
offset_negative: `${values?.duration} before`,
|
||||
offset_positive: `${values?.duration} after`,
|
||||
signed_negative: `-${values?.duration}`,
|
||||
signed_positive: `+${values?.duration}`,
|
||||
}[suffix]!;
|
||||
},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
it("formats a positive duration without a sign", () => {
|
||||
expect(
|
||||
formatSelectorValue(
|
||||
localizedHass,
|
||||
{ hours: 1, minutes: 30 },
|
||||
{ duration: {} }
|
||||
)
|
||||
).toBe("1 hour, 30 minutes");
|
||||
});
|
||||
|
||||
it("formats signed durations with an explicit sign", () => {
|
||||
expect(
|
||||
formatSelectorValue(
|
||||
localizedHass,
|
||||
{ negative: true, minutes: 30 },
|
||||
{ duration: { mode: "signed" } }
|
||||
)
|
||||
).toBe("-30 minutes");
|
||||
expect(
|
||||
formatSelectorValue(localizedHass, "00:30:00", {
|
||||
duration: { allow_negative: true },
|
||||
})
|
||||
).toBe("+30 minutes");
|
||||
});
|
||||
|
||||
it("formats offsets as before or after", () => {
|
||||
expect(
|
||||
formatSelectorValue(
|
||||
localizedHass,
|
||||
{ negative: true, hours: 1, minutes: 30 },
|
||||
{ duration: { mode: "offset" } }
|
||||
)
|
||||
).toBe("1 hour, 30 minutes before");
|
||||
expect(
|
||||
formatSelectorValue(localizedHass, "-00:10:00", {
|
||||
duration: { mode: "offset" },
|
||||
})
|
||||
).toBe("10 minutes before");
|
||||
expect(
|
||||
formatSelectorValue(localizedHass, 45, { duration: { mode: "offset" } })
|
||||
).toBe("45 seconds after");
|
||||
expect(
|
||||
formatSelectorValue(
|
||||
localizedHass,
|
||||
{ minutes: -5 },
|
||||
{ duration: { mode: "offset" } }
|
||||
)
|
||||
).toBe("5 minutes before");
|
||||
});
|
||||
|
||||
it("returns an empty string for a zero offset", () => {
|
||||
expect(
|
||||
formatSelectorValue(
|
||||
localizedHass,
|
||||
{ hours: 0, minutes: 0, seconds: 0 },
|
||||
{ duration: { mode: "offset" } }
|
||||
)
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,6 +179,64 @@ describe("buildEntityTree", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("nests a child device under its parent, even when the parent has no entities", () => {
|
||||
const hass = makeHass({
|
||||
states: {
|
||||
"switch.outlet_1": state("switch.outlet_1"),
|
||||
"switch.outlet_2": state("switch.outlet_2"),
|
||||
},
|
||||
entities: {
|
||||
"switch.outlet_1": entity({ device_id: "outlet_1" }),
|
||||
"switch.outlet_2": entity({ device_id: "outlet_2" }),
|
||||
},
|
||||
devices: {
|
||||
strip: device("strip", { name: "Power strip", area_id: "office" }),
|
||||
outlet_1: device("outlet_1", {
|
||||
name: "Outlet 1",
|
||||
parent_device_id: "strip",
|
||||
}),
|
||||
outlet_2: device("outlet_2", {
|
||||
name: "Outlet 2",
|
||||
parent_device_id: "strip",
|
||||
}),
|
||||
},
|
||||
areas: { office: area("office") },
|
||||
});
|
||||
|
||||
const tree = buildTree(hass);
|
||||
const office = tree.otherAreas[0];
|
||||
expect(office.devices.map((d) => d.id)).toEqual(["strip"]);
|
||||
expect(office.devices[0].entityIds).toEqual([]);
|
||||
expect(office.devices[0].children.map((d) => d.id)).toEqual([
|
||||
"outlet_1",
|
||||
"outlet_2",
|
||||
]);
|
||||
expect(office.devices[0].children[0].entityIds).toEqual([
|
||||
"switch.outlet_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a child device at the top level when its parent is in another area", () => {
|
||||
const hass = makeHass({
|
||||
states: { "switch.outlet_1": state("switch.outlet_1") },
|
||||
entities: { "switch.outlet_1": entity({ device_id: "outlet_1" }) },
|
||||
devices: {
|
||||
strip: device("strip", { area_id: "office" }),
|
||||
outlet_1: device("outlet_1", {
|
||||
area_id: "kitchen",
|
||||
parent_device_id: "strip",
|
||||
}),
|
||||
},
|
||||
areas: { office: area("office"), kitchen: area("kitchen") },
|
||||
});
|
||||
|
||||
const tree = buildTree(hass);
|
||||
const kitchen = tree.otherAreas.find((a) => a.id === "kitchen")!;
|
||||
expect(kitchen.devices.map((d) => d.id)).toEqual(["outlet_1"]);
|
||||
expect(kitchen.devices[0].children).toEqual([]);
|
||||
expect(tree.otherAreas.find((a) => a.id === "office")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats entities with their own area_id as direct area entities (not under device)", () => {
|
||||
const hass = makeHass({
|
||||
states: { "sensor.temp": state("sensor.temp") },
|
||||
@@ -353,6 +411,27 @@ describe("pathToEntity", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes the parent device key for an entity on a child device", () => {
|
||||
const hass = makeHass({
|
||||
states: { "switch.outlet_1": state("switch.outlet_1") },
|
||||
entities: { "switch.outlet_1": entity({ device_id: "outlet_1" }) },
|
||||
devices: {
|
||||
strip: device("strip", { area_id: "office" }),
|
||||
outlet_1: device("outlet_1", { parent_device_id: "strip" }),
|
||||
},
|
||||
areas: { office: area("office") },
|
||||
});
|
||||
const tree = buildTree(hass);
|
||||
const aKey = areaKey(floorKey(OTHER_AREAS_ID), "office");
|
||||
const stripKey = deviceKey(aKey, "strip");
|
||||
expect(pathToEntity(tree, "switch.outlet_1")).toEqual([
|
||||
floorKey(OTHER_AREAS_ID),
|
||||
aKey,
|
||||
stripKey,
|
||||
deviceKey(stripKey, "outlet_1"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the unassigned section + domain path for orphan entities", () => {
|
||||
const hass = makeHass({
|
||||
states: { "light.unowned": state("light.unowned") },
|
||||
|
||||
@@ -3042,9 +3042,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@home-assistant/webawesome@npm:3.7.0-ha.0":
|
||||
version: 3.7.0-ha.0
|
||||
resolution: "@home-assistant/webawesome@npm:3.7.0-ha.0"
|
||||
"@home-assistant/webawesome@npm:3.7.0-ha.1":
|
||||
version: 3.7.0-ha.1
|
||||
resolution: "@home-assistant/webawesome@npm:3.7.0-ha.1"
|
||||
dependencies:
|
||||
"@ctrl/tinycolor": "npm:4.1.0"
|
||||
"@floating-ui/dom": "npm:^1.6.13"
|
||||
@@ -3056,7 +3056,7 @@ __metadata:
|
||||
marked: "npm:^11.2.0"
|
||||
nanoid: "npm:^5.1.5"
|
||||
qr-creator: "npm:^1.0.0"
|
||||
checksum: 10/3bf46fc22cd42fb8d0484d91e7a631e830fda2d2d1d78babce477dcd50dc560b407bff42a5304bc7347dc13e9795efe199f7d8cda1a86d1282d9d43511c84ec5
|
||||
checksum: 10/4e2f4abf9a5f31984c6b8313d0b827e3b49123a3af2cf90aca0db23c5eb952effcdacfb4ff718c0914c3a309462cd673d7abf135e8c8e5cadb1a99ad381bfbf3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5131,79 +5131,79 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-arm64@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.2.2"
|
||||
"@rspack/binding-darwin-arm64@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.2.3"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-x64@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.2.2"
|
||||
"@rspack/binding-darwin-x64@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.2.3"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.2.2"
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.2.3"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.2.2"
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.2.3"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-ppc64-gnu@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-linux-ppc64-gnu@npm:2.2.2"
|
||||
"@rspack/binding-linux-ppc64-gnu@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-linux-ppc64-gnu@npm:2.2.3"
|
||||
conditions: os=linux & cpu=ppc64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.2.2"
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.2.3"
|
||||
conditions: os=linux & cpu=riscv64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.2.2"
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.2.3"
|
||||
conditions: os=linux & cpu=riscv64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-s390x-gnu@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-linux-s390x-gnu@npm:2.2.2"
|
||||
"@rspack/binding-linux-s390x-gnu@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-linux-s390x-gnu@npm:2.2.3"
|
||||
conditions: os=linux & cpu=s390x & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.2.2"
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.2.3"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-musl@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.2.2"
|
||||
"@rspack/binding-linux-x64-musl@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.2.3"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-wasm32-wasi@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.2.2"
|
||||
"@rspack/binding-wasm32-wasi@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.2.3"
|
||||
dependencies:
|
||||
"@emnapi/core": "npm:1.11.3"
|
||||
"@emnapi/runtime": "npm:1.11.3"
|
||||
@@ -5212,45 +5212,45 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.2.2"
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.2.3"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.2.2"
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.2.3"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.2.2"
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.2.3"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/binding@npm:2.2.2"
|
||||
"@rspack/binding@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/binding@npm:2.2.3"
|
||||
dependencies:
|
||||
"@rspack/binding-darwin-arm64": "npm:2.2.2"
|
||||
"@rspack/binding-darwin-x64": "npm:2.2.2"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.2.2"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.2.2"
|
||||
"@rspack/binding-linux-ppc64-gnu": "npm:2.2.2"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.2.2"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.2.2"
|
||||
"@rspack/binding-linux-s390x-gnu": "npm:2.2.2"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.2.2"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.2.2"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.2.2"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.2.2"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.2.2"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.2.2"
|
||||
"@rspack/binding-darwin-arm64": "npm:2.2.3"
|
||||
"@rspack/binding-darwin-x64": "npm:2.2.3"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.2.3"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.2.3"
|
||||
"@rspack/binding-linux-ppc64-gnu": "npm:2.2.3"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.2.3"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.2.3"
|
||||
"@rspack/binding-linux-s390x-gnu": "npm:2.2.3"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.2.3"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.2.3"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.2.3"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.2.3"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.2.3"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.2.3"
|
||||
dependenciesMeta:
|
||||
"@rspack/binding-darwin-arm64":
|
||||
optional: true
|
||||
@@ -5280,15 +5280,15 @@ __metadata:
|
||||
optional: true
|
||||
"@rspack/binding-win32-x64-msvc":
|
||||
optional: true
|
||||
checksum: 10/87fd254d325d81272aca50da6b39b3935c0ea35cc85ddbce28fd87d4b78b4da1a4e717430d539c4ad5d36b7e72d39b4995a35c2d3faa2acc02eceeb449f8cf39
|
||||
checksum: 10/5aa3aa2128529e07f9462a4ec5fd162aaceb9b0aac49bb7569f5586b7ae449610c39b0c48181a20ede742fd2ce7c0894ce1a5be0683c7ef22961e552bb2669c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/core@npm:2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "@rspack/core@npm:2.2.2"
|
||||
"@rspack/core@npm:2.2.3":
|
||||
version: 2.2.3
|
||||
resolution: "@rspack/core@npm:2.2.3"
|
||||
dependencies:
|
||||
"@rspack/binding": "npm:2.2.2"
|
||||
"@rspack/binding": "npm:2.2.3"
|
||||
peerDependencies:
|
||||
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
|
||||
"@swc/helpers": ^0.5.23
|
||||
@@ -5297,7 +5297,7 @@ __metadata:
|
||||
optional: true
|
||||
"@swc/helpers":
|
||||
optional: true
|
||||
checksum: 10/6c7c021ec5dcb66fd298f98baf3600b4cf3a2145edf3cf3e8db1fc2500575c208eb19bd26797e4e396cfa2604fc0c779302a06f41dab5d95e220ae6765821846
|
||||
checksum: 10/efd0191eb87f24f9ccc68ddb3e0a1c6a23b2c8bcab2ce0297acc38f459cf7970ae960b69298c04837e516d3bfeef69694fd139fb81885b211b82815d94e73099
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5645,16 +5645,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@thomasloven/round-slider@npm:0.6.0":
|
||||
version: 0.6.0
|
||||
resolution: "@thomasloven/round-slider@npm:0.6.0"
|
||||
dependencies:
|
||||
lit: "npm:^2.0.0-rc.2"
|
||||
tslib: "npm:^2.2.0"
|
||||
checksum: 10/0430009230daa0720b5520e96fad0ebcd7d200200279bd8a300bb7a98bb47b38c6feae3459d57b91d42f958742a5cb4b3ad34aa29b0e55934b660f6fc126da36
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tokenizer/token@npm:^0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "@tokenizer/token@npm:0.3.0"
|
||||
@@ -6202,105 +6192,105 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/eslint-plugin@npm:8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.69.0"
|
||||
"@typescript-eslint/eslint-plugin@npm:8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.70.0"
|
||||
dependencies:
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
"@typescript-eslint/scope-manager": "npm:8.69.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.69.0"
|
||||
"@typescript-eslint/utils": "npm:8.69.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.69.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.70.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.70.0"
|
||||
"@typescript-eslint/utils": "npm:8.70.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.70.0"
|
||||
ignore: "npm:^7.0.5"
|
||||
natural-compare: "npm:^1.4.0"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
"@typescript-eslint/parser": ^8.69.0
|
||||
"@typescript-eslint/parser": ^8.70.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/3d020557de0cddb276d513800150934cfadffc507c4f808b753e1b7fe42a864b8f14c35063483867518108b71cb718ead22d17943513e72f2d0d4cf0fa2f558c
|
||||
checksum: 10/d078f6d7212b9f6d83f8240d4458d10a9975eaab74ab09b2ed3a1000fe3d94ba5c510dded243fd8750bd7fdbf58e63742ac29c996e737234430ab48910389ab1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/parser@npm:8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.69.0"
|
||||
"@typescript-eslint/parser@npm:8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.70.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager": "npm:8.69.0"
|
||||
"@typescript-eslint/types": "npm:8.69.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.69.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.69.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.70.0"
|
||||
"@typescript-eslint/types": "npm:8.70.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.70.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.70.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/3266e512700dc0d55d0c41a5aab08a8f8ef36ef8e9bc94e6de6ab35205ec7259105914ed8d2464afedde24b0c70e3af6b5e46a1c96bdc31c1c307f4a8bab823d
|
||||
checksum: 10/44ea118c7a0c0d4e8ce21845784cda4727240e10a1e8966f759fc48363748a48a74a0aa228664a2148fa24f586d77e230d98058916f8a1a1c85325bb1f9d2e0f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/project-service@npm:8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.69.0"
|
||||
"@typescript-eslint/project-service@npm:8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.70.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.69.0"
|
||||
"@typescript-eslint/types": "npm:^8.69.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.70.0"
|
||||
"@typescript-eslint/types": "npm:^8.70.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/55a07db29e9b5fac47c437913c5fad1857abbaa3204446abba0754eb0ab8515f74dda8358ba1f9a1a7d7ee3af9af328f7ac709ecba7b64e3476b5c7d90529309
|
||||
checksum: 10/b06286f1ec6fc0fde7382f14cf4645aa0d008dfc25f40a8ec58e644fd42058a91e1d9ce1e106fbe78f5c2a3cf6b75560a24d24901972a4d21cc604f13aa921ad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/scope-manager@npm:8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.69.0"
|
||||
"@typescript-eslint/scope-manager@npm:8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.70.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.69.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.69.0"
|
||||
checksum: 10/6eafb382ad54848a796fe6ba6e29ff0f5db246d459a5c30a04a2144e3005d0ed0ee08e83abf2b943614ef02cc7f78358a722f028c87fbbee3a736a099b5b8bf1
|
||||
"@typescript-eslint/types": "npm:8.70.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.70.0"
|
||||
checksum: 10/7168d1ddf945ee47179420063bcc867f289926c7bea348bab4b9310b7c9b0f4b9031eb7653a820bb9fe41f668cc724c9886e19b7e39e2220ed0df168e8cf7607
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.69.0, @typescript-eslint/tsconfig-utils@npm:^8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.69.0"
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.70.0, @typescript-eslint/tsconfig-utils@npm:^8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.70.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/e180d2c88ae043f2099fc0ef0cbb4ab3b839b24df33d1f30c473aaccadec367584762ba6553e41377beb2df7504c4e2b82c97e9b00f013b066c835de80d5e66d
|
||||
checksum: 10/c4ad046c1082ebca815d52fd9ababe122ba8c59012ae69e790185ea0e2f898fe5abc2b5256ff88ee167d19844a5f6152dc63342e0a866cfe22a11c2887c5d8da
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/type-utils@npm:8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.69.0"
|
||||
"@typescript-eslint/type-utils@npm:8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.70.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.69.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.69.0"
|
||||
"@typescript-eslint/utils": "npm:8.69.0"
|
||||
"@typescript-eslint/types": "npm:8.70.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.70.0"
|
||||
"@typescript-eslint/utils": "npm:8.70.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/3ae37edb88006fafe51339c1b7a232c5078188517b752472c9c8a8e3e682cfebec1bd594aaf5336092d078c7af7aaaaf4fb4cad9cdf6e1e4885ddd93acd673f4
|
||||
checksum: 10/54bf0ef53a67f3e58bcbbefa1837d6b973d31e6c682ce6447c863426fc396fbcac2800dc7e2d343d09029e5c333668f29463ec99197562ee6211736c8460e4fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/types@npm:8.69.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/types@npm:8.69.0"
|
||||
checksum: 10/5a2c44249c8973b83e3cebfd6e968f545e1507e1b0dbf9f3bd672e6bad2f1d58167fa3d9af03fdd2df986cd19ad1aa9ce9fccc9504d25e068080d6e2b7471145
|
||||
"@typescript-eslint/types@npm:8.70.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/types@npm:8.70.0"
|
||||
checksum: 10/a264fbebf12d3ee5363688f10713890f67264277749c7f7e2b7fa8636d8836a19a9532e7228bd3155b12f455121b22998282acb17e76561e81902891f7205b44
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/typescript-estree@npm:8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.69.0"
|
||||
"@typescript-eslint/typescript-estree@npm:8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.70.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service": "npm:8.69.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.69.0"
|
||||
"@typescript-eslint/types": "npm:8.69.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.69.0"
|
||||
"@typescript-eslint/project-service": "npm:8.70.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.70.0"
|
||||
"@typescript-eslint/types": "npm:8.70.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.70.0"
|
||||
debug: "npm:^4.4.3"
|
||||
minimatch: "npm:^10.2.2"
|
||||
semver: "npm:^7.7.3"
|
||||
@@ -6308,32 +6298,32 @@ __metadata:
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/5859a19c78abb689265fbb9a435def29ec6e50777b75729164fd3a65a668999d6708dfd5e27c871773ba33a405f820654c9dfda16f485a89413db059d048a2d7
|
||||
checksum: 10/c588aac52c24883befe0c07312df808f27fb00676250e20036f43760cbe831e482ec92232703d9dea95c583687cf374a23e2c49cf8afddce370929eb128270f4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/utils@npm:8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.69.0"
|
||||
"@typescript-eslint/utils@npm:8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.70.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
||||
"@typescript-eslint/scope-manager": "npm:8.69.0"
|
||||
"@typescript-eslint/types": "npm:8.69.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.69.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.70.0"
|
||||
"@typescript-eslint/types": "npm:8.70.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.70.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/6724b25b933dfd90437eb2747aae9448e312caabe949b7b7a9cfbee8b1554fef3e2db20162667f9379903bcc2faab5d77c7a0efa3eab9a5d857b76ae67a6b88f
|
||||
checksum: 10/518521b1f7e8bb7a5bd7bc2db39e85b3bd1d710d0cf92da9a7d13a3394e9fb99eda76b90b7ecdc334afc60cb4415b8e079439a3cb9b29b6aea7f8ff1f499590b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/visitor-keys@npm:8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.69.0"
|
||||
"@typescript-eslint/visitor-keys@npm:8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.70.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.69.0"
|
||||
"@typescript-eslint/types": "npm:8.70.0"
|
||||
eslint-visitor-keys: "npm:^5.0.0"
|
||||
checksum: 10/15096ea595db6c7571f2a2d6e20e55ff82409cd046af51bb7b9f9ac9fe74dba2fccc707c7a34040a028e126afc65ab3ee77f964a7cd4a0ffa26e9ceb17851b28
|
||||
checksum: 10/3b6724e29e68f4468da18aaf7300dd178d5aa0465e6f3a203f91920d3bda6d0cdb97615ada9d1b04a63ccb485301cc4cd701e78931061ec2ec90a3f008cba3c7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10385,7 +10375,7 @@ __metadata:
|
||||
"@fullcalendar/luxon3": "npm:6.1.21"
|
||||
"@fullcalendar/timegrid": "npm:6.1.21"
|
||||
"@gfx/zopfli": "npm:1.0.15"
|
||||
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
|
||||
"@home-assistant/webawesome": "npm:3.7.0-ha.1"
|
||||
"@html-eslint/eslint-plugin": "npm:0.65.0"
|
||||
"@lezer/highlight": "npm:1.2.3"
|
||||
"@lit-labs/motion": "npm:1.1.0"
|
||||
@@ -10408,10 +10398,9 @@ __metadata:
|
||||
"@playwright/test": "npm:1.62.1"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.3"
|
||||
"@rspack/core": "npm:2.2.2"
|
||||
"@rspack/core": "npm:2.2.3"
|
||||
"@rspack/dev-server": "npm:2.2.1"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
"@tsparticles/engine": "npm:4.4.0"
|
||||
"@tsparticles/preset-links": "npm:4.4.0"
|
||||
"@types/babel__plugin-transform-runtime": "npm:7.9.5"
|
||||
@@ -10476,7 +10465,7 @@ __metadata:
|
||||
intl-messageformat: "npm:11.2.14"
|
||||
js-yaml: "npm:5.4.1"
|
||||
jsdom: "npm:30.0.1"
|
||||
jszip: "npm:3.10.1"
|
||||
jszip: "npm:3.10.2"
|
||||
leaflet: "npm:1.9.4"
|
||||
leaflet.markercluster: "npm:1.5.3"
|
||||
license-checker-rseidelsohn: "npm:5.0.1"
|
||||
@@ -10490,7 +10479,7 @@ __metadata:
|
||||
luxon: "npm:3.7.2"
|
||||
map-stream: "npm:0.0.7"
|
||||
maplibre-gl: "npm:5.24.0"
|
||||
marked: "npm:18.0.11"
|
||||
marked: "npm:18.0.12"
|
||||
memoize-one: "npm:6.0.0"
|
||||
minify-literals: "npm:2.2.0"
|
||||
node-vibrant: "npm:4.0.4"
|
||||
@@ -10513,7 +10502,7 @@ __metadata:
|
||||
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.69.0"
|
||||
typescript-eslint: "npm:8.70.0"
|
||||
vite: "npm:8.2.2"
|
||||
vite-tsconfig-paths: "npm:6.1.1"
|
||||
vitest: "npm:5.0.0"
|
||||
@@ -11595,15 +11584,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jszip@npm:3.10.1":
|
||||
version: 3.10.1
|
||||
resolution: "jszip@npm:3.10.1"
|
||||
"jszip@npm:3.10.2":
|
||||
version: 3.10.2
|
||||
resolution: "jszip@npm:3.10.2"
|
||||
dependencies:
|
||||
lie: "npm:~3.3.0"
|
||||
pako: "npm:~1.0.2"
|
||||
readable-stream: "npm:~2.3.6"
|
||||
setimmediate: "npm:^1.0.5"
|
||||
checksum: 10/bfbfbb9b0a27121330ac46ab9cdb3b4812433faa9ba4a54742c87ca441e31a6194ff70ae12acefa5fe25406c432290e68003900541d948a169b23d30c34dd984
|
||||
checksum: 10/86666f57397df56a29968e5cbf25a6b50a4812ab7258e1165ae907e6d3b442e84290cd5eefa9cdd4298653009ebe4599dae17f3de30050e4bf4a5e54462fdfd6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -12181,12 +12170,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:18.0.11":
|
||||
version: 18.0.11
|
||||
resolution: "marked@npm:18.0.11"
|
||||
"marked@npm:18.0.12":
|
||||
version: 18.0.12
|
||||
resolution: "marked@npm:18.0.12"
|
||||
bin:
|
||||
marked: bin/marked.js
|
||||
checksum: 10/6da69f17820c7442358d751e902d3e0e06fef957e1178d634db938cf339c4504399c9f11a953b0a116b1e5bebc1c7fb52726777cc6c351f2c9f39e4a8276e21a
|
||||
checksum: 10/048dadb8f6a5e790923ea1b394a81cc51cade02b23f19d9dab069e719264e308f07b767916359cac355229b680ea3b8014409e91ed59a09f4c2d89475567d185
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15646,18 +15635,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript-eslint@npm:8.69.0":
|
||||
version: 8.69.0
|
||||
resolution: "typescript-eslint@npm:8.69.0"
|
||||
"typescript-eslint@npm:8.70.0":
|
||||
version: 8.70.0
|
||||
resolution: "typescript-eslint@npm:8.70.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.69.0"
|
||||
"@typescript-eslint/parser": "npm:8.69.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.69.0"
|
||||
"@typescript-eslint/utils": "npm:8.69.0"
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.70.0"
|
||||
"@typescript-eslint/parser": "npm:8.70.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.70.0"
|
||||
"@typescript-eslint/utils": "npm:8.70.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/d06d299ec1b5468a9963d57e1e835cf48f2fbf89b301b8a4d1a2438b2d1853e12f97c48ea78f7f27d7da91671c7d8cbb04c3e3cd3d5b1a3f8bc9f7224e73150a
|
||||
checksum: 10/afdb2760ff425e806165d516e5d5baea4b446ce75c8e039a71d225047550a07b712f76b4b4a48982613f881e9694155e1beff1c5f0c4d034286048a41448911c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user