mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-16 23:20:38 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c51007c2 | ||
|
|
050edb3f01 | ||
|
|
f8b6605915 | ||
|
|
44dd2249b5 | ||
|
|
f073a12cbd | ||
|
|
7dec4a57e8 | ||
|
|
aa78f368b1 | ||
|
|
06901dedaa | ||
|
|
43f194dad2 | ||
|
|
e7acf97b1a | ||
|
|
22d48fbfdd | ||
|
|
9409db7fda | ||
|
|
d917df359f | ||
|
|
6dcca89185 | ||
|
|
3f1b2ef65f | ||
|
|
34afbe37c3 | ||
|
|
848e81313e | ||
|
|
24e7bd306a | ||
|
|
3791548067 | ||
|
|
01cef9a99b | ||
|
|
8eb179a0d9 | ||
|
|
34e9b50920 | ||
|
|
2ebe452334 | ||
|
|
3052989e9d | ||
|
|
fcab4004b2 | ||
|
|
4610adb368 | ||
|
|
e0dba17b66 | ||
|
|
eee0f9447b | ||
|
|
8d149a914f | ||
|
|
b2bb3e9d9d | ||
|
|
13ef9712bc | ||
|
|
40b27608c0 | ||
|
|
e0f03254b5 | ||
|
|
35dcba4f3f | ||
|
|
ec4bfc9255 |
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
|
||||
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -12,19 +12,29 @@ const CONNECTIONS: ModbusConnection[] = [
|
||||
{
|
||||
endpoint: ["serial", RS485_PORT],
|
||||
connected: true,
|
||||
source: "config_entry",
|
||||
units: { [FLEXIT_ENTRY_ID]: [1], [SOLAREDGE_ENTRY_ID]: [2] },
|
||||
},
|
||||
{
|
||||
endpoint: ["tcp", "192.168.1.42", 502],
|
||||
connected: true,
|
||||
source: "config_entry",
|
||||
units: { [FRONIUS_ENTRY_ID]: [1] },
|
||||
},
|
||||
// A second inverter behind a gateway that is not answering
|
||||
{
|
||||
endpoint: ["tcp", "modbus-gateway.local", 502],
|
||||
connected: false,
|
||||
source: "config_entry",
|
||||
units: { [FRONIUS_ENTRY_ID]: [2] },
|
||||
},
|
||||
// A YAML hub keeps a link of its own to a device an integration also talks to
|
||||
{
|
||||
endpoint: ["tcp", "192.168.1.42", 502],
|
||||
connected: true,
|
||||
source: "yaml",
|
||||
units: { modbus_hub: [10, 11] },
|
||||
},
|
||||
];
|
||||
|
||||
export const mockModbus = (hass: MockHomeAssistant) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_eleme
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../../src/data/kiosk_mode";
|
||||
import "../../src/components/ha-button";
|
||||
import "../../src/components/ha-drawer";
|
||||
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
|
||||
@@ -633,6 +634,7 @@ class HaGallery extends LitElement {
|
||||
floors: {},
|
||||
hassUrl: (path) => path,
|
||||
kioskMode: false,
|
||||
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
|
||||
language: "en",
|
||||
loadBackendTranslation: async () => galleryLocalize,
|
||||
loadFragmentTranslation: async () => undefined,
|
||||
|
||||
@@ -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",
|
||||
@@ -320,6 +385,13 @@ const SCHEMAS: {
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
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 +576,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 +770,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 +1031,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",
|
||||
|
||||
+10
-11
@@ -39,7 +39,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.0",
|
||||
"@babel/runtime": "8.0.5",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.0",
|
||||
@@ -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",
|
||||
@@ -139,10 +138,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.1",
|
||||
"@babel/core": "8.0.5",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.1",
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@babel/preset-env": "8.0.5",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
@@ -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,10 +194,10 @@
|
||||
"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",
|
||||
"lint-staged": "17.5.1",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -213,8 +212,8 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "8.2.2",
|
||||
"typescript-eslint": "8.70.0",
|
||||
"vite": "8.3.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -12,13 +12,24 @@ import { getEntityContext } from "./context/get_entity_context";
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
export const ENTITY_NAME_TYPES = [
|
||||
"floor",
|
||||
"area",
|
||||
"parent_device",
|
||||
"device",
|
||||
"entity",
|
||||
] as const;
|
||||
|
||||
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
|
||||
|
||||
export type EntityNameItem =
|
||||
| {
|
||||
type: "entity" | "device" | "area" | "floor";
|
||||
type: EntityNameType;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
@@ -91,7 +102,7 @@ export const computeEntityNameList = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
const { device, area, floor } = getEntityContext(
|
||||
const { device, parentDevice, area, floor } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
@@ -105,6 +116,8 @@ export const computeEntityNameList = (
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "parent_device":
|
||||
return parentDevice ? computeDeviceName(parentDevice) : undefined;
|
||||
case "area":
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
case "floor":
|
||||
@@ -136,14 +149,20 @@ export const computeEntityPickerDisplay = (
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
@@ -152,7 +171,7 @@ export const computeEntityPickerDisplay = (
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, entityName ? deviceName : undefined]
|
||||
[areaName, parentDeviceName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
device: DeviceRegistryEntry | null;
|
||||
parentDevice: DeviceRegistryEntry | null;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export const getEntityContext = (
|
||||
return {
|
||||
entity: null,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
@@ -65,6 +67,9 @@ export const getEntityEntryContext = (
|
||||
const entity = entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const areaId =
|
||||
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
|
||||
return {
|
||||
entity: entity,
|
||||
device: device || null,
|
||||
parentDevice: parentDevice || null,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -31,8 +31,6 @@ export type FormatEntityAttributeNameFunc = (
|
||||
attribute: string
|
||||
) => string;
|
||||
|
||||
export type EntityNameType = "entity" | "device" | "area" | "floor";
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,9 +7,12 @@ import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
ENTITY_NAME_TYPES,
|
||||
type EntityNameItem,
|
||||
type EntityNameType,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
@@ -170,6 +171,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
no-sort
|
||||
allow-custom-value
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
@@ -387,6 +389,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
);
|
||||
|
||||
if (context.device) options.add("device");
|
||||
if (context.parentDevice) options.add("parent_device");
|
||||
if (context.area) options.add("area");
|
||||
if (context.floor) options.add("floor");
|
||||
return options;
|
||||
@@ -399,8 +402,8 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const types = this._validTypes(entityId);
|
||||
|
||||
const items = (
|
||||
["entity", "device", "area", "floor"] as const
|
||||
const items = ENTITY_NAME_TYPES.filter(
|
||||
(name) => name !== "parent_device" || types.has(name)
|
||||
).map<PickerComboBoxItem>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = types.has(name);
|
||||
@@ -464,7 +467,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const excludedValues = new Set(
|
||||
this._items
|
||||
.filter((item) => UNIQUE_TYPES.has(item.type))
|
||||
.filter((item) => KNOWN_TYPES.has(item.type))
|
||||
.map((item) => formatOptionValue(item))
|
||||
);
|
||||
|
||||
|
||||
@@ -178,6 +178,17 @@ export class HaStateContentPicker extends LitElement {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.parentDevice) {
|
||||
contextItems.push({
|
||||
id: "parent_device_name",
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
sorting_label: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.area) {
|
||||
contextItems.push({
|
||||
id: "area_name",
|
||||
|
||||
@@ -52,6 +52,7 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "statisticId", weight: 3 },
|
||||
@@ -292,17 +293,27 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || id;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -318,6 +329,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
@@ -395,14 +407,20 @@ export class HaStatisticPicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -410,7 +428,11 @@ export class HaStatisticPicker extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
@@ -427,6 +449,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
|
||||
@@ -197,17 +197,27 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -761,6 +761,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
@@ -795,6 +798,15 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (parentDeviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.device-picker.parent_device"
|
||||
),
|
||||
value: parentDeviceName,
|
||||
});
|
||||
}
|
||||
|
||||
if (areaName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.area-picker.area"),
|
||||
|
||||
@@ -103,69 +103,87 @@ export class HaDurationInput extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// 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 _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
|
||||
(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)
|
||||
);
|
||||
}
|
||||
|
||||
private get _days() {
|
||||
return this.data?.days
|
||||
return this._data?.days
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.days))
|
||||
: Number(this.data.days)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.days))
|
||||
: Number(this._data.days)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _hours() {
|
||||
return this.data?.hours
|
||||
return this._data?.hours
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.hours))
|
||||
: Number(this.data.hours)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.hours))
|
||||
: Number(this._data.hours)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _minutes() {
|
||||
return this.data?.minutes
|
||||
return this._data?.minutes
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.minutes))
|
||||
: Number(this.data.minutes)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.minutes))
|
||||
: Number(this._data.minutes)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _seconds() {
|
||||
return this.data?.seconds
|
||||
return this._data?.seconds
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.seconds))
|
||||
: Number(this.data.seconds)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.seconds))
|
||||
: Number(this._data.seconds)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _milliseconds() {
|
||||
return this.data?.milliseconds
|
||||
return this._data?.milliseconds
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.milliseconds))
|
||||
: Number(this.data.milliseconds)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.milliseconds))
|
||||
: Number(this._data.milliseconds)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
@@ -236,8 +254,8 @@ export class HaDurationInput extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const negative = (ev.detail?.value || ev.target.value) === "-";
|
||||
this._toggleNegative = negative;
|
||||
if (this.data) {
|
||||
const value = { ...this.data };
|
||||
if (this._data) {
|
||||
const value = { ...this._data };
|
||||
FIELDS.forEach((t) => {
|
||||
if (value[t]) {
|
||||
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { HaSvgIcon } from "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-icon-arrow-next")
|
||||
export class HaIconArrowNext extends HaSvgIcon {
|
||||
@property() public override path =
|
||||
mainWindow.document.dir === "rtl" ? mdiArrowLeft : mdiArrowRight;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-icon-arrow-next": HaIconArrowNext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { HaSvgIcon } from "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-icon-arrow-prev")
|
||||
export class HaIconArrowPrev extends HaSvgIcon {
|
||||
@property() public override path =
|
||||
mainWindow.document.dir === "rtl" ? mdiArrowRight : mdiArrowLeft;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-icon-arrow-prev": HaIconArrowPrev;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mdiChevronDoubleLeft, mdiChevronDoubleRight } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import "./ha-icon-button";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
|
||||
@customElement("ha-icon-button-first")
|
||||
export class HaIconButtonFirst extends LitElement {
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() href?: string;
|
||||
|
||||
@property() target?: "_blank" | "_parent" | "_self" | "_top";
|
||||
|
||||
@property() rel?: string;
|
||||
|
||||
@property() download?: string;
|
||||
|
||||
@state() private _icon =
|
||||
mainWindow.document.dir === "rtl"
|
||||
? mdiChevronDoubleRight
|
||||
: mdiChevronDoubleLeft;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.label || this._localize("ui.common.first") || "First"}
|
||||
.path=${this._icon}
|
||||
.href=${this.href}
|
||||
.target=${this.target}
|
||||
.rel=${this.rel}
|
||||
.download=${this.download}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-icon-button-first": HaIconButtonFirst;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mdiChevronDoubleLeft, mdiChevronDoubleRight } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import "./ha-icon-button";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
|
||||
@customElement("ha-icon-button-last")
|
||||
export class HaIconButtonLast extends LitElement {
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() href?: string;
|
||||
|
||||
@property() target?: "_blank" | "_parent" | "_self" | "_top";
|
||||
|
||||
@property() rel?: string;
|
||||
|
||||
@property() download?: string;
|
||||
|
||||
@state() private _icon =
|
||||
mainWindow.document.dir === "rtl"
|
||||
? mdiChevronDoubleLeft
|
||||
: mdiChevronDoubleRight;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.label || this._localize("ui.common.last") || "Last"}
|
||||
.path=${this._icon}
|
||||
.href=${this.href}
|
||||
.target=${this.target}
|
||||
.rel=${this.rel}
|
||||
.download=${this.download}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-icon-button-last": HaIconButtonLast;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -65,7 +65,9 @@ class HaMenuButton extends LitElement {
|
||||
}
|
||||
const hasNotifications =
|
||||
this._hasNotifications &&
|
||||
(this._narrow || this._ui.dockedSidebar === "always_hidden");
|
||||
(this._narrow ||
|
||||
this._ui.dockedSidebar === "always_hidden" ||
|
||||
this._ui.kioskElementsHidden.has("sidebar"));
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.label=${this._localize("ui.sidebar.sidebar_toggle")}
|
||||
@@ -92,9 +94,14 @@ class HaMenuButton extends LitElement {
|
||||
this._unsubNotifications = undefined;
|
||||
}
|
||||
|
||||
// A hidden sidebar is only reachable as an overlay, so it needs this
|
||||
// button on wide screens too, unless the button itself is hidden.
|
||||
const showButton =
|
||||
this._ui?.kioskMode === false &&
|
||||
(this._narrow || this._ui.dockedSidebar === "always_hidden");
|
||||
!!this._ui &&
|
||||
!this._ui.kioskElementsHidden.has("sidebar_button") &&
|
||||
(this._narrow ||
|
||||
this._ui.dockedSidebar === "always_hidden" ||
|
||||
this._ui.kioskElementsHidden.has("sidebar"));
|
||||
|
||||
this._show = showButton || this._alwaysVisible;
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -4,6 +4,7 @@ import memoizeOne from "memoize-one";
|
||||
import type { DurationSelector } from "../../data/selector";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
import { createDurationData } from "../../common/datetime/create_duration_data";
|
||||
|
||||
@customElement("ha-selector-duration")
|
||||
export class HaTimeDuration extends LitElement {
|
||||
@@ -27,33 +28,8 @@ export class HaTimeDuration extends LitElement {
|
||||
}
|
||||
|
||||
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)));
|
||||
|
||||
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;
|
||||
}
|
||||
(value?: HaDurationData | string | number): HaDurationData | undefined =>
|
||||
createDurationData(value)
|
||||
);
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1327,6 +1327,9 @@ export class HaMap extends ReactiveElement {
|
||||
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
|
||||
border-bottom-color: rgba(80, 80, 80, 0.9) !important;
|
||||
}
|
||||
.maplibregl-ctrl-bottom-left {
|
||||
direction: ltr;
|
||||
}
|
||||
.dark .leaflet-bar a {
|
||||
background-color: #1c1c1c;
|
||||
color: #ffffff;
|
||||
@@ -1377,6 +1380,7 @@ export class HaMap extends ReactiveElement {
|
||||
transparent
|
||||
) !important;
|
||||
text-shadow: none !important;
|
||||
direction: ltr;
|
||||
}
|
||||
/* the theme tokens follow the page, so forced modes need the opposite values */
|
||||
#map.forced-light .leaflet-control-scale-line {
|
||||
|
||||
@@ -129,21 +129,31 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
.filter(this._filterPlayerEntities)
|
||||
.map<MediaPlayerComboBoxItem>((stateObj) => {
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const entityId = stateObj.entity_id;
|
||||
const domainName = domainToName(
|
||||
this._i18n.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -157,6 +167,7 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -62,17 +62,27 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
isRTL: boolean,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiClose,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
@@ -21,7 +19,6 @@ import {
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import {
|
||||
@@ -30,6 +27,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,
|
||||
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";
|
||||
@@ -59,10 +60,12 @@ import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-button";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-icon-next";
|
||||
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")
|
||||
@@ -132,10 +135,35 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
private _loadedConfigEntryId?: string;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (!this.subEntry && changedProps.has("itemId")) {
|
||||
this._updateItemData();
|
||||
}
|
||||
if (
|
||||
changedProps.has("itemId") ||
|
||||
changedProps.has("type") ||
|
||||
changedProps.has("hass")
|
||||
) {
|
||||
this._updateDomain();
|
||||
}
|
||||
}
|
||||
|
||||
private _updateDomain() {
|
||||
if (this.type === "entity") {
|
||||
this._setDomainName(computeDomain(this.itemId));
|
||||
return;
|
||||
}
|
||||
if (this.type !== "device") {
|
||||
return;
|
||||
}
|
||||
const configEntryId =
|
||||
this.hass.devices?.[this.itemId]?.primary_config_entry;
|
||||
if (configEntryId && configEntryId !== this._loadedConfigEntryId) {
|
||||
this._loadedConfigEntryId = configEntryId;
|
||||
this._getDeviceDomain(configEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -288,19 +316,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
></ha-icon-button>
|
||||
`
|
||||
: this.subEntry && this.type === "entity"
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? mdiChevronLeft
|
||||
: mdiChevronRight
|
||||
}
|
||||
slot="end"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
? html` <ha-icon-next slot="end"></ha-icon-next> `
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
@@ -376,125 +392,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
|
||||
@@ -503,7 +419,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>
|
||||
@@ -518,7 +434,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>
|
||||
@@ -599,32 +515,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;
|
||||
});
|
||||
@@ -638,7 +575,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 (
|
||||
@@ -674,7 +614,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _itemData = memoizeOne((type: TargetType, item: string) => {
|
||||
private _itemData(type: TargetType, item: string) {
|
||||
if (type === "floor") {
|
||||
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
|
||||
return {
|
||||
@@ -697,9 +637,25 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
if (type === "device") {
|
||||
const device: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
|
||||
|
||||
if (device?.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const context = [
|
||||
area ? computeAreaName(area) : undefined,
|
||||
parentDevice ? computeDeviceName(parentDevice) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ "
|
||||
);
|
||||
|
||||
return {
|
||||
name: device
|
||||
@@ -709,19 +665,17 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.states
|
||||
)
|
||||
: item,
|
||||
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
|
||||
context,
|
||||
fallbackIconPath: mdiDevices,
|
||||
notFound: !device,
|
||||
};
|
||||
}
|
||||
if (type === "entity") {
|
||||
this._setDomainName(computeDomain(item));
|
||||
|
||||
const stateObject: HassEntity | undefined = this.hass.states[item];
|
||||
const entityName = stateObject
|
||||
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
|
||||
: item;
|
||||
const { area, device } = stateObject
|
||||
const { area, device, parentDevice } = stateObject
|
||||
? getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
@@ -729,10 +683,17 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: undefined, device: undefined };
|
||||
: { area: undefined, device: undefined, parentDevice: undefined };
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceName(parentDevice)
|
||||
: undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
@@ -760,7 +721,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
fallbackIconPath: mdiLabel,
|
||||
notFound: !label,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _setDomainName(domain: string) {
|
||||
this._domainName = domainToName(this.hass.localize, domain);
|
||||
|
||||
@@ -115,6 +115,7 @@ const updateUi = (
|
||||
value.panelUrl !== hass.panelUrl ||
|
||||
value.dockedSidebar !== hass.dockedSidebar ||
|
||||
value.kioskMode !== hass.kioskMode ||
|
||||
value.kioskElementsHidden !== hass.kioskElementsHidden ||
|
||||
value.enableShortcuts !== hass.enableShortcuts ||
|
||||
value.vibrate !== hass.vibrate ||
|
||||
value.suspendWhenHidden !== hass.suspendWhenHidden
|
||||
@@ -126,6 +127,7 @@ const updateUi = (
|
||||
panelUrl: hass.panelUrl,
|
||||
dockedSidebar: hass.dockedSidebar,
|
||||
kioskMode: hass.kioskMode,
|
||||
kioskElementsHidden: hass.kioskElementsHidden,
|
||||
enableShortcuts: hass.enableShortcuts,
|
||||
vibrate: hass.vibrate,
|
||||
suspendWhenHidden: hass.suspendWhenHidden,
|
||||
|
||||
@@ -31,6 +31,10 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.deviceName",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
name: "search_labels.areaName",
|
||||
weight: 6,
|
||||
@@ -129,14 +133,20 @@ export const getEntities = (
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
@@ -146,7 +156,11 @@ export const getEntities = (
|
||||
}
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -159,6 +173,7 @@ export const getEntities = (
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The parts of the UI a kiosk client can hide.
|
||||
*
|
||||
* A client names them over the external bus (`kiosk_mode/set`) as either
|
||||
* `excluded_elements` (hide exactly these) or `included_elements` (hide
|
||||
* everything except these), so the same set can be described from either side.
|
||||
*/
|
||||
export const KIOSK_ELEMENTS = [
|
||||
/** The sidebar itself: never docked, only reachable as an overlay. */
|
||||
"sidebar",
|
||||
/**
|
||||
* The hamburger button that opens the sidebar, in every panel's toolbar.
|
||||
* In the app panel it lives in `app_panel_header`, so hiding that header
|
||||
* hides this button too, whatever this element is set to.
|
||||
*/
|
||||
"sidebar_button",
|
||||
/**
|
||||
* The dashboard view tabs. The current view's title is shown instead.
|
||||
* Only outside edit mode: in edit mode the tabs are how views are selected,
|
||||
* moved, edited and added, so they stay. Hide `dashboard_edit_button` as well
|
||||
* to keep that button from being a way back to them.
|
||||
*/
|
||||
"dashboard_tabs",
|
||||
/** The "+" menu that adds a device, automation, area or person. */
|
||||
"dashboard_add_button",
|
||||
/** The search button that opens the quick bar. */
|
||||
"dashboard_search_button",
|
||||
/** The Assist button. */
|
||||
"dashboard_assist_button",
|
||||
/** The pencil that switches the dashboard into edit mode. */
|
||||
"dashboard_edit_button",
|
||||
/**
|
||||
* The header the app panel draws above an ingress add-on iframe, including
|
||||
* its sidebar button. With it hidden the iframe takes the full height; a
|
||||
* client can still open the sidebar with `sidebar/show` or `sidebar/toggle`.
|
||||
*/
|
||||
"app_panel_header",
|
||||
] as const;
|
||||
|
||||
export type KioskElement = (typeof KIOSK_ELEMENTS)[number];
|
||||
|
||||
const KIOSK_ELEMENTS_SET: ReadonlySet<string> = new Set(KIOSK_ELEMENTS);
|
||||
|
||||
/**
|
||||
* Nothing hidden. Shared instance so context consumers that compare by
|
||||
* reference don't see a change every time kiosk mode is switched off.
|
||||
*/
|
||||
export const NO_KIOSK_ELEMENTS_HIDDEN: ReadonlySet<KioskElement> = new Set();
|
||||
|
||||
/**
|
||||
* What a client gets when it enables kiosk mode without naming any elements.
|
||||
* This is the set kiosk mode hid back when it was a single boolean, so a client
|
||||
* that only sends `enable` keeps the behavior it has today.
|
||||
*/
|
||||
export const DEFAULT_KIOSK_ELEMENTS_HIDDEN: ReadonlySet<KioskElement> = new Set(
|
||||
[
|
||||
"sidebar",
|
||||
"sidebar_button",
|
||||
"dashboard_add_button",
|
||||
"dashboard_search_button",
|
||||
"dashboard_edit_button",
|
||||
"app_panel_header",
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Who asked for kiosk mode. Each source's request is kept on its own and the
|
||||
* hidden elements are their union, so one source switching kiosk mode off
|
||||
* doesn't undo what another still asks for.
|
||||
*
|
||||
* - `external_app`: the companion app, over `kiosk_mode/set`.
|
||||
* - `app_panel`: an ingress add-on, via `home-assistant/subscribe-properties`.
|
||||
*/
|
||||
export type KioskModeSource = "external_app" | "app_panel";
|
||||
|
||||
export interface KioskModeParams {
|
||||
enable: boolean;
|
||||
/** Defaults to `external_app`. */
|
||||
source?: KioskModeSource;
|
||||
/** Hide exactly these. Mutually exclusive with `includedElements`. */
|
||||
excludedElements?: readonly string[];
|
||||
/** Hide everything except these. Mutually exclusive with `excludedElements`. */
|
||||
includedElements?: readonly string[];
|
||||
}
|
||||
|
||||
const isKioskElement = (value: string): value is KioskElement =>
|
||||
KIOSK_ELEMENTS_SET.has(value);
|
||||
|
||||
/**
|
||||
* Resolve a kiosk request into the elements to hide.
|
||||
*
|
||||
* Element names this frontend doesn't know are ignored rather than rejected, so
|
||||
* a newer client naming an element an older frontend has never heard of still
|
||||
* gets the rest of its request honored.
|
||||
*/
|
||||
export const resolveKioskElementsHidden = ({
|
||||
enable,
|
||||
excludedElements,
|
||||
includedElements,
|
||||
}: KioskModeParams): ReadonlySet<KioskElement> => {
|
||||
if (!enable) {
|
||||
return NO_KIOSK_ELEMENTS_HIDDEN;
|
||||
}
|
||||
if (excludedElements) {
|
||||
return new Set(excludedElements.filter(isKioskElement));
|
||||
}
|
||||
if (includedElements) {
|
||||
const shown = new Set(includedElements);
|
||||
return new Set(KIOSK_ELEMENTS.filter((element) => !shown.has(element)));
|
||||
}
|
||||
return DEFAULT_KIOSK_ELEMENTS_HIDDEN;
|
||||
};
|
||||
|
||||
/**
|
||||
* Combine the elements each kiosk source hides into the set the UI hides.
|
||||
*
|
||||
* Returns a source's set unchanged when it is the only one hiding anything, and
|
||||
* the shared empty set when none is, so context consumers comparing by
|
||||
* reference only see a change when the combined result actually changes shape.
|
||||
*/
|
||||
export const combineKioskElementsHidden = (
|
||||
requests: Iterable<ReadonlySet<KioskElement>>
|
||||
): ReadonlySet<KioskElement> => {
|
||||
const nonEmpty = [...requests].filter((request) => request.size > 0);
|
||||
if (nonEmpty.length === 0) {
|
||||
return NO_KIOSK_ELEMENTS_HIDDEN;
|
||||
}
|
||||
if (nonEmpty.length === 1) {
|
||||
return nonEmpty[0];
|
||||
}
|
||||
return new Set(nonEmpty.flatMap((request) => [...request]));
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
+8
-1
@@ -3,10 +3,17 @@ import type { HomeAssistant } from "../types";
|
||||
/** Transport with host and port, or with the serial device path. */
|
||||
export type ModbusEndpoint = [string, string, number] | [string, string];
|
||||
|
||||
/** Whether a connection belongs to a config entry or to a YAML hub. */
|
||||
export type ModbusConnectionSource = "config_entry" | "yaml";
|
||||
|
||||
export interface ModbusConnection {
|
||||
endpoint: ModbusEndpoint;
|
||||
connected: boolean;
|
||||
/** The unit IDs each config entry holds, keyed by config entry ID. */
|
||||
source: ModbusConnectionSource;
|
||||
/**
|
||||
* The unit IDs each holder uses, keyed by config entry ID, or by hub name
|
||||
* when `source` is `"yaml"`.
|
||||
*/
|
||||
units: Record<string, number[]>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Selector } from "../selector";
|
||||
@@ -82,10 +83,7 @@ export const formatSelectorValue = (
|
||||
if (!stateObj) {
|
||||
return entityId;
|
||||
}
|
||||
const name = hass.formatEntityName(stateObj, [
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
]);
|
||||
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
return name || entityId;
|
||||
})
|
||||
.join(", ");
|
||||
@@ -104,6 +102,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);
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { mdiArrowRight } from "@mdi/js";
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { formatNumericDuration } from "../../common/datetime/format_duration";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-icon-arrow-next";
|
||||
import type { HttpConfig } from "../../data/http";
|
||||
import {
|
||||
HTTP_CONFIG_FIELDS,
|
||||
@@ -158,10 +156,6 @@ export class DialogHttpPendingConfig
|
||||
|
||||
const changes = this._changedFields;
|
||||
const { stable, pending } = this._params.state;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
@@ -233,12 +227,7 @@ export class DialogHttpPendingConfig
|
||||
<span class="old"
|
||||
>${this._formatValue(key, stable[key])}</span
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowRight}
|
||||
style=${styleMap({
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
></ha-svg-icon>
|
||||
<ha-icon-arrow-next></ha-icon-arrow-next>
|
||||
<span class="new"
|
||||
>${this._formatValue(key, pending![key])}</span
|
||||
>
|
||||
@@ -403,7 +392,7 @@ export class DialogHttpPendingConfig
|
||||
.values .new {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.values ha-svg-icon {
|
||||
.values ha-icon-arrow-next {
|
||||
--mdc-icon-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiChevronRight,
|
||||
mdiFan,
|
||||
mdiHomeImportOutline,
|
||||
mdiMapMarker,
|
||||
@@ -25,6 +24,7 @@ import "../../../components/ha-control-select-menu";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-icon-next";
|
||||
import {
|
||||
apiContext,
|
||||
entitiesContext,
|
||||
@@ -489,7 +489,7 @@ class MoreInfoVacuum extends LitElement {
|
||||
</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<ha-svg-icon .path=${mdiChevronRight}></ha-svg-icon>
|
||||
<ha-icon-next></ha-icon-next>
|
||||
</div>
|
||||
</button>
|
||||
`
|
||||
|
||||
@@ -591,11 +591,17 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const deviceName = context?.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context?.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context?.area ? computeAreaName(context.area) : undefined;
|
||||
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
const addToMenuItem = this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.item"
|
||||
);
|
||||
|
||||
@@ -82,7 +82,7 @@ class HaMoreInfoRelated extends LitElement {
|
||||
? (domain as ItemType)
|
||||
: "entity";
|
||||
|
||||
const { floor, area, device } = getEntityContext(
|
||||
const { floor, area, device, parentDevice } = getEntityContext(
|
||||
this._stateObj,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
@@ -91,6 +91,13 @@ class HaMoreInfoRelated extends LitElement {
|
||||
);
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceNameDisplay(
|
||||
parentDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
const deviceName = device
|
||||
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
|
||||
: undefined;
|
||||
@@ -124,6 +131,14 @@ class HaMoreInfoRelated extends LitElement {
|
||||
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
|
||||
});
|
||||
}
|
||||
if (parentDevice && parentDeviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.dialogs.more_info_control.parent_device",
|
||||
value: parentDeviceName,
|
||||
href: `/config/devices/device/${parentDevice.id}`,
|
||||
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
|
||||
});
|
||||
}
|
||||
if (device && deviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.components.related-items.device",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiChevronLeft, mdiMenuDown } from "@mdi/js";
|
||||
import { mdiMenuDown } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -9,6 +9,7 @@ import { formatLanguageCode } from "../../common/language/format_language";
|
||||
import "../../components/chips/ha-assist-chip";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dropdown";
|
||||
import "../../components/ha-icon-button-prev";
|
||||
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
|
||||
import "../../components/ha-dropdown-item";
|
||||
import { getLanguageOptions } from "../../components/ha-language-picker";
|
||||
@@ -152,12 +153,11 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
hideNavigationIcon
|
||||
? html`<span slot="headerNavigationIcon"></span>`
|
||||
: this._previousSteps.length
|
||||
? html`<ha-icon-button
|
||||
? html`<ha-icon-button-prev
|
||||
slot="headerNavigationIcon"
|
||||
.label=${this.hass.localize("ui.common.back") ?? "Back"}
|
||||
.path=${mdiChevronLeft}
|
||||
@click=${this._goToPreviousStep}
|
||||
></ha-icon-button>`
|
||||
></ha-icon-button-prev>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
|
||||
@@ -98,7 +98,27 @@ export const handleExternalMessage = (
|
||||
} else if (msg.command === "bar_code/aborted") {
|
||||
barCodeListeners.forEach((listener) => listener(msg));
|
||||
} else if (msg.command === "kiosk_mode/set") {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
|
||||
const { enable, excluded_elements, included_elements } = msg.payload;
|
||||
// The two lists are two ways of describing the same set, so a message
|
||||
// carrying both has no single meaning we could act on.
|
||||
if (excluded_elements && included_elements) {
|
||||
bus.fireMessage({
|
||||
id: msg.id,
|
||||
type: "result",
|
||||
success: false,
|
||||
error: {
|
||||
code: "invalid_format",
|
||||
message:
|
||||
"Pass either excluded_elements or included_elements, not both",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
fireEvent(window, "hass-kiosk-mode", {
|
||||
enable,
|
||||
excludedElements: excluded_elements,
|
||||
includedElements: included_elements,
|
||||
});
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -325,8 +325,29 @@ export interface EMIncomingMessageKioskModeSet {
|
||||
id: number;
|
||||
type: "command";
|
||||
command: "kiosk_mode/set";
|
||||
/**
|
||||
* Element names are listed in `KIOSK_ELEMENTS`. Two of them depend on
|
||||
* another element:
|
||||
* - `app_panel_header` holds the app panel's sidebar button, so hiding the
|
||||
* header hides that button even when `sidebar_button` is not hidden.
|
||||
* - `dashboard_tabs` is only hidden outside edit mode, where the tabs are the
|
||||
* view editor; hide `dashboard_edit_button` too to keep them out of reach.
|
||||
*/
|
||||
payload: {
|
||||
enable: boolean;
|
||||
/**
|
||||
* Hide exactly these parts of the UI. Mutually exclusive with
|
||||
* `included_elements`; sending both fails the command. Plain strings, as a
|
||||
* newer client may name elements this frontend doesn't know; those are
|
||||
* ignored (see `resolveKioskElementsHidden`).
|
||||
*/
|
||||
excluded_elements?: string[];
|
||||
/**
|
||||
* Hide every part of the UI except these. Mutually exclusive with
|
||||
* `excluded_elements`; sending both fails the command. Unknown names are
|
||||
* ignored, as for `excluded_elements`.
|
||||
*/
|
||||
included_elements?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../data/context";
|
||||
import { updateHassGroups } from "../data/context/updateContext";
|
||||
import type { IconCategory } from "../data/icons";
|
||||
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../data/kiosk_mode";
|
||||
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
|
||||
import {
|
||||
DateFormat,
|
||||
@@ -464,6 +465,7 @@ export const provideHass = (
|
||||
vibrate: true,
|
||||
debugConnection: false,
|
||||
kioskMode: false,
|
||||
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
|
||||
suspendWhenHidden: false,
|
||||
// @ts-ignore
|
||||
async callService(domain, service, data, target, _notify, returnResponse) {
|
||||
|
||||
@@ -60,7 +60,9 @@ export class HaInitPage extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: html`<p class=${classMap({ "loading-text": !this.migration })}>
|
||||
: html`<p
|
||||
class=${classMap({ "loading-text": !this.migration, rtl: document.dir === "rtl" })}
|
||||
>
|
||||
${
|
||||
this.migration
|
||||
? html`<span class="migration-text"
|
||||
@@ -124,6 +126,9 @@ export class HaInitPage extends LitElement {
|
||||
.loading-text {
|
||||
opacity: 0.66;
|
||||
}
|
||||
.rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ export class HomeAssistantMain extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const sidebarNarrow =
|
||||
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode;
|
||||
this._sidebarNarrow ||
|
||||
this._externalSidebar ||
|
||||
this.hass.kioskElementsHidden.has("sidebar");
|
||||
|
||||
const isPanelReady =
|
||||
this.hass.panels && this.hass.userData && this.hass.systemData;
|
||||
@@ -105,7 +107,7 @@ export class HomeAssistantMain extends LitElement {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this._sidebarNarrow || this.hass.kioskMode) {
|
||||
if (this._sidebarNarrow || this.hass.kioskElementsHidden.has("sidebar")) {
|
||||
this._drawerOpen = ev.detail?.open ?? !this._drawerOpen;
|
||||
} else {
|
||||
fireEvent(this, "hass-dock-sidebar", {
|
||||
@@ -144,7 +146,9 @@ export class HomeAssistantMain extends LitElement {
|
||||
|
||||
this.toggleAttribute(
|
||||
"modal",
|
||||
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode
|
||||
this._sidebarNarrow ||
|
||||
this._externalSidebar ||
|
||||
this.hass.kioskElementsHidden.has("sidebar")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -49,8 +49,6 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
@state() private _loadingMessage?: string;
|
||||
|
||||
@state() private _kioskMode = false;
|
||||
|
||||
@state() private _iframeLoaded = false;
|
||||
|
||||
// Set when the addon signals (via subscribe-properties) that it handles the
|
||||
@@ -83,11 +81,6 @@ class HaPanelApp extends LitElement {
|
||||
) {
|
||||
this._sendPropertiesToIframe();
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (oldHass && oldHass.kioskMode !== this.hass.kioskMode) {
|
||||
this._kioskMode = this.hass.kioskMode;
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
@@ -110,7 +103,10 @@ class HaPanelApp extends LitElement {
|
||||
this._fetchDataTimeout = undefined;
|
||||
}
|
||||
if (this._enabledKioskMode) {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: false });
|
||||
fireEvent(window, "hass-kiosk-mode", {
|
||||
enable: false,
|
||||
source: "app_panel",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,18 +117,35 @@ class HaPanelApp extends LitElement {
|
||||
></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
// The sidebar button only exists inside this header, so hiding the header
|
||||
// hides the button too (documented on `app_panel_header`). Keeping it would
|
||||
// need a floating control over the add-on, which kiosk mode never had.
|
||||
const hideHeader = this.hass.kioskElementsHidden.has("app_panel_header");
|
||||
const sidebarHidden = this.hass.kioskElementsHidden.has("sidebar");
|
||||
const hideMenuButton = this.hass.kioskElementsHidden.has("sidebar_button");
|
||||
|
||||
// Make sure this all is 1 template so hiding toolbar doesn't reload iframe
|
||||
return html`
|
||||
${
|
||||
!this._kioskMode &&
|
||||
(this.narrow || this.hass.dockedSidebar === "always_hidden")
|
||||
!hideHeader &&
|
||||
(this.narrow ||
|
||||
this.hass.dockedSidebar === "always_hidden" ||
|
||||
sidebarHidden)
|
||||
? html`
|
||||
<div class="header">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
|
||||
.path=${mdiMenu}
|
||||
@click=${this._toggleMenu}
|
||||
></ha-icon-button>
|
||||
${
|
||||
hideMenuButton
|
||||
? nothing
|
||||
: html`
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.sidebar.sidebar_toggle"
|
||||
)}
|
||||
.path=${mdiMenu}
|
||||
@click=${this._toggleMenu}
|
||||
></ha-icon-button>
|
||||
`
|
||||
}
|
||||
<div class="main-title">${this._addon.name}</div>
|
||||
</div>
|
||||
`
|
||||
@@ -141,7 +154,7 @@ class HaPanelApp extends LitElement {
|
||||
<iframe
|
||||
class=${classMap({
|
||||
loaded: this._iframeLoaded,
|
||||
"kiosk-mode": this._kioskMode,
|
||||
"kiosk-mode": hideHeader,
|
||||
"handle-safe-area": this._handleSafeArea,
|
||||
})}
|
||||
title=${this._addon.name}
|
||||
@@ -183,7 +196,10 @@ class HaPanelApp extends LitElement {
|
||||
this._iframeLoaded = false;
|
||||
// Reset state when switching apps
|
||||
if (this._enabledKioskMode) {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: false });
|
||||
fireEvent(window, "hass-kiosk-mode", {
|
||||
enable: false,
|
||||
source: "app_panel",
|
||||
});
|
||||
this._enabledKioskMode = false;
|
||||
}
|
||||
this._iframeSubscribeUpdates = false;
|
||||
@@ -429,9 +445,14 @@ class HaPanelApp extends LitElement {
|
||||
// safe area itself; we then forward the inset values below.
|
||||
this._handleSafeArea = !!data.handleSafeArea;
|
||||
this._sendPropertiesToIframe();
|
||||
if (data.kioskMode && !this.hass.kioskMode) {
|
||||
// Kept apart from the companion app's own kiosk request, so it adds
|
||||
// to that selection and leaves it in place when the addon lets go.
|
||||
if (data.kioskMode && !this._enabledKioskMode) {
|
||||
this._enabledKioskMode = true;
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: true });
|
||||
fireEvent(window, "hass-kiosk-mode", {
|
||||
enable: true,
|
||||
source: "app_panel",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -439,7 +460,10 @@ class HaPanelApp extends LitElement {
|
||||
this._iframeSubscribeUpdates = false;
|
||||
this._handleSafeArea = false;
|
||||
if (this._enabledKioskMode) {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: false });
|
||||
fireEvent(window, "hass-kiosk-mode", {
|
||||
enable: false,
|
||||
source: "app_panel",
|
||||
});
|
||||
this._enabledKioskMode = false;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -82,6 +82,9 @@ export class HAFullCalendar extends LitElement {
|
||||
|
||||
@property({ attribute: "add-fab-style" }) public addFabStyle = "on_top";
|
||||
|
||||
@property({ attribute: "auto-height", type: Boolean }) public autoHeight =
|
||||
false;
|
||||
|
||||
@property({ attribute: false }) public events: CalendarEvent[] = [];
|
||||
|
||||
@property({ attribute: false }) public calendars: CalendarData[] = [];
|
||||
@@ -314,6 +317,10 @@ export class HAFullCalendar extends LitElement {
|
||||
this.calendar!.setOption("eventDisplay", this.eventDisplay);
|
||||
}
|
||||
|
||||
if (changedProps.has("autoHeight")) {
|
||||
this.calendar.setOption("height", this._height);
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant;
|
||||
|
||||
if (oldHass && oldHass.language !== this.hass.language) {
|
||||
@@ -345,6 +352,7 @@ export class HAFullCalendar extends LitElement {
|
||||
: this.hass.config.time_zone,
|
||||
firstDay: firstWeekdayIndex(this.hass.locale),
|
||||
initialView,
|
||||
height: this._height,
|
||||
eventDisplay: this.eventDisplay,
|
||||
eventTimeFormat: {
|
||||
hour: useAmPm(this.hass.locale) ? "numeric" : "2-digit",
|
||||
@@ -364,6 +372,10 @@ export class HAFullCalendar extends LitElement {
|
||||
this._fireViewChanged();
|
||||
}
|
||||
|
||||
private get _height(): CalendarOptions["height"] {
|
||||
return this.autoHeight ? "auto" : defaultFullCalendarConfig.height;
|
||||
}
|
||||
|
||||
// Return if there are calendars that support creating events
|
||||
private get _hasMutableCalendars(): boolean {
|
||||
return this.calendars.some((selCal) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { startOfYesterday } from "date-fns";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiChevronRight,
|
||||
mdiDelete,
|
||||
mdiDevices,
|
||||
mdiDotsVertical,
|
||||
@@ -39,6 +38,7 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-icon-button-next";
|
||||
import "../../../components/ha-list";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
@@ -604,12 +604,11 @@ class HaConfigAreaPage extends LitElement {
|
||||
back: "1",
|
||||
})}"
|
||||
>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronRight}
|
||||
<ha-icon-button-next
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.show_more"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
></ha-icon-button-next>
|
||||
</a>
|
||||
</div>
|
||||
<ha-logbook
|
||||
|
||||
@@ -13,9 +13,11 @@ import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { mainWindow } from "../../../common/dom/get_main_window";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
|
||||
import { computeFloorName } from "../../../common/entity/compute_floor_name";
|
||||
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
|
||||
import { isNumericState } from "../../../common/number/format_number";
|
||||
import { stringCompare } from "../../../common/string/compare";
|
||||
import type {
|
||||
@@ -901,6 +903,12 @@ class DialogAddAutomationElement
|
||||
const [targetType, targetId] = this._extractTypeAndIdFromTarget(
|
||||
this._selectedTarget
|
||||
);
|
||||
const separator = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ ";
|
||||
|
||||
if (targetId) {
|
||||
if (targetType === "area") {
|
||||
@@ -913,11 +921,21 @@ class DialogAddAutomationElement
|
||||
);
|
||||
}
|
||||
} else if (targetType === "device") {
|
||||
const areaId = this.hass.devices[targetId]?.area_id;
|
||||
if (areaId) {
|
||||
subtitle = computeAreaName(this.hass.areas[areaId]) || areaId;
|
||||
const device = this.hass.devices[targetId];
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
if (area) {
|
||||
subtitle = [
|
||||
computeAreaName(area) || area.area_id,
|
||||
parentDevice ? computeDeviceName(parentDevice) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
} else {
|
||||
const device = this.hass.devices[targetId];
|
||||
subtitle = this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${device?.entry_type === "service" ? "services" : "unassigned_devices"}`
|
||||
);
|
||||
@@ -933,25 +951,28 @@ class DialogAddAutomationElement
|
||||
);
|
||||
} else {
|
||||
const stateObj = this.hass.states[targetId];
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
subtitle = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ "
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
subtitle = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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}`
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiCog,
|
||||
mdiChevronRight,
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
@@ -48,6 +47,7 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-button-next";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/item/ha-list-item-base";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
@@ -959,12 +959,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
back: "1",
|
||||
})}"
|
||||
>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronRight}
|
||||
<ha-icon-button-next
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.show_more"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
></ha-icon-button-next>
|
||||
</a>
|
||||
</div>
|
||||
<ha-logbook
|
||||
|
||||
@@ -31,6 +31,7 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "id", weight: 2 },
|
||||
@@ -64,14 +65,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
@@ -89,6 +96,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
|
||||
@@ -160,11 +160,17 @@ export class DialogVacuumSegmentMapping
|
||||
const deviceName = context?.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context?.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context?.area ? computeAreaName(context.area) : undefined;
|
||||
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
|
||||
@@ -3,7 +3,6 @@ import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiCancel,
|
||||
mdiChevronRight,
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
mdiEye,
|
||||
@@ -69,6 +68,7 @@ import "../../../components/ha-filter-states";
|
||||
import "../../../components/ha-filter-voice-assistants";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-sub-menu";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
@@ -906,10 +906,7 @@ export class HaConfigEntities extends LitElement {
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.add_label"
|
||||
)}
|
||||
<ha-svg-icon
|
||||
slot="end"
|
||||
.path=${mdiChevronRight}
|
||||
></ha-svg-icon>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
${this._renderLabelItems("submenu")}
|
||||
</ha-dropdown-item>
|
||||
<wa-divider></wa-divider>`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
+40
-15
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
mdiCableData,
|
||||
mdiFileDocumentOutline,
|
||||
mdiLan,
|
||||
mdiPuzzle,
|
||||
mdiRefresh,
|
||||
@@ -22,7 +23,10 @@ import "../../../../../components/ha-svg-icon";
|
||||
import type { ConfigEntry } from "../../../../../data/config_entries";
|
||||
import { getConfigEntries } from "../../../../../data/config_entries";
|
||||
import { domainToName } from "../../../../../data/integration";
|
||||
import type { ModbusConnection } from "../../../../../data/modbus";
|
||||
import type {
|
||||
ModbusConnection,
|
||||
ModbusConnectionSource,
|
||||
} from "../../../../../data/modbus";
|
||||
import {
|
||||
listModbusConnections,
|
||||
modbusEndpointTarget,
|
||||
@@ -43,7 +47,8 @@ const isKnownTransport = (transport: string): transport is ModbusTransport =>
|
||||
(TRANSPORTS as readonly string[]).includes(transport);
|
||||
|
||||
interface ConnectionHolder {
|
||||
entryId: string;
|
||||
/** A config entry ID, or a hub name on a YAML connection. */
|
||||
id: string;
|
||||
entry?: ConfigEntry;
|
||||
units: number[];
|
||||
}
|
||||
@@ -51,9 +56,10 @@ interface ConnectionHolder {
|
||||
interface ConnectionListItem {
|
||||
icon: string;
|
||||
primary: string;
|
||||
transport: string;
|
||||
secondary: string;
|
||||
state: string;
|
||||
serialDevice?: string;
|
||||
source: ModbusConnectionSource;
|
||||
holders: ConnectionHolder[];
|
||||
connection: ModbusConnection;
|
||||
}
|
||||
@@ -114,22 +120,34 @@ export class ModbusConfigDashboard extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
// A YAML hub is a link of its own rather than a hold on a shared
|
||||
// connection, so the same device can be listed twice
|
||||
private _secondaryName(connection: ModbusConnection): string {
|
||||
const transport = this._transportName(connection.endpoint[0]);
|
||||
if (connection.source !== "yaml") {
|
||||
return transport;
|
||||
}
|
||||
return `${transport} • ${this.hass.localize(
|
||||
"ui.panel.config.modbus.configured_in_yaml"
|
||||
)}`;
|
||||
}
|
||||
|
||||
private _connectionListItem(
|
||||
connection: ModbusConnection,
|
||||
entries: Record<string, ConfigEntry>
|
||||
): ConnectionListItem {
|
||||
const [transport] = connection.endpoint;
|
||||
const serialDevice = modbusSerialDevice(connection.endpoint);
|
||||
|
||||
return {
|
||||
icon: serialDevice ? mdiCableData : mdiLan,
|
||||
primary: modbusEndpointTarget(connection.endpoint),
|
||||
transport: this._transportName(transport),
|
||||
secondary: this._secondaryName(connection),
|
||||
state: this._stateName(connection.connected, serialDevice !== undefined),
|
||||
serialDevice,
|
||||
holders: Object.entries(connection.units).map(([entryId, units]) => ({
|
||||
entryId,
|
||||
entry: entries[entryId],
|
||||
source: connection.source,
|
||||
holders: Object.entries(connection.units).map(([id, units]) => ({
|
||||
id,
|
||||
entry: connection.source === "yaml" ? undefined : entries[id],
|
||||
units,
|
||||
})),
|
||||
connection,
|
||||
@@ -161,13 +179,20 @@ export class ModbusConfigDashboard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHolder(holder: ConnectionHolder): TemplateResult {
|
||||
// An entry removed while the connection was listed has nowhere to link to
|
||||
private _renderHolder(
|
||||
holder: ConnectionHolder,
|
||||
source: ModbusConnectionSource
|
||||
): TemplateResult {
|
||||
// A YAML hub is named by the key it is configured under, and an entry
|
||||
// removed while the connection was listed has nowhere to link to
|
||||
if (!holder.entry) {
|
||||
return html`
|
||||
<ha-md-list-item class="holder">
|
||||
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
|
||||
<div slot="headline">${holder.entryId}</div>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${source === "yaml" ? mdiFileDocumentOutline : mdiPuzzle}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">${holder.id}</div>
|
||||
${this._renderUnits(holder)}
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
@@ -179,7 +204,7 @@ export class ModbusConfigDashboard extends LitElement {
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
class="holder"
|
||||
href=${`/config/integrations/integration/${domain}#config_entry=${holder.entryId}`}
|
||||
href=${`/config/integrations/integration/${domain}#config_entry=${holder.id}`}
|
||||
>
|
||||
<img
|
||||
slot="start"
|
||||
@@ -234,10 +259,10 @@ export class ModbusConfigDashboard extends LitElement {
|
||||
<ha-md-list-item class="connection">
|
||||
<ha-svg-icon slot="start" .path=${item.icon}></ha-svg-icon>
|
||||
<div slot="headline">${item.primary}</div>
|
||||
<div slot="supporting-text">${item.transport}</div>
|
||||
<div slot="supporting-text">${item.secondary}</div>
|
||||
${this._renderState(item)}
|
||||
</ha-md-list-item>
|
||||
${item.holders.map((holder) => this._renderHolder(holder))}
|
||||
${item.holders.map((holder) => this._renderHolder(holder, item.source))}
|
||||
${
|
||||
item.serialDevice && isComponentLoaded(this.hass.config, "usb")
|
||||
? this._renderSerialPortLink()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiChevronRight } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -9,7 +8,7 @@ import type {
|
||||
HASSDomEvent,
|
||||
} from "../../../../../common/dom/fire_event";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-icon-button-next";
|
||||
import "../../../../../components/ha-list";
|
||||
import "../../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
|
||||
@@ -186,15 +185,14 @@ export class ZHADeviceEndpointList extends LitElement {
|
||||
${
|
||||
this.showDeviceLink
|
||||
? html`
|
||||
<ha-icon-button
|
||||
<ha-icon-button-next
|
||||
slot="end"
|
||||
.path=${mdiChevronRight}
|
||||
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.config.zha.groups.open_device"
|
||||
)}
|
||||
@click=${this._stopPropagation}
|
||||
></ha-icon-button>
|
||||
></ha-icon-button-next>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
@@ -214,14 +212,13 @@ export class ZHADeviceEndpointList extends LitElement {
|
||||
${
|
||||
this.showDeviceLink
|
||||
? html`
|
||||
<ha-icon-button
|
||||
<ha-icon-button-next
|
||||
slot="end"
|
||||
.path=${mdiChevronRight}
|
||||
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.config.zha.groups.open_device"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
></ha-icon-button-next>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,4 +1,4 @@
|
||||
import { mdiChevronLeft, mdiClose } from "@mdi/js";
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -64,6 +64,7 @@ import "./zwave-js-add-node-loading";
|
||||
import "./zwave-js-add-node-searching-devices";
|
||||
import "./zwave-js-add-node-select-method";
|
||||
import "./zwave-js-add-node-select-security-strategy";
|
||||
import { mainWindow } from "../../../../../../common/dom/get_main_window";
|
||||
|
||||
const INCLUSION_TIMEOUT_MINUTES = 5;
|
||||
|
||||
@@ -184,7 +185,8 @@ class DialogZWaveJSAddNode extends LitElement {
|
||||
(this._step && backButtonStages.includes(this._step)) ||
|
||||
(this._step === "search_devices" && this._supportsSmartStart)
|
||||
) {
|
||||
icon = mdiChevronLeft;
|
||||
icon =
|
||||
mainWindow.document.dir === "rtl" ? mdiChevronRight : mdiChevronLeft;
|
||||
}
|
||||
|
||||
let titleTranslationKey = "title";
|
||||
|
||||
@@ -103,6 +103,7 @@ class HaConfigSectionNetwork extends LitElement {
|
||||
max-width: 600px;
|
||||
}
|
||||
.discovery-card ha-md-list {
|
||||
background: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
mdiChevronDoubleLeft,
|
||||
mdiChevronDoubleRight,
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiInformationOutline,
|
||||
} from "@mdi/js";
|
||||
import { mdiInformationOutline } from "@mdi/js";
|
||||
import type { HassEvent } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -15,6 +9,10 @@ import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-icon-button-next";
|
||||
import "../../../../components/ha-icon-button-prev";
|
||||
import "../../../../components/ha-icon-button-last";
|
||||
import "../../../../components/ha-icon-button-first";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
@@ -169,22 +167,20 @@ class EventSubscribeCard extends LitElement {
|
||||
return html`
|
||||
<ha-card class="events-card">
|
||||
<div class="events-toolbar">
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronDoubleLeft}
|
||||
<ha-icon-button-first
|
||||
.disabled=${index >= bufferTotal - 1}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.tools.tabs.events.oldest_event"
|
||||
)}
|
||||
@click=${this._showOldest}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronLeft}
|
||||
></ha-icon-button-first>
|
||||
<ha-icon-button-prev
|
||||
.disabled=${index >= bufferTotal - 1}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.tools.tabs.events.older_event"
|
||||
)}
|
||||
@click=${this._showOlder}
|
||||
></ha-icon-button>
|
||||
></ha-icon-button-prev>
|
||||
<div class="event-info">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.tools.tabs.events.event_fired",
|
||||
@@ -218,22 +214,20 @@ class EventSubscribeCard extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronRight}
|
||||
<ha-icon-button-next
|
||||
.disabled=${atNewest}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.tools.tabs.events.newer_event"
|
||||
)}
|
||||
@click=${this._showNewer}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronDoubleRight}
|
||||
></ha-icon-button-next>
|
||||
<ha-icon-button-last
|
||||
.disabled=${atNewest}
|
||||
.label=${this.hass!.localize(
|
||||
"ui.panel.config.tools.tabs.events.newest_event"
|
||||
)}
|
||||
@click=${this._showNewest}
|
||||
></ha-icon-button>
|
||||
></ha-icon-button-last>
|
||||
</div>
|
||||
<ha-yaml-editor
|
||||
.value=${event.event}
|
||||
|
||||
@@ -86,6 +86,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
const entities = this._filterEntities(
|
||||
this.params.exposedEntities,
|
||||
this._registries,
|
||||
this._filter
|
||||
);
|
||||
|
||||
@@ -180,6 +181,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
private _filterEntities = memoizeOne(
|
||||
(
|
||||
exposedEntities: Record<string, ExposeEntitySettings>,
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
filter?: string
|
||||
): FilteredEntity[] => {
|
||||
const lowerFilter = filter?.toLowerCase();
|
||||
@@ -196,11 +198,16 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
const nameList = computeEntityNameList(
|
||||
entity,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._registries.entities,
|
||||
this._registries.devices,
|
||||
this._registries.areas,
|
||||
this._registries.floors
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
registries.areas,
|
||||
registries.floors
|
||||
);
|
||||
|
||||
if (!lowerFilter) {
|
||||
@@ -219,13 +226,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, deviceName, areaName] = nameList;
|
||||
const [, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
if (deviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parentDeviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (areaName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
@@ -238,14 +250,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
private _renderItem = (item: FilteredEntity) => {
|
||||
const { entity: entityState, nameList } = item;
|
||||
const [entityName, deviceName, areaName] = nameList;
|
||||
const [entityName, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
const primary = entityName || deviceName || entityState.entity_id;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const showEntityId = this._config?.userData?.showEntityIdPicker;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,8 +41,8 @@ export const isRunRow = (item: LogbookEntry): boolean =>
|
||||
classifyLogbookEntry(item) === "automation";
|
||||
|
||||
// How much naming detail an entity row shows, from least to most. The value is
|
||||
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
|
||||
// entity), `area` (area ▸ device ▸ entity).
|
||||
// the broadest part shown: `none` (name hidden), `entity`, `device` (parent
|
||||
// device ▸ device ▸ entity), `area` (area ▸ parent device ▸ device ▸ entity).
|
||||
export type LogbookNameDetail = "none" | "entity" | "device" | "area";
|
||||
|
||||
export interface EntityDisplay {
|
||||
@@ -60,14 +60,20 @@ export const entityDisplay = (
|
||||
return {};
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
|
||||
@@ -82,11 +88,11 @@ export const entityDisplay = (
|
||||
parts = [];
|
||||
break;
|
||||
case "device":
|
||||
parts = [deviceQualifier];
|
||||
parts = [parentDeviceName, deviceQualifier];
|
||||
break;
|
||||
case "area":
|
||||
default:
|
||||
parts = [areaName, deviceQualifier];
|
||||
parts = [areaName, parentDeviceName, deviceQualifier];
|
||||
}
|
||||
|
||||
const filtered = parts.filter(Boolean) as string[];
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -190,6 +190,10 @@ export class HuiCalendarCard
|
||||
}
|
||||
|
||||
const loading = !this._entityRegistry || !this._eventsLoaded;
|
||||
const rows = this._config.grid_options
|
||||
? this._config.grid_options.rows
|
||||
: this._config.layout_options?.grid_rows;
|
||||
const autoHeight = this.layout === "grid" && rows === "auto";
|
||||
|
||||
const views: FullCalendarView[] = [
|
||||
"dayGridMonth",
|
||||
@@ -206,11 +210,12 @@ export class HuiCalendarCard
|
||||
}
|
||||
<ha-full-calendar
|
||||
class=${classMap({
|
||||
"is-grid": this.layout === "grid",
|
||||
"is-grid": this.layout === "grid" && !autoHeight,
|
||||
"is-panel": this.layout === "panel",
|
||||
"has-title": !!this._config.title,
|
||||
loading: loading,
|
||||
})}
|
||||
?auto-height=${autoHeight}
|
||||
?add-fab=${this._config.show_add_event}
|
||||
add-fab-style=${this._config.show_add_event ? (this._config.add_event_style ?? "below") : nothing}
|
||||
add-fab-size=${this._config.show_add_event && this._config.add_event_style !== "header" ? (this._config.add_event_size ?? "small") : nothing}
|
||||
@@ -403,6 +408,11 @@ export class HuiCalendarCard
|
||||
min-height: var(--calendar-height);
|
||||
}
|
||||
|
||||
ha-full-calendar[auto-height] {
|
||||
--calendar-height: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
ha-full-calendar.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,8 +63,8 @@ export class HuiEntityEditor extends LitElement {
|
||||
this.hass.formatEntityName(
|
||||
stateObj,
|
||||
useDeviceName
|
||||
? [{ type: "area" }]
|
||||
: [{ type: "area" }, { type: "device" }],
|
||||
? [{ type: "area" }, { type: "parent_device" }]
|
||||
: [{ type: "area" }, { type: "parent_device" }, { type: "device" }],
|
||||
{
|
||||
separator: isRTL ? " ◂ " : " ▸ ",
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user