Compare commits

...
Author SHA1 Message Date
Paul Bottein d203799e57 Merge remote-tracking branch 'origin/dev' into duration-selector-modes
# Conflicts:
#	src/components/ha-duration-input.ts
#	src/components/ha-selector/ha-selector-duration.ts
2026-09-14 10:46:10 +02:00
Petar PetrovandCursor Agent 6dcca89185 Move history-graph card timeline names under the bars (#54102)
* Move history-graph card timeline names under the bars

Enable the same inside-labels layout as the History panel on the
Lovelace history-graph card. Custom entity names still override
the default Device ▸ Entity labels from computeHistory.

* Keep compact timeline rows when names are hidden

inside-labels only applies when the history-graph card is showing
names, and the timeline uses the taller row height only when those
under-bar labels are actually drawn.

* Match timeline row height to when inside labels are drawn

Use the same chunked || showNames condition as the axis options so
virtualized charts keep enough room for under-bar names. String YAML
names still apply when the entity is missing from current state;
structured names fall through to computeHistory instead of the entity ID.

* Preserve structured YAML names without a current state

formatEntityName still needs an entity id when the override is an
EntityNameItem. Use a stub state so device/text/entity name configs
apply even if the entity is missing from hass.states.

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-09-12 13:28:53 +02:00
Wendelin 3f1b2ef65f Onboarding: lite internationalization context (#54125)
* Add internatinalization context to onboarding

* Use the context in welcome
2026-09-12 11:53:30 +02:00
Petar Petrov 34afbe37c3 Use ha-control-circular-slider in the light card (#54123)
Replace the legacy @thomasloven/round-slider brightness ring with the
in-house ha-control-circular-slider (already used by the thermostat and
humidifier cards). The card keeps its layout, center toggle button,
brightness readout, size, and all config options, so existing dashboards
are unaffected. The light card was the last consumer of round-slider, so
the dependency and its patch are dropped.
2026-09-12 11:53:05 +02:00
renovate[bot] 848e81313e Update dependency jszip to v3.10.2 (#54132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-12 09:32:56 +03:00
Paul Bottein 24e7bd306a Sync My redirects with my.home-assistant.io (#53956) 2026-09-11 16:13:45 +02:00
3791548067 Show every selector on the gallery selector page (#54121)
* Show the serial port selector in the gallery

The selector page had no serial_port entry, so the picker could only be
exercised against a running instance with real hardware attached.

Add it to "One of each", backed by a scan mock with one port per section
the picker groups by. The entry carries a flow context, and the page now
forwards that to ha-selector, because the recommended and not recommended
grouping is keyed off the domain a config flow supplies.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NTxTXXEY4FnQryNFbtB5tf

* Answer the ingress panel list in the supervisor demo stub

The navigation picker waits for that collection before it enables itself,
so with the endpoint rejecting it stayed disabled for the whole demo and
gallery, with nothing on screen to say why.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NTxTXXEY4FnQryNFbtB5tf

* Show every selector on the gallery selector page

Twenty-nine of the sixty selectors had no entry, so they could only be
looked at inside a config flow or a blueprint that happened to use one.

Add the missing entries to "One of each", with the mocks the assist and
statistic pickers need to return a list. A few carry a value or a config
because they render their fallback without one: the meta selector reads
the first key of its value, the period selector offers its custom-period
editor, and the voice list is fetched per engine and language.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NTxTXXEY4FnQryNFbtB5tf

* Add new serial port configuration

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-11 17:11:41 +03:00
01cef9a99b Fix trigger 'for' choose (#53749)
* Fix trigger 'for' choose

* fix stub

* try something else

* merge duration-selector with create_duration_data

* handle negatives

* default ms to false for selector

* Handle hidden duration units in ha-duration-input

createDurationData grew three flags describing which fields the duration
input renders, but the input already owns enableDay, enableMillisecond,
enableSecond and allowNegative. Fold the units it does not render there
instead, so createDurationData keeps its single-argument signature.

This handles days and milliseconds symmetrically, and covers every
ha-duration-input consumer rather than only ha-selector-duration:
ha-form-positive_time_period_dict passes .data straight through without
enable-day and dropped days in exactly the same way.

A leading "-" negates the whole period in cv.time_period_str, so the
sign is now always distributed across all parts. The calendar trigger is
the only caller that can see a negative value and does its own sign
handling, so it takes the absolute value of every part instead of just
hours, and copies the duration first since createDurationData returns
dict values by reference.

Co-Authored-By: Claude Opus 5 <[email protected]>

* cleanup calendar trigger

* Refine duration type check for object values

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-11 16:34:29 +03:00
Steffen ProbstandClaude Fable 5 8eb179a0d9 Show pointer cursor on the whole track of the light card's round-slider (#53723)
The classic light card uses @thomasloven/round-slider, whose invisible
`.shadowpath` arc already accepts click/drag (nearest handle follows), but
only the handle dot showed a pointer cursor. Patch the dependency (same
mechanism as leaflet-draw/sortablejs) so the whole track shows the pointer,
and falls back to the default cursor when the slider is disabled or has no
handle. Adds a `--round-slider-path-cursor` override (defaults to
`--round-slider-handle-cursor`).

Co-authored-by: Claude Fable 5 <[email protected]>
2026-09-11 16:10:07 +03:00
pcan08andClaude Sonnet 4.6 34e9b50920 Redesign integration card and remove double navigation (#52828)
* Redesign integration card to align with apps card style

Replace ha-button navigation links with a plain count badge, remove the
ha-icon-next chevron, make the entire card clickable on all content, and match the apps card hover/separator/spacing patterns.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Extract integration card footer into ha-integration-card-footer

Move footer rendering logic (count badge, custom/cloud/yaml icons) and
their computations into a dedicated component, mirroring the
ha-integration-header split.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Rename `ha-integration-header` to `ha-integration-card-header`

* Fix count badge vertical centering in integration card footer

* Replace hardcoded pixel values with spacing tokens

* Remove redundant physical margin properties

* Use consistent <span> elements for footer icons

* Remove dead CSS properties

* Add align-items: flex-start to secondary row

* Push integration card footer to bottom regardless of title length

* Migrate to Lit contexts

* Remove idvider in app and integration card

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-09-11 15:16:09 +03:00
Paul Bottein 2ebe452334 Add idle state and stop action to lawn mower (#53957) 2026-09-11 09:58:49 +00:00
renovate[bot] 3052989e9d Update dependency @rspack/core to v2.2.3 (#54118) 2026-09-11 11:39:13 +02:00
Preethi Pantangi fcab4004b2 Reserve min-height for view header to prevent layout shift (#54115) 2026-09-11 09:55:18 +01:00
Paul Bottein 03ed72147d Simplify the duration sign handling 2026-09-11 10:08:07 +02:00
Paul Bottein b585d41bf5 Address remaining Copilot comments on the duration selector 2026-09-11 09:50:52 +02:00
renovate[bot] 4610adb368 Update dependency marked to v18.0.12 (#54114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-11 06:42:59 +02:00
renovate[bot] e0dba17b66 Update dependency typescript-eslint to v8.70.0 (#54112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-11 06:42:41 +02:00
Paul Bottein 3e069f2eac Rename the offset type state 2026-09-10 22:51:16 +02:00
Paul Bottein 94e7fdba47 Keep the offset type when the duration is cleared to zero 2026-09-10 22:50:30 +02:00
Paul Bottein 0b6e3f20b1 Keep the pending offset type in local state instead of writing negative false 2026-09-10 22:49:47 +02:00
Paul Bottein 4ef97bc2d0 Omit the negative flag for positive signed durations 2026-09-10 22:48:33 +02:00
Paul Bottein 6bc6be27ad Merge the duration sign helpers into normalizeDuration 2026-09-10 22:46:32 +02:00
Paul Bottein 33b7a59aef Test the duration sign and value helpers 2026-09-10 22:44:03 +02:00
Paul Bottein 3c08f602dd Derive the magnitude of mixed-sign legacy durations from their total 2026-09-10 22:43:02 +02:00
Paul Bottein 7d564c8047 Migrate allow_negative to mode when editing a duration selector 2026-09-10 22:34:17 +02:00
Paul Bottein 6c352e904d Remove allow_negative from the selector editor 2026-09-10 21:17:00 +02:00
Paul Bottein c12fa5591c Expose the duration selector mode in the selector editor 2026-09-10 21:14:01 +02:00
Paul Bottein f389679bf3 Address review comments on the duration selector 2026-09-10 19:25:44 +02:00
Paul Bottein 9b8bbafa7e Remove translation key from the duration selector 2026-09-10 19:24:13 +02:00
Paul Bottein b0f0eb1819 Add signed and offset modes to the duration selector 2026-09-10 19:07:46 +02:00
karwosts eee0f9447b Format media selector in object selector (#54107)
Format media selector in object title
2026-09-10 18:34:03 +02:00
Paul Bottein 8d149a914f Add parent device to entity ID format (#54060)
* Add parent device to entity ID format editor

* Explain entity ID format examples with their context

* Share the entity name type list with the entity ID format editor

* Keep filter chip padding stable without a leading icon

* Show one entity ID format example at a time in a separate card

* Shorten entity ID format example names
2026-09-10 16:52:04 +03:00
Paul Bottein b2bb3e9d9d Nest child devices under their parent in device trees (#54063)
* Nest child devices under their parent in the entity tree

* Resolve device entries through shared helpers in the automation target browser

* Nest child devices under their parent when browsing automation targets

* Nest child devices under their parent in target details

* Skip child device rows already nested under their parent in area targets

* Fix expanding the automation target tree to an entity attached to an area

* Skip labeled child device rows already nested under a labeled parent

* Extract target sub-row computation and test child device nesting

* Use the inherited area when hiding filtered child devices

* Keep a parent device as a group row when a child device matches

* Keep child devices at the top level when their parent is disabled

* Drop disabled-parent special cases, core disables children with their parent
2026-09-10 15:46:28 +02:00
karwostsandCopilot Autofix powered by AI 13ef9712bc Use 'image_proxy' endpoint for image entity backgrounds (#54038)
* Use 'image_proxy' endpoint for image entity backgrounds

* Update image source URL with hassUrl method

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* handle concurrent updates

* more robust

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-10 16:42:26 +03:00
Jump-333 40b27608c0 Add buttons style to target-humidity card feature (#53901)
* Add buttons style to target-humidity card feature

* Removed step configuration per-card

* Remove 'step' key from style list in translations

* Add target humidity step to humidity card feature

* Fixed a typo

* Updated style and style_list keys with localization keys.
2026-09-10 16:31:54 +03:00
Aidan Timson e0f03254b5 Show active alerts on Home dashboard (#53526)
* Show security alerts on Home

* Refresh Home security alerts

* Fix Home security alert state handling

* Fix Home alert fallback state

* Render Home alerts as individual cards

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