Compare commits

..
Author SHA1 Message Date
Paul Bottein 6ccc78114c Add offset selector 2026-09-10 13:46:10 +02:00
114 changed files with 1828 additions and 4121 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
- name: Setup Node and install
uses: ./.github/actions/setup
@@ -12,29 +12,19 @@ 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) => {
-6
View File
@@ -358,12 +358,6 @@ 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;
}
-2
View File
@@ -10,7 +10,6 @@ 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";
@@ -634,7 +633,6 @@ class HaGallery extends LitElement {
floors: {},
hassUrl: (path) => path,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
language: "en",
loadBackendTranslation: async () => galleryLocalize,
loadFragmentTranslation: async () => undefined,
@@ -57,7 +57,6 @@ 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. |
+2 -257
View File
@@ -21,8 +21,6 @@ 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,
@@ -295,72 +293,9 @@ 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;
context?: Record<string, unknown>;
})
| null
>;
input: Record<string, (BlueprintInput & { required?: boolean }) | null>;
}[] = [
{
name: "One of each",
@@ -384,14 +319,8 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
offset: { name: "Offset", selector: { offset: { enable_day: true } } },
app: { name: "App", selector: { app: {} } },
serial_port: {
name: "Serial port",
selector: { serial_port: {} },
// A config flow passes its own domain as context, which is what the
// picker groups recommended and not recommended ports by
context: { domain: "zha" },
},
number_box: {
name: "Number Box",
selector: {
@@ -576,99 +505,6 @@ 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: {} },
},
},
},
{
@@ -770,98 +606,8 @@ 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) {
@@ -1031,7 +777,6 @@ 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}
+3 -4
View File
@@ -21,8 +21,7 @@ const ENTITIES = [
state: "on",
attributes: {
friendly_name: "Dining Room",
supported_color_modes: ["brightness"],
color_mode: "brightness",
supported_features: 1,
brightness: 100,
},
},
@@ -31,7 +30,7 @@ const ENTITIES = [
state: "off",
attributes: {
friendly_name: "Dining Room",
supported_color_modes: ["brightness"],
supported_features: 1,
},
},
{
@@ -39,7 +38,7 @@ const ENTITIES = [
state: "unavailable",
attributes: {
friendly_name: "Lost Light",
supported_color_modes: ["brightness"],
supported_features: 1,
},
},
];
@@ -11,7 +11,6 @@ import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
const ALL_FEATURES =
LawnMowerEntityFeature.START_MOWING +
LawnMowerEntityFeature.PAUSE +
LawnMowerEntityFeature.STOP +
LawnMowerEntityFeature.DOCK;
const ENTITIES = [
@@ -50,14 +49,6 @@ 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",
+11 -10
View File
@@ -39,7 +39,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.5",
"@babel/runtime": "8.0.0",
"@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.1",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -84,6 +84,7 @@
"@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",
@@ -114,7 +115,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.12",
"marked": "18.0.11",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -138,10 +139,10 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.5",
"@babel/core": "8.0.1",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.5",
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
@@ -152,7 +153,7 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.2.3",
"@rspack/core": "2.2.2",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -194,10 +195,10 @@
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"jszip": "3.10.2",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.5.1",
"lint-staged": "17.5.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -212,8 +213,8 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.70.0",
"vite": "8.3.0",
"typescript-eslint": "8.69.0",
"vite": "8.2.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.0",
"webpack-stats-plugin": "1.1.3",
+21 -19
View File
@@ -9,34 +9,36 @@ export const createDurationData = (
}
if (typeof duration !== "object") {
if (typeof duration === "string" || isNaN(duration)) {
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)
);
const parts = duration?.toString().split(":") || [];
if (parts.length === 1) {
return { seconds: parts[0] };
return { seconds: Number(parts[0]) };
}
if (parts.length > 3) {
return undefined;
}
const seconds = parts[2] || 0;
const secondsWhole = Math.trunc(seconds);
const seconds = Number(parts[2]) || 0;
const seconds_whole = Math.floor(seconds);
return {
hours: parts[0] || 0,
minutes: parts[1] || 0,
seconds: secondsWhole,
milliseconds: Math.trunc(
Number((seconds - secondsWhole).toFixed(4)) * 1000
hours: Number(parts[0]) || 0,
minutes: Number(parts[1]) || 0,
seconds: seconds_whole,
milliseconds: Math.floor(
Number((seconds - seconds_whole).toFixed(4)) * 1000
),
};
}
return { seconds: duration };
}
return 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,
};
};
@@ -0,0 +1,31 @@
import type { HaDurationData } from "../../components/ha-duration-input";
export const durationValueToData = (
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;
};
+1 -1
View File
@@ -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", "idle"],
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
light: ["on", "off"],
lock: [
"jammed",
+1 -1
View File
@@ -36,7 +36,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
case "person":
return compareState !== "not_home";
case "lawn_mower":
return !["docked", "paused", "idle"].includes(compareState);
return !["docked", "paused"].includes(compareState);
case "lock":
return compareState !== "locked";
case "media_player":
@@ -1,48 +0,0 @@
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,8 +44,7 @@ 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 and
// history-graph card.
// left-hand category-label column. Opt-in; used by the history panel.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@@ -80,9 +79,7 @@ export class StateHistoryChartTimeline extends LitElement {
.options=${this._chartOptions}
.height=${`${
this.data.length *
(this.insideLabels && (this.chunked || this.showNames)
? ROW_HEIGHT_INSIDE_LABELS
: ROW_HEIGHT) +
(this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) +
GRID_BOTTOM
}px`}
.data=${this._chartData as HaECSeries}
+1 -1
View File
@@ -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 and history-graph card.
// left-hand column. Opt-in; used by the history panel.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
-8
View File
@@ -35,14 +35,6 @@ 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
+32 -50
View File
@@ -103,87 +103,69 @@ 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;
}
@@ -254,8 +236,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]);
@@ -42,6 +42,7 @@ const SELECTOR_FALLBACK_VALUES = {
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
offset: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
@@ -74,6 +74,7 @@ const SELECTOR_INITIAL_VALUES = {
};
},
object: (selector) => (selector.object?.multiple ? [] : ""),
offset: () => ({ type: "none" }),
period: undefined,
qr_code: undefined,
select: (selector) => {
-1
View File
@@ -57,7 +57,6 @@ 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")
-16
View File
@@ -1,16 +0,0 @@
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;
}
}
-16
View File
@@ -1,16 +0,0 @@
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;
}
}
-52
View File
@@ -1,52 +0,0 @@
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;
}
}
-52
View File
@@ -1,52 +0,0 @@
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,11 +28,6 @@ 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",
+3 -10
View File
@@ -65,9 +65,7 @@ class HaMenuButton extends LitElement {
}
const hasNotifications =
this._hasNotifications &&
(this._narrow ||
this._ui.dockedSidebar === "always_hidden" ||
this._ui.kioskElementsHidden.has("sidebar"));
(this._narrow || this._ui.dockedSidebar === "always_hidden");
return html`
<ha-icon-button
.label=${this._localize("ui.sidebar.sidebar_toggle")}
@@ -94,14 +92,9 @@ 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 &&
!this._ui.kioskElementsHidden.has("sidebar_button") &&
(this._narrow ||
this._ui.dockedSidebar === "always_hidden" ||
this._ui.kioskElementsHidden.has("sidebar"));
this._ui?.kioskMode === false &&
(this._narrow || this._ui.dockedSidebar === "always_hidden");
this._show = showButton || this._alwaysVisible;
@@ -45,11 +45,8 @@ export class HaChooseSelector extends LitElement {
}
if (
changedProperties.has("value") &&
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
changedProperties.get("value")?.active_choice &&
changedProperties.get("value")?.active_choice !== this._activeChoice
) {
this._setActiveChoice();
}
@@ -153,14 +150,14 @@ export class HaChooseSelector extends LitElement {
if (this.value === null || this.value === undefined) {
return undefined;
}
return typeof this.value === "object" && "active_choice" in this.value
return typeof this.value === "object"
? this.value[choice || this.value.active_choice]
: this.value;
}
private _setActiveChoice() {
if (this.value) {
if (typeof this.value === "object" && "active_choice" in this.value) {
if (typeof this.value === "object") {
if (this.value.active_choice in this.selector.choose.choices) {
this._activeChoice = this.value.active_choice;
return;
@@ -202,22 +199,6 @@ export class HaChooseSelector extends LitElement {
];
return;
}
if (
typeofValue === "object" &&
!Array.isArray(this.value) &&
Object.keys(this.value).length > 0 &&
Object.keys(this.value).every((key) =>
["days", "hours", "minutes", "seconds", "milliseconds"].includes(
key
)
) &&
selectorTypes.includes("duration")
) {
this._activeChoice = Object.keys(this.selector.choose.choices)[
selectorTypes.indexOf("duration")
];
return;
}
}
}
this._activeChoice = Object.keys(this.selector.choose.choices)[0];
@@ -1,10 +1,10 @@
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
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,10 +27,7 @@ export class HaTimeDuration extends LitElement {
return this._input?.reportValidity() ?? true;
}
private _data = memoizeOne(
(value?: HaDurationData | string | number): HaDurationData | undefined =>
createDurationData(value)
);
private _data = memoizeOne(durationValueToData);
protected render() {
return html`
@@ -0,0 +1,182 @@
import {
mdiClockMinusOutline,
mdiClockOutline,
mdiClockPlusOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import type {
OffsetSelector,
OffsetSelectorValue,
OffsetType,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
import "../ha-input-helper-text";
import "../ha-select";
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
{ value: "none", iconPath: mdiClockOutline },
{ value: "before", iconPath: mdiClockMinusOutline },
{ value: "after", iconPath: mdiClockPlusOutline },
];
const DEFAULT_DURATION: HaDurationData = { hours: 0, minutes: 0, seconds: 0 };
const isOffsetValue = (value: unknown): value is OffsetSelectorValue =>
typeof value === "object" && value !== null && "type" in value;
@customElement("ha-selector-offset")
export class HaOffsetSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: OffsetSelector;
@property({ attribute: false }) public value?: OffsetSelectorValue;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@query("ha-duration-input", true) private _durationInput?: HaDurationInput;
public reportValidity(): boolean {
return this._durationInput?.reportValidity() ?? true;
}
private get _value(): OffsetSelectorValue | undefined {
return isOffsetValue(this.value) ? this.value : undefined;
}
private _duration = memoizeOne(durationValueToData);
private _typeOptions = memoizeOne((localize: LocalizeFunc) =>
OFFSET_TYPES.map(({ value, iconPath }) => ({
value,
iconPath,
label: localize(`ui.components.selectors.offset.${value}`),
}))
);
protected render() {
const type = this._value?.type ?? "none";
return html`
<div class="container">
${
this.label
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
: nothing
}
<div class="inputs">
<ha-select
.value=${type}
.options=${this._typeOptions(this.hass.localize)}
.disabled=${this.disabled}
@selected=${this._typeChanged}
></ha-select>
${
type !== "none"
? html`<div class="value-row">
<span class="value-label"
>${this.hass.localize(
"ui.components.selectors.offset.duration"
)}${this.required ? "*" : ""}</span
>
<ha-duration-input
.data=${this._duration(this._value?.duration)}
.disabled=${this.disabled}
.required=${this.required}
.enableDay=${this.selector.offset?.enable_day}
.enableMillisecond=${
this.selector.offset?.enable_millisecond
}
@value-changed=${this._durationChanged}
></ha-duration-input>
</div>`
: nothing
}
</div>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
</div>
`;
}
private _durationChanged(ev: CustomEvent<{ value?: HaDurationData }>) {
ev.stopPropagation();
const type = this._value?.type;
if (!type || type === "none") {
return;
}
fireEvent(this, "value-changed", {
value: { type, duration: ev.detail.value ?? DEFAULT_DURATION },
});
}
private _typeChanged(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const type = ev.detail.value as OffsetType | undefined;
if (!type || type === this._value?.type) {
return;
}
fireEvent(this, "value-changed", {
value:
type === "none"
? { type }
: {
type,
duration:
this._duration(this._value?.duration) ?? DEFAULT_DURATION,
},
});
}
static styles = css`
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-1);
}
.inputs,
.value-row {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.value-label {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
ha-select {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-offset": HaOffsetSelector;
}
}
@@ -43,6 +43,7 @@ const LOAD_ELEMENTS = {
number: () => import("./ha-selector-number"),
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
object: () => import("./ha-selector-object"),
offset: () => import("./ha-selector-offset"),
period: () => import("./ha-selector-period"),
qr_code: () => import("./ha-selector-qr-code"),
select: () => import("./ha-selector-select"),
-48
View File
@@ -77,7 +77,6 @@ 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) {
@@ -125,10 +124,6 @@ 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;
@@ -165,39 +160,6 @@ 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> {
@@ -213,7 +175,6 @@ export class HaInput extends WaInputMixin(LitElement) {
public override disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("focusin", this._syncFromNativeInput);
this._startSlotResizeObserver?.disconnect();
}
@@ -254,7 +215,6 @@ 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,
@@ -386,14 +346,6 @@ 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`
-4
View File
@@ -1327,9 +1327,6 @@ 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;
@@ -1380,7 +1377,6 @@ 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 {
@@ -1,181 +0,0 @@
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,5 +1,7 @@
import { consume } from "@lit/context";
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiDevices,
mdiHome,
@@ -27,10 +29,7 @@ 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 { getDeviceArea } 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";
@@ -60,12 +59,10 @@ 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")
@@ -316,7 +313,19 @@ export class HaTargetPickerItemRow extends LitElement {
></ha-icon-button>
`
: this.subEntry && this.type === "entity"
? html` <ha-icon-next slot="end"></ha-icon-next> `
? html`
<ha-svg-icon
.path=${
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? mdiChevronLeft
: mdiChevronRight
}
slot="end"
></ha-svg-icon>
`
: nothing
}
`;
@@ -392,25 +401,125 @@ export class HaTargetPickerItemRow extends LitElement {
return this._renderEmptyEntries();
}
const {
nextType,
rows,
rowEntries,
deviceRows,
deviceRowEntries,
entityRows,
} = computeTargetSubRows(
this.type,
this.itemId,
entries,
this.hass.entities,
this.hass.devices
);
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 nextSubLevel = this.subLevel + 1;
return html`
${rows.map(
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
@@ -419,7 +528,7 @@ export class HaTargetPickerItemRow extends LitElement {
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rowEntries?.[index]}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
@@ -434,7 +543,7 @@ export class HaTargetPickerItemRow extends LitElement {
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowEntries?.[index]}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
@@ -515,53 +624,32 @@ 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) => {
// 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]) {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (matchingDeviceIds.has(device_id)) {
return true;
}
if (parentsOfMatching.has(device_id)) {
groupOnlyDeviceIds.add(device_id);
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
});
@@ -575,10 +663,7 @@ export class HaTargetPickerItemRow extends LitElement {
if (!entity) {
return false;
}
if (
hiddenDeviceIds.includes(entity.device_id || "") ||
groupOnlyDeviceIds.has(entity.device_id || "")
) {
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
if (
-2
View File
@@ -115,7 +115,6 @@ 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
@@ -127,7 +126,6 @@ const updateUi = (
panelUrl: hass.panelUrl,
dockedSidebar: hass.dockedSidebar,
kioskMode: hass.kioskMode,
kioskElementsHidden: hass.kioskElementsHidden,
enableShortcuts: hass.enableShortcuts,
vibrate: hass.vibrate,
suspendWhenHidden: hass.suspendWhenHidden,
+1 -4
View File
@@ -1,12 +1,9 @@
import type { EntityNameType } from "../common/entity/compute_entity_name_display";
export type EntityIdPart = EntityNameType;
export type EntityIdPart = "area" | "device" | "entity" | "floor";
export type EntityIdFormat = EntityIdPart[];
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
"area",
"parent_device",
"device",
"entity",
];
-132
View File
@@ -1,132 +0,0 @@
/**
* 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]));
};
+1 -9
View File
@@ -5,13 +5,12 @@ import type {
import { UNAVAILABLE } from "./entity/entity";
export type LawnMowerEntityState =
"paused" | "mowing" | "returning" | "docked" | "idle" | "error";
"paused" | "mowing" | "returning" | "docked" | "error";
export enum LawnMowerEntityFeature {
START_MOWING = 1,
PAUSE = 2,
DOCK = 4,
STOP = 8,
}
interface LawnMowerEntityAttributes
@@ -39,13 +38,6 @@ 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;
-7
View File
@@ -69,13 +69,6 @@ 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,
+1 -8
View File
@@ -3,17 +3,10 @@ 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;
source: ModbusConnectionSource;
/**
* The unit IDs each holder uses, keyed by config entry ID, or by hub name
* when `source` is `"yaml"`.
*/
/** The unit IDs each config entry holds, keyed by config entry ID. */
units: Record<string, number[]>;
}
+16
View File
@@ -22,6 +22,7 @@ import type {
EntityRegistryEntry,
} from "./entity/entity_registry";
import type { EntitySources } from "./entity/entity_sources";
import type { HaDurationData } from "../components/ha-duration-input";
export type ThresholdMode = "crossed" | "changed" | "is";
@@ -63,6 +64,7 @@ export type Selector =
| NumberSelector
| NumericThresholdSelector
| ObjectSelector
| OffsetSelector
| PeriodSelector
| AssistPipelineSelector
| QRCodeSelector
@@ -446,6 +448,20 @@ export interface ObjectSelector {
} | null;
}
export type OffsetType = "none" | "before" | "after";
export interface OffsetSelector {
offset: {
enable_day?: boolean;
enable_millisecond?: boolean;
} | null;
}
export interface OffsetSelectorValue {
type: OffsetType;
duration?: HaDurationData | string | number;
}
export type PeriodKey =
| "today"
| "yesterday"
+18 -8
View File
@@ -1,9 +1,11 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { formatDurationLong } from "../../common/datetime/format_duration";
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";
import type { OffsetSelectorValue, Selector } from "../selector";
export const formatSelectorValue = (
hass: HomeAssistant,
@@ -102,13 +104,6 @@ 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);
@@ -130,6 +125,21 @@ export const formatSelectorValue = (
.join(", ");
}
if ("offset" in selector) {
const { type, duration } = value as OffsetSelectorValue;
const durationData = durationValueToData(duration);
if (type === "none" || !durationData) {
return "";
}
const formattedDuration = formatDurationLong(hass.locale, durationData);
if (!formattedDuration) {
return "";
}
return hass.localize(`ui.components.selectors.offset.summary.${type}`, {
duration: formattedDuration,
});
}
return ensureArray(value)
.map((v) =>
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
@@ -1,14 +1,16 @@
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,
@@ -156,6 +158,10 @@ 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
@@ -227,7 +233,12 @@ export class DialogHttpPendingConfig
<span class="old"
>${this._formatValue(key, stable[key])}</span
>
<ha-icon-arrow-next></ha-icon-arrow-next>
<ha-svg-icon
.path=${mdiArrowRight}
style=${styleMap({
transform: rtl ? "scaleX(-1)" : "",
})}
></ha-svg-icon>
<span class="new"
>${this._formatValue(key, pending![key])}</span
>
@@ -392,7 +403,7 @@ export class DialogHttpPendingConfig
.values .new {
color: var(--primary-text-color);
}
.values ha-icon-arrow-next {
.values ha-svg-icon {
--mdc-icon-size: 18px;
flex-shrink: 0;
}
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { mdiHomeImportOutline, mdiPause, mdiPlay, mdiStop } from "@mdi/js";
import { mdiHomeImportOutline, mdiPause, mdiPlay } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -29,7 +29,6 @@ import {
LawnMowerEntityFeature,
canDock,
canStartMowing,
canStop,
isMowing,
} from "../../../data/lawn_mower";
import "../../../state-control/lawn_mower/ha-state-control-lawn_mower-status";
@@ -174,14 +173,6 @@ 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");
@@ -197,11 +188,9 @@ 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 || supportsStop || supportsDock;
const hasAnyCommand = this._supportsStartPause || supportsDock;
return html`
<ha-more-info-state-header .stateObj=${this.stateObj}>
@@ -233,21 +222,6 @@ 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,5 +1,6 @@
import { consume } from "@lit/context";
import {
mdiChevronRight,
mdiFan,
mdiHomeImportOutline,
mdiMapMarker,
@@ -24,7 +25,6 @@ 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-icon-next></ha-icon-next>
<ha-svg-icon .path=${mdiChevronRight}></ha-svg-icon>
</div>
</button>
`
@@ -1,4 +1,4 @@
import { mdiMenuDown } from "@mdi/js";
import { mdiChevronLeft, 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,7 +9,6 @@ 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";
@@ -153,11 +152,12 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
hideNavigationIcon
? html`<span slot="headerNavigationIcon"></span>`
: this._previousSteps.length
? html`<ha-icon-button-prev
? html`<ha-icon-button
slot="headerNavigationIcon"
.label=${this.hass.localize("ui.common.back") ?? "Back"}
.path=${mdiChevronLeft}
@click=${this._goToPreviousStep}
></ha-icon-button-prev>`
></ha-icon-button>`
: nothing
}
${
+1 -21
View File
@@ -98,27 +98,7 @@ export const handleExternalMessage = (
} else if (msg.command === "bar_code/aborted") {
barCodeListeners.forEach((listener) => listener(msg));
} else if (msg.command === "kiosk_mode/set") {
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,
});
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
} else {
return false;
}
-21
View File
@@ -325,29 +325,8 @@ 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,11 +24,6 @@ export class MockLawnMowerEntity extends MockBaseEntity {
return;
}
if (service === "stop") {
this.update({ state: "idle" });
return;
}
if (service === "dock") {
this._transition("returning", "docked", TRANSITION_MS);
return;
-2
View File
@@ -24,7 +24,6 @@ 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,
@@ -465,7 +464,6 @@ 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) {
+1 -6
View File
@@ -60,9 +60,7 @@ export class HaInitPage extends LitElement {
: nothing
}
`
: html`<p
class=${classMap({ "loading-text": !this.migration, rtl: document.dir === "rtl" })}
>
: html`<p class=${classMap({ "loading-text": !this.migration })}>
${
this.migration
? html`<span class="migration-text"
@@ -126,9 +124,6 @@ export class HaInitPage extends LitElement {
.loading-text {
opacity: 0.66;
}
.rtl {
direction: rtl;
}
`;
}
+3 -7
View File
@@ -51,9 +51,7 @@ export class HomeAssistantMain extends LitElement {
protected render(): TemplateResult {
const sidebarNarrow =
this._sidebarNarrow ||
this._externalSidebar ||
this.hass.kioskElementsHidden.has("sidebar");
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode;
const isPanelReady =
this.hass.panels && this.hass.userData && this.hass.systemData;
@@ -107,7 +105,7 @@ export class HomeAssistantMain extends LitElement {
});
return;
}
if (this._sidebarNarrow || this.hass.kioskElementsHidden.has("sidebar")) {
if (this._sidebarNarrow || this.hass.kioskMode) {
this._drawerOpen = ev.detail?.open ?? !this._drawerOpen;
} else {
fireEvent(this, "hass-dock-sidebar", {
@@ -146,9 +144,7 @@ export class HomeAssistantMain extends LitElement {
this.toggleAttribute(
"modal",
this._sidebarNarrow ||
this._externalSidebar ||
this.hass.kioskElementsHidden.has("sidebar")
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode
);
}
+4 -17
View File
@@ -17,7 +17,6 @@ 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,
@@ -25,7 +24,6 @@ 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";
@@ -42,6 +40,7 @@ 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";
@@ -166,7 +165,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
}
if (this._init) {
return html`<onboarding-welcome></onboarding-welcome>`;
return html`<onboarding-welcome
.localize=${this.localize}
></onboarding-welcome>`;
}
const step = this._curStep()!;
@@ -229,20 +230,6 @@ 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")) {
+12 -12
View File
@@ -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, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { customElement, property } from "lit/decorators";
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 {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public 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>
+20 -44
View File
@@ -49,6 +49,8 @@ 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
@@ -81,6 +83,11 @@ 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() {
@@ -103,10 +110,7 @@ class HaPanelApp extends LitElement {
this._fetchDataTimeout = undefined;
}
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", {
enable: false,
source: "app_panel",
});
fireEvent(window, "hass-kiosk-mode", { enable: false });
}
}
@@ -117,35 +121,18 @@ 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`
${
!hideHeader &&
(this.narrow ||
this.hass.dockedSidebar === "always_hidden" ||
sidebarHidden)
!this._kioskMode &&
(this.narrow || this.hass.dockedSidebar === "always_hidden")
? html`
<div class="header">
${
hideMenuButton
? nothing
: html`
<ha-icon-button
.label=${this.hass.localize(
"ui.sidebar.sidebar_toggle"
)}
.path=${mdiMenu}
@click=${this._toggleMenu}
></ha-icon-button>
`
}
<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>
`
@@ -154,7 +141,7 @@ class HaPanelApp extends LitElement {
<iframe
class=${classMap({
loaded: this._iframeLoaded,
"kiosk-mode": hideHeader,
"kiosk-mode": this._kioskMode,
"handle-safe-area": this._handleSafeArea,
})}
title=${this._addon.name}
@@ -196,10 +183,7 @@ class HaPanelApp extends LitElement {
this._iframeLoaded = false;
// Reset state when switching apps
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", {
enable: false,
source: "app_panel",
});
fireEvent(window, "hass-kiosk-mode", { enable: false });
this._enabledKioskMode = false;
}
this._iframeSubscribeUpdates = false;
@@ -445,14 +429,9 @@ class HaPanelApp extends LitElement {
// safe area itself; we then forward the inset values below.
this._handleSafeArea = !!data.handleSafeArea;
this._sendPropertiesToIframe();
// 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) {
if (data.kioskMode && !this.hass.kioskMode) {
this._enabledKioskMode = true;
fireEvent(window, "hass-kiosk-mode", {
enable: true,
source: "app_panel",
});
fireEvent(window, "hass-kiosk-mode", { enable: true });
}
break;
@@ -460,10 +439,7 @@ class HaPanelApp extends LitElement {
this._iframeSubscribeUpdates = false;
this._handleSafeArea = false;
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", {
enable: false,
source: "app_panel",
});
fireEvent(window, "hass-kiosk-mode", { enable: false });
this._enabledKioskMode = false;
}
break;
-12
View File
@@ -82,9 +82,6 @@ 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[] = [];
@@ -317,10 +314,6 @@ 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) {
@@ -352,7 +345,6 @@ 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",
@@ -372,10 +364,6 @@ 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,6 +175,8 @@ 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,6 +1,7 @@
import { startOfYesterday } from "date-fns";
import { consume } from "@lit/context";
import {
mdiChevronRight,
mdiDelete,
mdiDevices,
mdiDotsVertical,
@@ -38,7 +39,6 @@ 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,11 +604,12 @@ class HaConfigAreaPage extends LitElement {
back: "1",
})}"
>
<ha-icon-button-next
<ha-icon-button
.path=${mdiChevronRight}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
></ha-icon-button-next>
></ha-icon-button>
</a>
</div>
<ha-logbook
@@ -20,7 +20,6 @@ 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";
@@ -31,7 +30,10 @@ 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 { getAreaEntityLookup } from "../../../../data/area/area_registry";
import {
getAreaDeviceLookup,
getAreaEntityLookup,
} from "../../../../data/area/area_registry";
import {
getAreasNestedInFloors,
type AreaFloorValue,
@@ -49,10 +51,7 @@ import {
registriesContext,
statesContext,
} from "../../../../data/context";
import {
getDeviceEntityLookup,
type DeviceRegistryEntry,
} from "../../../../data/device/device_registry";
import { getDeviceEntityLookup } from "../../../../data/device/device_registry";
import {
domainToName,
type DomainManifestLookup,
@@ -84,7 +83,6 @@ interface Level2Entries {
interface Level3Entries {
open: boolean;
entities: string[];
devices?: Record<string, Level3Entries>;
}
@customElement("ha-automation-add-from-target")
@@ -283,19 +281,23 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (valueId && valueType === "device") {
const entry = this._getDeviceEntry(entries, valueId);
if (!entry) {
return nothing;
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 numberOfDevices = Object.keys(entry.devices ?? {}).length;
return html`
${numberOfDevices ? this._renderDevices(entry.devices!) : nothing}
${
entry.entities.length
? this._renderEntities(entry.entities)
: 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;
}
if (valueType === "device" || valueType === "helper") {
@@ -678,8 +680,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
stringCompare(deviceNameA, deviceNameB, this.hass.locale.language)
)
.map(([deviceId, deviceName, domain]) => {
const { open, entities, devices: children } = devices[deviceId];
const numberOfChildren = Object.keys(children ?? {}).length;
const { open, entities } = devices[deviceId];
return this._renderItem(
deviceName || deviceId,
@@ -687,15 +688,10 @@ export default class HaAutomationAddFromTarget extends LitElement {
false,
this._getSelectedTargetId(this.value) ===
`device${TARGET_SEPARATOR}${deviceId}`,
!open && !!(entities.length || numberOfChildren),
!open && !!entities.length,
open,
domain ? this._renderDomainIcon(domain) : undefined,
open
? html`
${numberOfChildren ? this._renderDevices(children!) : nothing}
${this._renderEntities(entities)}
`
: undefined
open ? this._renderEntities(entities) : undefined
);
});
@@ -903,16 +899,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
// #region memoized data helpers
private _getAreaDeviceLookupMemoized = memoizeOne(
(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;
}
(devices: HomeAssistant["devices"]) =>
getAreaDeviceLookup(Object.values(devices))
);
private _getAreaEntityLookupMemoized = memoizeOne(
@@ -986,18 +974,32 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _loadUnassignedDevices() {
const unassignedDevices = Object.values(this._registries.devices).filter(
(device) =>
!device.disabled_by &&
!getDeviceAreaId(device, this._registries.devices)
(device) => !device.area_id
);
const devices = this._buildDeviceEntries(
unassignedDevices.filter((device) => device.entry_type !== "service")
);
const devices: Record<string, Level3Entries> = {};
const services = 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;
});
if (Object.keys(devices).length) {
this._entries = {
@@ -1077,21 +1079,31 @@ 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] || []
).filter((device) => !device.disabled_by);
const referenced_devices =
this._getAreaDeviceLookupMemoized(this._registries.devices)[id] || [];
const referenced_entities =
this._getAreaEntityLookupMemoized(this._registries.entities)[id] || [];
const devices = this._buildDeviceEntries(referenced_devices);
const areaDeviceIds = new Set(
referenced_devices.map((device) => device.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 entities: string[] = [];
referenced_entities.forEach((entity) => {
if (!entity.device_id || !areaDeviceIds.has(entity.device_id)) {
if (!entity.device_id || !devices[entity.device_id]) {
entities.push(entity.entity_id);
}
});
@@ -1103,160 +1115,6 @@ 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;
@@ -1264,26 +1122,39 @@ export default class HaAutomationAddFromTarget extends LitElement {
if (type === "entity") {
const deviceId = this._registries.entities[id]?.device_id;
if (deviceId && this._registries.devices[deviceId]) {
this._setDeviceOpen(deviceId, true, true);
return;
}
const device = deviceId ? this._registries.devices[deviceId] : undefined;
const deviceAreaId = (deviceId && device?.area_id) || undefined;
const entity = this._registries.entities[id];
if (!deviceAreaId) {
let floor: string;
let area: string;
const entity = this._registries.entities[id];
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];
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;
}
this._entries = {
...this._entries,
[floor]: {
...floorEntry,
...this._entries[floor],
open: true,
areas: {
...floorEntry.areas,
devices: {
...this._entries[floor].devices!,
[area]: {
...floorEntry.areas![area],
...this._entries[floor].devices![area],
open: true,
},
},
@@ -1292,23 +1163,26 @@ export default class HaAutomationAddFromTarget extends LitElement {
return;
}
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];
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[deviceAreaId]?.floor_id || ""}`;
const area = `area${TARGET_SEPARATOR}${deviceAreaId}`;
this._entries = {
...this._entries,
[group]: {
...groupEntry,
[floor]: {
...this._entries[floor],
open: true,
devices: {
...groupEntry.devices,
[domainGroup]: {
...groupEntry.devices![domainGroup],
areas: {
...this._entries[floor].areas!,
[area]: {
...this._entries[floor].areas![area],
open: true,
devices: {
...this._entries[floor].areas![area].devices,
[deviceId!]: {
...this._entries[floor].areas![area].devices![deviceId!],
open: true,
},
},
},
},
},
@@ -1317,7 +1191,38 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (type === "device") {
this._setDeviceOpen(id, undefined, true);
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,
},
},
},
};
return;
}
@@ -1443,7 +1348,51 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (type === "device" && id) {
this._setDeviceOpen(id, open, false);
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,
},
},
},
};
return;
}
@@ -8,7 +8,6 @@ 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";
@@ -83,21 +82,13 @@ 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;
// Copy, `createDurationData` returns the input object as-is for dict values.
const duration: HaDurationData = { ...createDurationData(trigger_offset)! };
const duration: HaDurationData = createDurationData(trigger_offset)!;
let offset_type = "after";
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);
}
if (
(typeof trigger_offset === "object" && duration!.hours! < 0) ||
(typeof trigger_offset === "string" && trigger_offset.startsWith("-"))
) {
duration.hours = Math.abs(duration.hours!);
offset_type = "before";
}
const data = {
@@ -122,12 +113,11 @@ 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}${h}:${m}:${s}`,
offset: `${offsetType}${duration.hours ?? 0}:${duration.minutes ?? 0}:${
duration.seconds ?? 0
}`,
};
delete newTrigger.offset_type;
fireEvent(this, "value-changed", { value: newTrigger });
@@ -2,7 +2,9 @@ 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";
@@ -196,6 +198,21 @@ 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"] {
@@ -223,6 +240,7 @@ 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,6 +15,7 @@ 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";
@@ -219,6 +220,21 @@ 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"] {
@@ -235,6 +251,7 @@ 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,7 +3,9 @@ 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 = [
@@ -35,6 +37,21 @@ 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"] {
@@ -50,6 +67,7 @@ export class HaTemplateTrigger extends LitElement {
protected render() {
const data = {
...this.trigger,
for: this._wrapForValue(this.trigger.for),
};
return html`
@@ -5,13 +5,10 @@ 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";
@@ -35,11 +32,6 @@ 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()
@@ -58,12 +50,10 @@ export class HaConfigEntityIdFormat extends LitElement {
@state() private _error?: string;
@state() private _previews?: string[];
@state() private _preview?: string;
@state() private _previewError = false;
@state() private _selectedExample = 0;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
@@ -89,49 +79,12 @@ export class HaConfigEntityIdFormat extends LitElement {
}
private _examples = memoizeOne(
(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"
),
},
},
]
(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"),
})
);
private _debouncedUpdatePreview = debounce(
@@ -141,16 +94,12 @@ 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 {
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;
})
);
const { slug } = await fetchSlug(this._api, fullName);
this._preview = slug;
this._previewError = false;
} catch (_err: any) {
this._previewError = true;
@@ -197,6 +146,7 @@ export class HaConfigEntityIdFormat extends LitElement {
)}
@value-changed=${this._formatChanged}
></ha-entity-id-format-editor>
${this._renderPreview()}
`
: nothing
}
@@ -219,82 +169,28 @@ export class HaConfigEntityIdFormat extends LitElement {
</ha-progress-button>
</div>
</ha-card>
${this._format ? this._renderExamples() : nothing}
`;
}
private _renderExamples() {
const examples = this._examples(this._localize);
const example = examples[this._selectedExample];
const parts = ENTITY_NAME_TYPES.filter((part) => example.parts[part]);
private _renderPreview() {
return html`
<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>
<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>
`;
}
private _selectExample(ev: Event) {
ev.preventDefault();
this._selectedExample = Number(
(ev.currentTarget as HTMLElement).dataset.index
);
}
private _formatChanged(ev: CustomEvent) {
this._format = ev.detail.value;
}
@@ -329,36 +225,24 @@ export class HaConfigEntityIdFormat extends LitElement {
haStyle,
css`
:host {
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
display: block;
}
.description {
margin-top: 0;
color: var(--secondary-text-color);
}
.examples {
.preview {
display: flex;
flex-direction: column;
gap: var(--ha-space-3);
gap: var(--ha-space-1);
margin-top: var(--ha-space-5);
}
.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-label {
font-weight: 500;
}
.examples dt,
.examples .unused {
color: var(--secondary-text-color);
}
.examples dd {
margin: 0;
}
.examples code {
.preview code {
display: block;
padding: var(--ha-space-2);
padding: var(--ha-space-3);
border-radius: var(--ha-border-radius-md);
background-color: var(--secondary-background-color);
font-family: var(--ha-font-family-code);
@@ -368,7 +252,6 @@ export class HaConfigEntityIdFormat extends LitElement {
display: flex;
justify-content: space-between;
align-items: center;
padding-block-end: var(--ha-space-4);
}
`,
];
@@ -6,7 +6,6 @@ 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";
@@ -23,6 +22,8 @@ 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`
@@ -38,7 +39,7 @@ const decodeType = (value: string): EntityIdPart | undefined => {
value.startsWith("___") && value.endsWith("___")
? value.slice(3, -3)
: value;
return (ENTITY_NAME_TYPES as readonly string[]).includes(type)
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
? (type as EntityIdPart)
: undefined;
};
@@ -64,7 +65,6 @@ 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[] =>
ENTITY_NAME_TYPES.map((type) => {
STRUCTURAL_TYPES.map((type) => {
const primary = localize(
`ui.components.entity.entity-name-picker.types.${type}`
);
@@ -3,6 +3,7 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiCog,
mdiChevronRight,
mdiDelete,
mdiDotsVertical,
mdiDownload,
@@ -47,7 +48,6 @@ 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,11 +959,12 @@ export class HaConfigDevicePage extends LitElement {
back: "1",
})}"
>
<ha-icon-button-next
<ha-icon-button
.path=${mdiChevronRight}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
></ha-icon-button-next>
></ha-icon-button>
</a>
</div>
<ha-logbook
@@ -3,6 +3,7 @@ import { consume } from "@lit/context";
import {
mdiAlertCircle,
mdiCancel,
mdiChevronRight,
mdiDelete,
mdiDotsVertical,
mdiEye,
@@ -68,7 +69,6 @@ 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,7 +906,10 @@ export class HaConfigEntities extends LitElement {
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
<ha-icon-next slot="end"></ha-icon-next>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
${this._renderLabelItems("submenu")}
</ha-dropdown-item>
<wa-divider></wa-divider>`
@@ -678,6 +678,7 @@ 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,6 +6,7 @@ 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";
@@ -1,251 +0,0 @@
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;
}
}
@@ -1,14 +1,19 @@
import { mdiFileCodeOutline, mdiPackageVariant, mdiWeb } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { PROTOCOL_INTEGRATIONS } from "../../../common/integrations/protocolIntegrationPicked";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-button";
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,
@@ -16,15 +21,13 @@ 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-card-footer";
import "./ha-integration-card-header";
import "./ha-integration-header";
@customElement("ha-integration-card")
export class HaIntegrationCard extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public domain!: string;
@@ -62,42 +65,183 @@ export class HaIntegrationCard extends LitElement {
class="ripple-anchor"
>
<ha-ripple></ha-ripple>
<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}`
<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"
)
: undefined
}
.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>
}
.manifest=${this.manifest}
>
</ha-integration-header>
</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) {
@@ -114,6 +258,60 @@ 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,
@@ -121,24 +319,16 @@ 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 {
display: flex;
flex-direction: column;
flex: 1;
flex-grow: 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;
@@ -148,11 +338,13 @@ export class HaIntegrationCard extends LitElement {
background-color: var(--secondary-text-color);
opacity: 0.08;
}
.card-content {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
flex: 1;
ha-integration-header {
height: 100%;
}
.card-actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
}
.debug-logging {
--state-color: var(--warning-color);
@@ -184,10 +376,40 @@ 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;
}
`,
];
}
@@ -1,20 +1,18 @@
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, 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 { customElement, property } from "lit/decorators";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import { configContext, uiContext } from "../../../data/context";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import type { HomeAssistantConfig, HomeAssistantUI } from "../../../types";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
@customElement("ha-integration-card-header")
export class HaIntegrationCardHeader extends LitElement {
@customElement("ha-integration-header")
export class HaIntegrationHeader extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public error?: string;
@property() public warning?: string;
@@ -25,28 +23,10 @@ export class HaIntegrationCardHeader 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._localize, this.domain, this.manifest);
domainToName(this.hass.localize, this.domain, this.manifest);
return html`
<div class="header">
@@ -56,9 +36,9 @@ export class HaIntegrationCardHeader extends LitElement {
{
domain: this.domain,
type: "icon",
darkOptimized: this._darkMode,
darkOptimized: this.hass.themes?.darkMode,
},
this._hassUrl
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
@@ -91,6 +71,12 @@ export class HaIntegrationCardHeader 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>
`;
}
@@ -104,31 +90,36 @@ export class HaIntegrationCardHeader 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: var(--ha-space-4);
margin-inline-end: 16px;
width: 40px;
height: 40px;
direction: var(--direction);
}
.info {
.header .info {
position: relative;
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;
@@ -146,10 +137,10 @@ export class HaIntegrationCardHeader 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;
@@ -159,7 +150,9 @@ export class HaIntegrationCardHeader extends LitElement {
white-space: nowrap;
}
.secondary > ha-svg-icon {
margin-inline-end: var(--ha-space-1);
margin-right: 4px;
margin-inline-end: 4px;
margin-inline-start: initial;
flex-shrink: 0;
}
.error ha-svg-icon {
@@ -173,6 +166,6 @@ export class HaIntegrationCardHeader extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-integration-card-header": HaIntegrationCardHeader;
"ha-integration-header": HaIntegrationHeader;
}
}
@@ -1,6 +1,5 @@
import {
mdiCableData,
mdiFileDocumentOutline,
mdiLan,
mdiPuzzle,
mdiRefresh,
@@ -23,10 +22,7 @@ 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,
ModbusConnectionSource,
} from "../../../../../data/modbus";
import type { ModbusConnection } from "../../../../../data/modbus";
import {
listModbusConnections,
modbusEndpointTarget,
@@ -47,8 +43,7 @@ const isKnownTransport = (transport: string): transport is ModbusTransport =>
(TRANSPORTS as readonly string[]).includes(transport);
interface ConnectionHolder {
/** A config entry ID, or a hub name on a YAML connection. */
id: string;
entryId: string;
entry?: ConfigEntry;
units: number[];
}
@@ -56,10 +51,9 @@ interface ConnectionHolder {
interface ConnectionListItem {
icon: string;
primary: string;
secondary: string;
transport: string;
state: string;
serialDevice?: string;
source: ModbusConnectionSource;
holders: ConnectionHolder[];
connection: ModbusConnection;
}
@@ -120,34 +114,22 @@ 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),
secondary: this._secondaryName(connection),
transport: this._transportName(transport),
state: this._stateName(connection.connected, serialDevice !== undefined),
serialDevice,
source: connection.source,
holders: Object.entries(connection.units).map(([id, units]) => ({
id,
entry: connection.source === "yaml" ? undefined : entries[id],
holders: Object.entries(connection.units).map(([entryId, units]) => ({
entryId,
entry: entries[entryId],
units,
})),
connection,
@@ -179,20 +161,13 @@ export class ModbusConfigDashboard extends LitElement {
`;
}
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
private _renderHolder(holder: ConnectionHolder): TemplateResult {
// 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=${source === "yaml" ? mdiFileDocumentOutline : mdiPuzzle}
></ha-svg-icon>
<div slot="headline">${holder.id}</div>
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
<div slot="headline">${holder.entryId}</div>
${this._renderUnits(holder)}
</ha-md-list-item>
`;
@@ -204,7 +179,7 @@ export class ModbusConfigDashboard extends LitElement {
<ha-md-list-item
type="link"
class="holder"
href=${`/config/integrations/integration/${domain}#config_entry=${holder.id}`}
href=${`/config/integrations/integration/${domain}#config_entry=${holder.entryId}`}
>
<img
slot="start"
@@ -259,10 +234,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.secondary}</div>
<div slot="supporting-text">${item.transport}</div>
${this._renderState(item)}
</ha-md-list-item>
${item.holders.map((holder) => this._renderHolder(holder, item.source))}
${item.holders.map((holder) => this._renderHolder(holder))}
${
item.serialDevice && isComponentLoaded(this.hass.config, "usb")
? this._renderSerialPortLink()
@@ -1,4 +1,5 @@
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";
@@ -8,7 +9,7 @@ import type {
HASSDomEvent,
} from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button-next";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
@@ -185,14 +186,15 @@ export class ZHADeviceEndpointList extends LitElement {
${
this.showDeviceLink
? html`
<ha-icon-button-next
<ha-icon-button
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-next>
></ha-icon-button>
`
: nothing
}
@@ -212,13 +214,14 @@ export class ZHADeviceEndpointList extends LitElement {
${
this.showDeviceLink
? html`
<ha-icon-button-next
<ha-icon-button
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-next>
></ha-icon-button>
`
: nothing
}
@@ -1,4 +1,4 @@
import { mdiChevronLeft, mdiChevronRight, mdiClose } from "@mdi/js";
import { mdiChevronLeft, 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,7 +64,6 @@ 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;
@@ -185,8 +184,7 @@ class DialogZWaveJSAddNode extends LitElement {
(this._step && backButtonStages.includes(this._step)) ||
(this._step === "search_devices" && this._supportsSmartStart)
) {
icon =
mainWindow.document.dir === "rtl" ? mdiChevronRight : mdiChevronLeft;
icon = mdiChevronLeft;
}
let titleTranslationKey = "title";
@@ -103,7 +103,6 @@ class HaConfigSectionNetwork extends LitElement {
max-width: 600px;
}
.discovery-card ha-md-list {
background: none;
padding-top: 0;
}
`;
@@ -1,4 +1,10 @@
import { mdiInformationOutline } from "@mdi/js";
import {
mdiChevronDoubleLeft,
mdiChevronDoubleRight,
mdiChevronLeft,
mdiChevronRight,
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";
@@ -9,10 +15,6 @@ 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";
@@ -167,20 +169,22 @@ class EventSubscribeCard extends LitElement {
return html`
<ha-card class="events-card">
<div class="events-toolbar">
<ha-icon-button-first
<ha-icon-button
.path=${mdiChevronDoubleLeft}
.disabled=${index >= bufferTotal - 1}
.label=${this.hass!.localize(
"ui.panel.config.tools.tabs.events.oldest_event"
)}
@click=${this._showOldest}
></ha-icon-button-first>
<ha-icon-button-prev
></ha-icon-button>
<ha-icon-button
.path=${mdiChevronLeft}
.disabled=${index >= bufferTotal - 1}
.label=${this.hass!.localize(
"ui.panel.config.tools.tabs.events.older_event"
)}
@click=${this._showOlder}
></ha-icon-button-prev>
></ha-icon-button>
<div class="event-info">
${this.hass!.localize(
"ui.panel.config.tools.tabs.events.event_fired",
@@ -214,20 +218,22 @@ class EventSubscribeCard extends LitElement {
: nothing
}
</div>
<ha-icon-button-next
<ha-icon-button
.path=${mdiChevronRight}
.disabled=${atNewest}
.label=${this.hass!.localize(
"ui.panel.config.tools.tabs.events.newer_event"
)}
@click=${this._showNewer}
></ha-icon-button-next>
<ha-icon-button-last
></ha-icon-button>
<ha-icon-button
.path=${mdiChevronDoubleRight}
.disabled=${atNewest}
.label=${this.hass!.localize(
"ui.panel.config.tools.tabs.events.newest_event"
)}
@click=${this._showNewest}
></ha-icon-button-last>
></ha-icon-button>
</div>
<ha-yaml-editor
.value=${event.event}
+6 -60
View File
@@ -7,7 +7,6 @@ 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";
@@ -15,12 +14,9 @@ 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";
@@ -39,7 +35,7 @@ import { showNewOverviewDialog } from "./dialogs/show-dialog-new-overview";
import { hasLegacyOverviewPanel } from "../../data/panel";
@customElement("ha-panel-home")
class PanelHome extends SubscribeMixin(LitElement) {
class PanelHome extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -52,36 +48,12 @@ class PanelHome extends SubscribeMixin(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) {
@@ -178,41 +150,16 @@ class PanelHome extends SubscribeMixin(LitElement) {
}
private async _loadConfig() {
const securityConfigRevision = this._securityConfigRevision;
const [translationsResult, homeResult, securityResult] =
await Promise.allSettled([
try {
const [_, data] = await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
fetchFrontendSystemData(this.hass.connection, "security"),
]);
if (translationsResult.status === "rejected") {
this._config = data || {};
} catch (err) {
// eslint-disable-next-line no-console
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);
console.error("Failed to load favorites:", err);
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 || {};
}
}
@@ -396,7 +343,6 @@ class PanelHome extends SubscribeMixin(LitElement) {
return {
strategy: {
type: "home",
alert_entities: this._securityConfig.alert_entities,
favorite_entities: this._config.favorite_entities,
home_panel: true,
hide_welcome_message: this._config.hide_welcome_message,
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { mdiHomeImportOutline, mdiPause, mdiPlay, mdiStop } from "@mdi/js";
import { mdiHomeImportOutline, mdiPause, mdiPlay } 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,7 +20,6 @@ import {
LawnMowerEntityFeature,
canDock,
canStartMowing,
canStop,
} from "../../../data/lawn_mower";
import type { HomeAssistant, HomeAssistantApi } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
@@ -47,7 +46,6 @@ export const LAWN_MOWER_COMMANDS_FEATURES: Record<
LawnMowerEntityFeature.PAUSE,
LawnMowerEntityFeature.START_MOWING,
],
stop: [LawnMowerEntityFeature.STOP],
dock: [LawnMowerEntityFeature.DOCK],
};
@@ -83,12 +81,6 @@ 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,14 +26,12 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type { LovelaceCardFeature } 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);
@@ -88,15 +86,9 @@ 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");
@@ -111,6 +103,10 @@ class HuiTargetHumidityCardFeature
}
}
private get _step() {
return this._stateObj!.attributes.target_humidity_step ?? 1;
}
private get _min() {
return this._stateObj!.attributes.min_humidity ?? 0;
}
@@ -144,34 +140,13 @@ 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,
+1 -2
View File
@@ -186,7 +186,6 @@ export interface NumericInputCardFeatureConfig {
export interface TargetHumidityCardFeatureConfig {
type: "target-humidity";
style?: "buttons" | "slider";
}
export interface TargetTemperatureCardFeatureConfig {
@@ -265,7 +264,7 @@ export interface ValvePositionFavoriteCardFeatureConfig {
type: "valve-position-favorite";
}
export const LAWN_MOWER_COMMANDS = ["start_pause", "stop", "dock"] as const;
export const LAWN_MOWER_COMMANDS = ["start_pause", "dock"] as const;
export type LawnMowerCommand = (typeof LAWN_MOWER_COMMANDS)[number];
+1 -11
View File
@@ -190,10 +190,6 @@ 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",
@@ -210,12 +206,11 @@ export class HuiCalendarCard
}
<ha-full-calendar
class=${classMap({
"is-grid": this.layout === "grid" && !autoHeight,
"is-grid": this.layout === "grid",
"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}
@@ -408,11 +403,6 @@ 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,7 +2,6 @@ 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";
@@ -106,22 +105,10 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
}
this._names = {};
this._entities.forEach((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
);
const stateObj = this.hass!.states[entity.entity];
this._names[entity.entity] = stateObj
? this.hass!.formatEntityName(stateObj, entity.name)
: entity.entity;
});
}
@@ -328,7 +315,6 @@ 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>
@@ -374,8 +360,11 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.names=${this._names}
up-to-now
.hoursToShow=${this._hoursToShow}
.showNames=${showNames}
?inside-labels=${showNames}
.showNames=${
this._config.show_names !== undefined
? this._config.show_names
: true
}
.logarithmicScale=${this._config.logarithmic_scale || false}
.minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis}
@@ -2,7 +2,6 @@ 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";
@@ -40,10 +39,6 @@ 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";
@@ -184,16 +179,6 @@ 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) =>
@@ -217,22 +202,8 @@ 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 warningText ?? "";
return "";
}
const unlockedLocks = locks.filter((entityId) => {
@@ -240,18 +211,23 @@ export class HuiHomeSummaryCard
return s === "unlocked" || s === "jammed" || s === "open";
});
const statusText = unlockedLocks.length
? this.hass.localize("ui.card.home-summary.count_locks_unlocked", {
if (unlockedLocks.length) {
return this.hass.localize(
"ui.card.home-summary.count_locks_unlocked",
{
count: unlockedLocks.length,
})
: 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(", ");
}
);
}
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");
}
case "media_players": {
// Playing media
@@ -362,29 +338,23 @@ export class HuiHomeSummaryCard
return nothing;
}
const summary = this._config.summary;
const isAlertsSummary = summary === "alerts";
const color = computeCssColor(HOME_SUMMARIES_COLORS[summary]);
const color = computeCssColor(HOME_SUMMARIES_COLORS[this._config.summary]);
const style = {
"--tile-color": color,
"--ha-alert-color": isAlertsSummary ? color : undefined,
};
const secondary = this._computeSummaryState();
const secondaryLoading = this._computeSecondaryLoading(
summary,
this._config.summary,
this._energyData
);
const label = getSummaryLabel(this.hass.localize, summary);
const icon = HOME_SUMMARIES_ICONS[summary];
const label = getSummaryLabel(this.hass.localize, this._config.summary);
const icon = HOME_SUMMARIES_ICONS[this._config.summary];
return html`
<ha-card
class=${classMap({ alert: isAlertsSummary })}
style=${styleMap(style)}
>
<ha-card style=${styleMap(style)}>
<ha-tile-container
.vertical=${Boolean(this._config.vertical)}
.interactive=${this._hasCardAction}
@@ -411,24 +381,6 @@ 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;
}
`,
];
+15 -24
View File
@@ -1,15 +1,14 @@
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";
@@ -109,14 +108,12 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
<div class="content">
<div id="controls">
<div id="slider">
<ha-control-circular-slider
mode="start"
.min=${1}
.max=${100}
.step=${1}
<!-- @ts-ignore Round-slider has no tag definition or exported type -->
<round-slider
min="1"
max="100"
.value=${brightness}
.disabled=${stateObj.state === UNAVAILABLE}
prevent-interaction-on-scroll
@value-changing=${this._dragEvent}
@value-changed=${this._setBrightness}
style=${styleMap({
@@ -124,7 +121,7 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
? "visible"
: "hidden",
})}
></ha-control-circular-slider>
></round-slider>
<ha-icon-button
class="light-button ${classMap({
"slider-center": lightSupportsBrightness(stateObj),
@@ -194,10 +191,9 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
}
}
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} %`;
private _dragEvent(e: any): void {
this.shadowRoot!.querySelector(".brightness")!.innerHTML =
`${e.detail.value} %`;
this._showBrightness();
this._hideBrightness();
}
@@ -217,14 +213,10 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
}, 500);
}
private _setBrightness(
ev: HASSDomEvent<HASSDomEvents["value-changed"]>
): void {
const { value } = ev.detail;
if (typeof value !== "number" || isNaN(value)) return;
private _setBrightness(e: any): void {
this.hass!.callService("light", "turn_on", {
entity_id: this._config!.entity,
brightness_pct: value,
brightness_pct: e.detail.value,
});
}
@@ -300,11 +292,10 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
min-width: 100px;
}
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;
round-slider {
--round-slider-path-color: var(--slider-track-color);
--round-slider-bar-color: var(--primary-color);
padding-bottom: 10%;
}
.light-button {
-2
View File
@@ -3,7 +3,6 @@ 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 {
@@ -715,7 +714,6 @@ export interface HeadingCardConfig extends LovelaceCardConfig {
export interface HomeSummaryCard extends LovelaceCardConfig {
summary: HomeSummary;
alert_entities?: SecurityAlertEntityConfig[];
vertical?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
@@ -12,11 +12,9 @@ import { computeDeviceName } from "../../../../common/entity/compute_device_name
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { stringCompare } from "../../../../common/string/compare";
import { entityComboBoxKeys } from "../../../../data/entity/entity_picker";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import { domainToName } from "../../../../data/integration";
import { multiTermSortedSearch } from "../../../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../../../types";
@@ -26,7 +24,6 @@ export interface DeviceNode {
id: string;
name: string;
entityIds: string[];
children: DeviceNode[];
}
export interface AreaNode {
@@ -135,30 +132,6 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
const unassignedEntityByDomain = new Map<string, string[]>();
const searchableEntities: SearchableEntity[] = [];
const addDeviceEntity = (
bucket: Map<string, string[]>,
device: DeviceRegistryEntry,
entityId: string,
areaId: string | undefined
) => {
const list = bucket.get(device.id) ?? [];
list.push(entityId);
bucket.set(device.id, list);
// A child device nests under its parent when both land in the same
// bucket, so the parent needs a row even without entities of its own.
const parent = device.parent_device_id
? deviceReg[device.parent_device_id]
: undefined;
if (
parent &&
getDeviceAreaId(parent, deviceReg) === areaId &&
(parent.entry_type === "service") === (device.entry_type === "service") &&
!bucket.has(parent.id)
) {
bucket.set(parent.id, []);
}
};
for (const entityId of Object.keys(states)) {
const stateObj = states[entityId];
if (!stateObj) continue;
@@ -209,7 +182,9 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
const target = isService
? unassignedServiceEntities
: unassignedDeviceEntities;
addDeviceEntity(target, device, entityId, undefined);
const list = target.get(device.id) ?? [];
list.push(entityId);
target.set(device.id, list);
} else if (isHelperDomain(domain)) {
const list = unassignedHelperByDomain.get(domain) ?? [];
list.push(entityId);
@@ -225,7 +200,9 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
const groupUnderDevice = device && !entry?.area_id;
if (groupUnderDevice) {
const byDevice = areaDeviceEntities.get(areaId) ?? new Map();
addDeviceEntity(byDevice, device!, entityId, areaId);
const list = byDevice.get(device!.id) ?? [];
list.push(entityId);
byDevice.set(device!.id, list);
areaDeviceEntities.set(areaId, byDevice);
} else {
const list = areaDirectEntities.get(areaId) ?? [];
@@ -240,35 +217,17 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
return stringCompare(an, bn, language);
};
const sortDeviceNodes = (nodes: DeviceNode[]) => {
nodes.sort((a, b) => stringCompare(a.name, b.name, language));
nodes.forEach((node) => sortDeviceNodes(node.children));
};
const buildDeviceNodes = (source: Map<string, string[]>): DeviceNode[] => {
const nodes = new Map<string, DeviceNode>();
for (const [id, ids] of source) {
const device = deviceReg[id];
nodes.set(id, {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
children: [],
});
}
const roots: DeviceNode[] = [];
for (const node of nodes.values()) {
const parentId = deviceReg[node.id]?.parent_device_id;
const parent = parentId ? nodes.get(parentId) : undefined;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
sortDeviceNodes(roots);
return roots;
};
const buildDeviceNodes = (source: Map<string, string[]>): DeviceNode[] =>
[...source.entries()]
.map(([id, ids]) => {
const device = deviceReg[id];
return {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
};
})
.sort((a, b) => stringCompare(a.name, b.name, language));
const buildAreaNode = (areaId: string): AreaNode | undefined => {
const area = areaReg[areaId];
@@ -366,28 +325,17 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
};
}
const devicePathToEntity = (
devices: DeviceNode[],
parentKey: string,
entityId: string
): string[] | undefined => {
for (const device of devices) {
const key = deviceKey(parentKey, device.id);
if (device.entityIds.includes(entityId)) return [key];
const nested = devicePathToEntity(device.children, key, entityId);
if (nested) return [key, ...nested];
}
return undefined;
};
export function pathToEntity(tree: EntityTree, entityId: string): string[] {
for (const floor of tree.floors) {
const fKey = floorKey(floor.id);
for (const area of floor.areas) {
const aKey = areaKey(fKey, area.id);
if (area.directEntityIds.includes(entityId)) return [fKey, aKey];
const devicePath = devicePathToEntity(area.devices, aKey, entityId);
if (devicePath) return [fKey, aKey, ...devicePath];
for (const device of area.devices) {
if (device.entityIds.includes(entityId)) {
return [fKey, aKey, deviceKey(aKey, device.id)];
}
}
}
}
@@ -397,15 +345,21 @@ export function pathToEntity(tree: EntityTree, entityId: string): string[] {
if (area.directEntityIds.includes(entityId)) {
return [otherAreasFloor, aKey];
}
const devicePath = devicePathToEntity(area.devices, aKey, entityId);
if (devicePath) return [otherAreasFloor, aKey, ...devicePath];
for (const device of area.devices) {
if (device.entityIds.includes(entityId)) {
return [otherAreasFloor, aKey, deviceKey(aKey, device.id)];
}
}
}
for (const section of tree.unassignedSections) {
const sKey = unassignedKey(section.id);
if (section.devices) {
const devicePath = devicePathToEntity(section.devices, sKey, entityId);
if (devicePath) return [sKey, ...devicePath];
for (const device of section.devices) {
if (device.entityIds.includes(entityId)) {
return [sKey, deviceKey(sKey, device.id)];
}
}
}
if (section.domains) {
for (const group of section.domains) {
@@ -416,18 +416,14 @@ export class HuiSuggestionEntityTree extends LitElement {
`;
}
private _renderDevice(
device: DeviceNode,
parentKey: string,
nested = false
): TemplateResult {
private _renderDevice(device: DeviceNode, parentKey: string): TemplateResult {
const key = deviceKey(parentKey, device.id);
const expanded = this._isExpanded(key);
const domain = this._deviceDomain(device.id);
return html`
<ha-combo-box-item
type="button"
class="branch ${nested ? "depth-device-nested" : "depth-device"} device-item"
class="branch depth-device device-item"
aria-expanded=${expanded}
data-node-key=${key}
@click=${this._toggleNode}
@@ -448,24 +444,11 @@ export class HuiSuggestionEntityTree extends LitElement {
</ha-combo-box-item>
${
expanded
? html`
${repeat(
device.children,
(child: DeviceNode) => child.id,
(child: DeviceNode) => this._renderDevice(child, key, true)
)}
${repeat(
device.entityIds,
(id: string) => id,
(id: string) =>
this._renderEntity(
id,
nested
? "depth-entity-device-nested"
: "depth-entity-device"
)
)}
`
? repeat(
device.entityIds,
(id: string) => id,
(id: string) => this._renderEntity(id, "depth-entity-device")
)
: nothing
}
`;
@@ -667,13 +650,9 @@ export class HuiSuggestionEntityTree extends LitElement {
ha-combo-box-item.depth-entity-area {
--md-list-item-leading-space: var(--ha-space-12);
}
ha-combo-box-item.depth-entity-device,
ha-combo-box-item.depth-device-nested {
ha-combo-box-item.depth-entity-device {
--md-list-item-leading-space: var(--ha-space-16);
}
ha-combo-box-item.depth-entity-device-nested {
--md-list-item-leading-space: var(--ha-space-20);
}
.leading {
display: flex;
align-items: center;
@@ -173,7 +173,6 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"media-player-volume-slider",
"numeric-input",
"select-options",
"target-humidity",
"timer-actions",
"timer-presets",
"trend-graph",
@@ -1,90 +0,0 @@
import { html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type {
TargetHumidityCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
@customElement("hui-target-humidity-card-feature-editor")
export class HuiTargetHumidityCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: TargetHumidityCardFeatureConfig;
public setConfig(config: TargetHumidityCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "style",
selector: {
select: {
multiple: false,
mode: "list",
options: ["slider", "buttons"].map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.features.types.target-humidity.style_list.${mode}`
),
})),
},
},
},
] as const
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const data: TargetHumidityCardFeatureConfig = {
style: "slider",
...this._config,
};
const schema = this._schema(this.hass.localize);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.target-humidity.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-target-humidity-card-feature-editor": HuiTargetHumidityCardFeatureEditor;
}
}
+8 -24
View File
@@ -257,9 +257,7 @@ class HUIRoot extends LitElement {
icon: mdiPlus,
key: "ui.panel.lovelace.menu.add",
visible:
!this._editMode &&
this.hass.user?.is_admin &&
!this.hass.kioskElementsHidden.has("dashboard_add_button"),
!this._editMode && this.hass.user?.is_admin && !this.hass.kioskMode,
overflow: this.narrow,
subItems: [
{
@@ -302,9 +300,7 @@ class HUIRoot extends LitElement {
? "(⌘ + K)"
: "(Ctrl + K)"
: undefined,
visible:
!this._editMode &&
!this.hass.kioskElementsHidden.has("dashboard_search_button"),
visible: !this._editMode && !this.hass.kioskMode,
overflow: this.narrow,
},
{
@@ -315,9 +311,7 @@ class HUIRoot extends LitElement {
suffix:
this.hass.enableShortcuts && !isMobileClient ? "(A)" : undefined,
visible:
!this._editMode &&
this._conversation(this.hass.config.components) &&
!this.hass.kioskElementsHidden.has("dashboard_assist_button"),
!this._editMode && this._conversation(this.hass.config.components),
overflow: this.narrow,
},
{
@@ -353,7 +347,7 @@ class HUIRoot extends LitElement {
!this._editMode &&
this.hass!.user?.is_admin &&
!this.hass!.config.recovery_mode &&
!this.hass.kioskElementsHidden.has("dashboard_edit_button") &&
!this.hass.kioskMode &&
!this.noEdit,
overflow: true,
overflow_can_promote: true,
@@ -555,10 +549,6 @@ class HUIRoot extends LitElement {
const isSubview = curViewConfig?.subview;
const hasTabViews = views.filter((view) => !view.subview).length > 1;
// Only honored outside edit mode. In edit mode the tabs are the view
// editor (select, move, edit and add views), so hiding them there would
// make views uneditable; see `dashboard_tabs` in data/kiosk_mode.
const hideTabs = this.hass.kioskElementsHidden.has("dashboard_tabs");
return html`
<div
@@ -615,19 +605,13 @@ class HUIRoot extends LitElement {
${curViewConfig.title}
</div>
`
: hideTabs
? html`
: hasTabViews
? tabs
: html`
<div class="main-title">
${curViewConfig?.title ?? dashboardTitle}
${views[0]?.title ?? dashboardTitle}
</div>
`
: hasTabViews
? tabs
: html`
<div class="main-title">
${views[0]?.title ?? dashboardTitle}
</div>
`
}
<div class="action-items">
${this._renderActionItems()}
@@ -6,7 +6,6 @@ import { lightEntityFilters } from "../../../../light/strategies/light-view-stra
import { securityEntityFilters } from "../../../../security/strategies/security-view-strategy";
export const HOME_SUMMARIES = [
"alerts",
"light",
"climate",
"security",
@@ -19,7 +18,6 @@ export const HOME_SUMMARIES = [
export type HomeSummary = (typeof HOME_SUMMARIES)[number];
export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
alerts: "mdi:alarm-plus",
light: "mdi:lamps",
climate: "mdi:home-thermometer",
security: "mdi:security",
@@ -30,7 +28,6 @@ export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
};
export const HOME_SUMMARIES_COLORS: Record<HomeSummary, string> = {
alerts: "red",
light: "amber",
climate: "deep-orange",
security: "blue-grey",
@@ -41,7 +38,6 @@ export const HOME_SUMMARIES_COLORS: Record<HomeSummary, string> = {
};
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
alerts: [],
light: lightEntityFilters,
climate: climateEntityFilters,
security: securityEntityFilters,
@@ -1,8 +1,6 @@
import { STATE_NOT_RUNNING } from "home-assistant-js-websocket";
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import type { SecurityAlertEntityConfig } from "../../../../data/frontend";
import type { ShortcutItem } from "../../../../data/home_shortcuts";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
@@ -16,11 +14,11 @@ import {
} from "./helpers/home-summaries";
import type { HomeAreaViewStrategyConfig } from "./home-area-view-strategy";
import type { HomeOtherDevicesViewStrategyConfig } from "./home-other-devices-view-strategy";
import type { ShortcutItem } from "../../../../data/home_shortcuts";
import type { HomeOverviewViewStrategyConfig } from "./home-overview-view-strategy";
export interface HomeDashboardStrategyConfig {
type: "home";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
home_panel?: boolean;
hide_welcome_message?: boolean;
@@ -105,7 +103,6 @@ export class HomeDashboardStrategy extends ReactiveElement {
path: "overview",
strategy: {
type: "home-overview",
alert_entities: config.alert_entities,
favorite_entities: config.favorite_entities,
home_panel: config.home_panel,
hide_welcome_message: config.hide_welcome_message,
@@ -11,7 +11,6 @@ import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
import type { EnergyPreferences } from "../../../../data/energy";
import { getEnergyPreferences } from "../../../../data/energy";
import type { SecurityAlertEntityConfig } from "../../../../data/frontend";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
LovelaceSectionConfig,
@@ -25,7 +24,6 @@ import type { HomeAssistant } from "../../../../types";
import { hasClimateEntities } from "../../../climate/strategies/climate-view-strategy";
import type {
AreaCardConfig,
ConditionalCardConfig,
DiscoveredDevicesCardConfig,
EmptyStateCardConfig,
HeadingCardConfig,
@@ -37,24 +35,17 @@ import type {
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import {
computeDefaultSecurityAlertVisibility,
filterSecurityAlertEntities,
resolveSecurityAlertSeverity,
} from "../../../security/strategies/security-alerts";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
} from "../helpers/view-columns-conditions";
import type { LovelaceStrategyDependency } from "../types";
import type { CommonControlsSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
import { generateLovelaceSectionStrategy } from "../get-strategy";
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
import { OTHER_DEVICES_FILTERS } from "./helpers/other-devices-filters";
export interface HomeOverviewViewStrategyConfig {
type: "home-overview";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
home_panel?: boolean;
hide_welcome_message?: boolean;
@@ -121,28 +112,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
"panels",
];
static shouldRegenerate(
config: HomeOverviewViewStrategyConfig,
oldHass: HomeAssistant,
newHass: HomeAssistant
) {
return (
this.registryDependencies.some((key) => oldHass[key] !== newHass[key]) ||
(config.alert_entities?.some(
(alertEntity) =>
resolveSecurityAlertSeverity(
alertEntity,
oldHass.states[alertEntity.entity]
) !==
resolveSecurityAlertSeverity(
alertEntity,
newHass.states[alertEntity.entity]
)
) ??
false)
);
}
static async generate(
config: HomeOverviewViewStrategyConfig,
hass: HomeAssistant
@@ -287,25 +256,16 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
let favoritesSection: LovelaceSectionRawConfig | undefined;
if (!config.hide_suggested_entities) {
const generatedFavoritesSection = await generateLovelaceSectionStrategy(
{
strategy: {
type: "common-controls",
limit: maxCommonControls,
include_entities: favoriteEntities,
hide_empty: true,
heading: favoritesHeadingCard,
} satisfies CommonControlsSectionStrategyConfig,
column_span: maxColumns,
} satisfies LovelaceStrategySectionConfig,
hass
);
if (!generatedFavoritesSection.disabled) {
favoritesSection = {
...generatedFavoritesSection,
column_span: maxColumns,
};
}
favoritesSection = {
strategy: {
type: "common-controls",
limit: maxCommonControls,
include_entities: favoriteEntities,
hide_empty: true,
heading: favoritesHeadingCard,
} satisfies CommonControlsSectionStrategyConfig,
column_span: maxColumns,
} satisfies LovelaceStrategySectionConfig;
} else if (favoriteEntities.length > 0) {
favoritesSection = {
type: "grid",
@@ -345,17 +305,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
hass.panels.maintenance &&
findEntities(allEntities, maintenanceFilters).length > 0;
const alertEntities = config.alert_entities ?? [];
const alertSeverityEntities = filterSecurityAlertEntities(
alertEntities,
hass,
"alert"
);
const alertActiveConditions = alertSeverityEntities.map((alertEntity) => ({
condition: "and" as const,
conditions: computeDefaultSecurityAlertVisibility(alertEntity.entity),
}));
const weatherFilter = generateEntityFilter(hass, {
domain: "weather",
entity_category: "none",
@@ -401,23 +350,17 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
},
} satisfies HomeSummaryCard)
: undefined,
security: () => {
if (!hasSecurity) {
return undefined;
}
const card: HomeSummaryCard = {
type: "home-summary",
summary: "security",
tap_action: {
action: "navigate",
navigation_path: "/security?historyBack=1",
},
};
if (alertEntities.length) {
card.alert_entities = alertEntities;
}
return card;
},
security: () =>
hasSecurity
? ({
type: "home-summary",
summary: "security",
tap_action: {
action: "navigate",
navigation_path: "/security?historyBack=1",
},
} satisfies HomeSummaryCard)
: undefined,
media_players: () =>
hasMediaPlayers
? ({
@@ -511,10 +454,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
}
}
const hasVisibleSummaryCards = summaryCards.some(
(card) => !("hide_empty" in card && card.hide_empty)
);
// Build summary cards for sidebar (full width: columns 12)
const sidebarSummaryCards = summaryCards.map((card) => ({
...card,
@@ -535,33 +474,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
heading_style: "title",
};
const alertsCard: HomeSummaryCard | undefined = alertSeverityEntities.length
? ({
type: "home-summary",
summary: "alerts",
alert_entities: alertSeverityEntities,
tap_action: {
action: "navigate",
navigation_path: "/security?historyBack=1",
},
visibility: [
{
condition: "or",
conditions: alertActiveConditions,
},
],
} satisfies HomeSummaryCard)
: undefined;
const mobileAlertsSection: LovelaceSectionConfig | undefined = alertsCard
? {
type: "grid",
column_span: maxColumns,
visibility: [SMALL_SCREEN_CONDITION],
cards: [{ ...alertsCard, grid_options: { columns: 6 } }],
}
: undefined;
// Mobile summary section (visible on small screens only)
const mobileSummarySection: LovelaceSectionConfig | undefined =
mobileSummaryCards.length > 0
@@ -575,7 +487,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
// Sidebar section
const sidebarSection: LovelaceSectionConfig | undefined =
sidebarSummaryCards.length > 0 || alertsCard
sidebarSummaryCards.length > 0
? {
type: "grid",
cards: [
@@ -583,103 +495,67 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
...summaryHeadingCard,
grid_options: { rows: "auto" }, // Compact style
},
...(alertsCard
? [{ ...alertsCard, grid_options: { columns: 12 } }]
: []),
...sidebarSummaryCards,
],
}
: undefined;
const emptyStateCard = {
type: "empty-state",
icon: "mdi:home-assistant",
content_only: true,
title: hass.localize("ui.panel.lovelace.strategy.home.welcome_title"),
content: hass.localize("ui.panel.lovelace.strategy.home.welcome_content"),
...(config.home_panel && hass.user?.is_admin
? {
buttons: [
{
icon: "mdi:plus",
text: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_add_device"
),
appearance: "filled" as const,
variant: "brand" as const,
tap_action: {
action: "fire-dom-event" as const,
home_panel: {
type: "add_integration",
},
},
},
{
icon: "mdi:home-edit",
text: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
),
appearance: "plain" as const,
variant: "brand" as const,
tap_action: {
action: "navigate" as const,
navigation_path: "/config/areas/dashboard",
},
},
],
}
: {}),
} as EmptyStateCardConfig;
// No sections, show empty state
if (
floorsSections.length === 0 &&
!alertsCard &&
!favoritesSection &&
!hasVisibleSummaryCards
) {
if (floorsSections.length === 0) {
return {
type: "panel",
cards: [emptyStateCard],
cards: [
{
type: "empty-state",
icon: "mdi:home-assistant",
content_only: true,
title: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_title"
),
content: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_content"
),
...(config.home_panel && hass.user?.is_admin
? {
buttons: [
{
icon: "mdi:plus",
text: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_add_device"
),
appearance: "filled",
variant: "brand",
tap_action: {
action: "fire-dom-event",
home_panel: {
type: "add_integration",
},
},
},
{
icon: "mdi:home-edit",
text: hass.localize(
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
),
appearance: "plain",
variant: "brand",
tap_action: {
action: "navigate",
navigation_path: "/config/areas/dashboard",
},
},
],
}
: {}),
} as EmptyStateCardConfig,
],
};
}
const emptyStateSection: LovelaceSectionConfig | undefined =
floorsSections.length === 0 &&
!favoritesSection &&
!hasVisibleSummaryCards &&
alertSeverityEntities.length
? {
type: "grid",
column_span: maxColumns,
cards: [
{
type: "conditional",
conditions: [
{
condition: "not",
conditions: [
{
condition: "or",
conditions: alertActiveConditions,
},
],
},
],
card: emptyStateCard,
} satisfies ConditionalCardConfig,
],
}
: undefined;
const sections = (
[
emptyStateSection,
mobileAlertsSection,
favoritesSection,
mobileSummarySection,
...floorsSections,
] satisfies (LovelaceSectionRawConfig | undefined)[]
[favoritesSection, mobileSummarySection, ...floorsSections] satisfies (
LovelaceSectionRawConfig | undefined
)[]
).filter(Boolean) as LovelaceSectionRawConfig[];
return {
@@ -4,11 +4,9 @@ import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
import {
getImageEntityIdFromMediaSourceContentId,
isMediaSourceContentId,
resolveMediaSourceWithCache,
} from "../../../data/media_source";
import { computeImageUrl } from "../../../data/image";
@customElement("hui-view-background")
export class HUIViewBackground extends LitElement {
@@ -19,8 +17,6 @@ export class HUIViewBackground extends LitElement {
@state({ attribute: false }) resolvedImage?: string;
private _entityId: string | undefined = undefined;
protected render() {
return nothing;
}
@@ -41,41 +37,17 @@ export class HUIViewBackground extends LitElement {
const backgroundImage = this._getBackgroundImage(this.background);
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
this._entityId = undefined;
this.resolvedImage = undefined;
return;
}
let resolvedUrl: string | undefined;
this._entityId = getImageEntityIdFromMediaSourceContentId(backgroundImage);
if (this._entityId) {
const stateObj = this.hass.states[this._entityId];
if (stateObj) {
const url = computeImageUrl(stateObj);
if (url) {
const image = new Image();
image.src = this.hass.hassUrl(url);
try {
await image.decode();
const newStateObj = this.hass.states[this._entityId];
// Discard if stale
if (url !== computeImageUrl(newStateObj)) {
return;
}
resolvedUrl = url;
} catch {
resolvedUrl = undefined;
}
}
}
} else {
try {
resolvedUrl = (
await resolveMediaSourceWithCache(this.hass, backgroundImage)
).url;
} catch {
resolvedUrl = undefined;
}
try {
resolvedUrl = (
await resolveMediaSourceWithCache(this.hass, backgroundImage)
).url;
} catch {
resolvedUrl = undefined;
}
// Discard if the background changed while resolving
if (this._getBackgroundImage(this.background) === backgroundImage) {
@@ -159,13 +131,6 @@ export class HUIViewBackground extends LitElement {
) {
applyTheme = true;
}
if (
this._entityId &&
this.hass.states[this._entityId] !== oldHass?.states[this._entityId]
) {
this._fetchMedia();
applyTheme = true;
}
}
if (changedProperties.has("background")) {
@@ -346,9 +346,6 @@ export class HuiViewHeader extends LitElement {
width: 100%;
max-width: 700px;
display: flex;
min-height: calc(
var(--ha-font-size-xl) * var(--ha-line-height-condensed) + 4px
);
}
.heading > * {
@@ -356,10 +353,6 @@ export class HuiViewHeader extends LitElement {
height: 100%;
}
.container:not(.edit-mode) .heading:has(> *[hidden]) {
display: none;
}
.badges {
position: relative;
flex: 1;
+3 -9
View File
@@ -391,19 +391,10 @@ export const getMyRedirects = (): Redirects => ({
component: "hassio",
redirect: "/config/apps/available",
},
supervisor_apps: {
component: "hassio",
redirect: "/config/apps",
},
supervisor_addons: {
component: "hassio",
redirect: "/config/apps",
},
supervisor: {
// Supervisor panel was removed in 2026.2, fallback to apps
component: "hassio",
redirect: "/config/apps",
},
supervisor_app: {
component: "hassio",
redirect: "/config/app",
@@ -440,6 +431,9 @@ export const getMyRedirects = (): Redirects => ({
category: "string?",
},
},
lights: {
redirect: "/lights",
},
});
const getRedirect = (path: string): Redirect | undefined =>
@@ -4,12 +4,8 @@ import type {
SecurityAlertEntityConfig,
SecurityAlertSeverity,
} from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { StateCondition } from "../../lovelace/common/validate-condition";
import type { AlertCardConfig } from "../../lovelace/cards/types";
import {
checkConditionsMet,
type StateCondition,
} from "../../lovelace/common/validate-condition";
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
"carbon_monoxide",
@@ -78,36 +74,12 @@ export const computeDefaultSecurityAlertVisibility = (
return [condition];
};
export const resolveSecurityAlertSeverity = (
alertEntity: SecurityAlertEntityConfig,
stateObj?: HassEntity
): SecurityAlertSeverity =>
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
export const isSecurityAlertActive = (
hass: HomeAssistant,
entityId: string
): boolean =>
checkConditionsMet(computeDefaultSecurityAlertVisibility(entityId), hass, {});
export const filterSecurityAlertEntities = (
alertEntities: SecurityAlertEntityConfig[],
hass: HomeAssistant,
severity: SecurityAlertSeverity
): SecurityAlertEntityConfig[] =>
alertEntities.filter(
(alertEntity) =>
resolveSecurityAlertSeverity(
alertEntity,
hass.states[alertEntity.entity]
) === severity
);
export const computeSecurityAlertCardConfig = (
stateObj: HassEntity | undefined,
alertEntity: SecurityAlertEntityConfig
): AlertCardConfig => {
const severity = resolveSecurityAlertSeverity(alertEntity, stateObj);
const severity =
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
return {
type: "alert",
entity: alertEntity.entity,
-2
View File
@@ -18,7 +18,6 @@ import {
subscribeFrontendUserData,
} from "../data/frontend";
import { forwardHaptic } from "../data/haptics";
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../data/kiosk_mode";
import { serviceCallWillDisconnect } from "../data/service";
import {
DateFormat,
@@ -86,7 +85,6 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
localize: () => "",
translationMetadata,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
dockedSidebar: "docked",
vibrate: true,
debugConnection: __DEV__,

Some files were not shown because too many files have changed in this diff Show More