Compare commits

...
Author SHA1 Message Date
Bruno Pantaleão GonçalvesandClaude Opus 5 59c51007c2 Document kiosk element dependencies on app panel header and edit mode
- app_panel_header holds the app panel's sidebar button, so hiding the header
  hides that button too. Keeping the button without its header would need a
  floating control over the add-on iframe, which kiosk mode never had; a
  client can still open the sidebar with sidebar/show or sidebar/toggle.
- dashboard_tabs is only honored outside edit mode, where the tabs are the
  view editor (select, move, edit and add views). A client keeping the tabs
  out of reach should also hide dashboard_edit_button.

Documented on KIOSK_ELEMENTS, on the kiosk_mode/set contract and next to the
code in ha-panel-app and hui-root.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 19:13:27 +02:00
Bruno Pantaleão GonçalvesandClaude Opus 5 050edb3f01 Address review feedback on kiosk element selection
- Keep kiosk requests per source (companion app, app panel add-on) and hide
  their union, so an add-on's kiosk request still hides the app panel header
  on top of a narrower companion app selection, and the companion app's
  selection is restored when the add-on lets go.
- Show the sidebar opener on wide screens when the sidebar is hidden, both in
  ha-menu-button and in the app panel header, and honor a hidden
  sidebar_button in the app panel header.
- Type kiosk_mode/set element lists as string[], matching the forward
  compatible handling of unknown element names.
- Cover kiosk_mode/set in the external app entrypoint tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 13:09:21 +02:00
Bruno Pantaleão Gonçalves f8b6605915 Merge branch 'dev' into kiosk-hidden-elements 2026-09-14 10:13:12 +02:00
iven 44dd2249b5 Fix calendar card collapsing with auto height (#54159)
* Fix calendar card collapsing with auto height

* Detect auto height from legacy layout_options
2026-09-14 09:52:04 +02:00
Yosi Levy f073a12cbd RTL chevron fixes (#53771)
* RTL chevron fixes

* Name changes

* Label fix

* Fix missing import
2026-09-14 09:43:41 +02:00
Yosi Levy 7dec4a57e8 Various RTL fixes (#54117)
* Various RTL fixes

* Fixes - no keyboard focus

* Fix style
2026-09-14 06:47:51 +02:00
Paulus SchoutsenandClaude aa78f368b1 Fix discovery list covering card corners on network page (#54157)
The ha-md-list inside the network discovery card draws its own surface
background. That square background hides the rounded bottom corners of
the card. Remove the list background so the card shows through.


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

Co-authored-by: Claude <[email protected]>
2026-09-14 06:47:01 +02:00
renovate[bot] 06901dedaa Update dependency lint-staged to v17.5.1 (#54155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 06:45:23 +02:00
renovate[bot] 43f194dad2 Update dependency vite to v8.3.0 (#54156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 06:45:04 +02:00
renovate[bot] e7acf97b1a Update babel monorepo to v8.0.5 (#54161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 06:44:12 +02:00
Bruno Pantaleão 22d48fbfdd Let kiosk clients choose which parts of the UI are hidden
Kiosk mode was a single boolean: a client could ask for kiosk mode and got
one fixed bundle of hidden UI, with no say over what it hid. The iOS app
enables it purely to suppress the hamburger for the native tab bar and
inherits the hidden add, search and edit buttons as a side effect.

Name the hideable parts in KIOSK_ELEMENTS and let `kiosk_mode/set` carry
either `excluded_elements` (hide exactly these) or `included_elements`
(hide everything but these). Sending neither keeps the set kiosk mode hid
before, so clients that only send `enable` are unaffected; sending both
fails the command, since the two lists describe the same set and a message
carrying both has no single meaning.

Two parts are newly hideable rather than newly configurable: the Assist
button, previously the only toolbar button kiosk mode never touched, and
the view tabs, which give way to the current view's title. Neither is in
the default set.

The app panel now reads the kiosk state during render instead of caching
it on a hass change, so opening an ingress add-on while kiosk mode is
already on hides its header on the first paint rather than never.
2026-09-13 16:48:42 +00:00
Paulus SchoutsenandClaude Opus 5 9409db7fda Show YAML-configured Modbus hubs in the Modbus panel (#54134)
* Show YAML-configured Modbus hubs in the connections panel

The backend now lists a hub configured in YAML as a connection of its
own, flagged with its source. Render those rows with the hub name and
the units it addresses, marked as configured in YAML, since there is no
config entry to link to.

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

* Demo the Modbus YAML hub on a TCP device

One serial port cannot carry two links, so a YAML hub sharing the
RS-485 port was not a state the panel can reach. Put the hub on the TCP
device an integration also talks to, where two sockets are real.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-13 07:08:44 +00:00
dependabot[bot] d917df359f Bump home-assistant/actions/helpers/verify-version from a7c616ce81ccda50150bf1595786c71b1883fabb to 58bff37c8947f690ace498be413a9b78d6f30f93 (#54138)
Bump home-assistant/actions/helpers/verify-version

Bumps [home-assistant/actions/helpers/verify-version](https://github.com/home-assistant/actions) from a7c616ce81ccda50150bf1595786c71b1883fabb to 58bff37c8947f690ace498be413a9b78d6f30f93.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](https://github.com/home-assistant/actions/compare/a7c616ce81ccda50150bf1595786c71b1883fabb...58bff37c8947f690ace498be413a9b78d6f30f93)

---
updated-dependencies:
- dependency-name: home-assistant/actions/helpers/verify-version
  dependency-version: 58bff37c8947f690ace498be413a9b78d6f30f93
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-13 09:36:50 +03: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
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
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
Paul Bottein ec4bfc9255 Add parent device to entity context (#53766)
* Add parent device to entity context

* Fix parent device context display edge cases

* Fix parent device context in suggestions and completions

* Order entity name parts by hierarchy

* Remove unused parent device missing string

* Add parent device to logbook entry context

* Show inherited area and parent device for device targets

* Test parent device search in the entity tree

* Show inherited area and parent device in the automation target header

* Move the device domain lookup out of target row item data

* Pass registries to memoized entity name lists

* Document parent device in logbook name detail
2026-09-10 13:38:39 +02:00
134 changed files with 4734 additions and 1777 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
- name: Setup Node and install
uses: ./.github/actions/setup
@@ -12,19 +12,29 @@ const CONNECTIONS: ModbusConnection[] = [
{
endpoint: ["serial", RS485_PORT],
connected: true,
source: "config_entry",
units: { [FLEXIT_ENTRY_ID]: [1], [SOLAREDGE_ENTRY_ID]: [2] },
},
{
endpoint: ["tcp", "192.168.1.42", 502],
connected: true,
source: "config_entry",
units: { [FRONIUS_ENTRY_ID]: [1] },
},
// A second inverter behind a gateway that is not answering
{
endpoint: ["tcp", "modbus-gateway.local", 502],
connected: false,
source: "config_entry",
units: { [FRONIUS_ENTRY_ID]: [2] },
},
// A YAML hub keeps a link of its own to a device an integration also talks to
{
endpoint: ["tcp", "192.168.1.42", 502],
connected: true,
source: "yaml",
units: { modbus_hub: [10, 11] },
},
];
export const mockModbus = (hass: MockHomeAssistant) => {
+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;
}
+2
View File
@@ -10,6 +10,7 @@ import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_eleme
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../../src/data/kiosk_mode";
import "../../src/components/ha-button";
import "../../src/components/ha-drawer";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
@@ -633,6 +634,7 @@ class HaGallery extends LitElement {
floors: {},
hassUrl: (path) => path,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
language: "en",
loadBackendTranslation: async () => galleryLocalize,
loadFragmentTranslation: async () => undefined,
@@ -57,6 +57,7 @@ This component is based on the webawesome input component.
| invalid | Boolean | false | Marks the input as invalid. |
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
| input-id | String | - | Sets the native input's `id` and associated label `for` on the inner `wa-input`. Defaults to `"input"`. |
| pattern | String | - | A regular expression pattern to validate input against. |
| minlength | Number | - | The minimum length of input that will be considered valid. |
| maxlength | Number | - | The maximum length of input that will be considered valid. |
+257 -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",
@@ -320,6 +385,13 @@ const SCHEMAS: {
},
duration: { name: "Duration", selector: { duration: {} } },
app: { name: "App", selector: { app: {} } },
serial_port: {
name: "Serial port",
selector: { serial_port: {} },
// A config flow passes its own domain as context, which is what the
// picker groups recommended and not recommended ports by
context: { domain: "zha" },
},
number_box: {
name: "Number Box",
selector: {
@@ -504,6 +576,99 @@ const SCHEMAS: {
},
},
},
addon: { name: "Add-on", selector: { addon: {} } },
areas_display: {
name: "Areas display",
selector: { areas_display: {} },
},
assist_pipeline: {
name: "Assist pipeline",
selector: { assist_pipeline: {} },
},
automation_behavior: {
name: "Automation behavior",
selector: { automation_behavior: { mode: "trigger" } },
},
backup_location: {
name: "Backup location",
selector: { backup_location: {} },
},
button_toggle: {
name: "Button toggle",
selector: {
button_toggle: {
options: [
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
{ label: "Right", value: "right" },
],
},
},
},
condition: { name: "Condition", selector: { condition: {} } },
conversation_agent: {
name: "Conversation agent",
selector: { conversation_agent: {} },
},
country: {
name: "Country",
selector: { country: { countries: ["DE", "GB", "NL", "US"] } },
},
entity_name: {
name: "Entity name",
selector: { entity_name: { entity_id: "light.bedroom" } },
},
file: {
name: "File",
selector: { file: { accept: "image/png,image/jpeg" } },
},
language: { name: "Language", selector: { language: {} } },
navigation: { name: "Navigation", selector: { navigation: {} } },
numeric_threshold: {
name: "Numeric threshold",
selector: { numeric_threshold: {} },
},
period: {
name: "Period",
selector: {
period: {
options: ["today", "yesterday", "this_week", "this_month"],
},
},
// Without a value that matches one of the options the selector offers
// its custom-period editor instead of the list
default: { calendar: { period: "day" } },
},
selector: {
name: "Selector",
selector: { selector: {} },
default: { text: {} },
},
state_class: { name: "State class", selector: { state_class: {} } },
statistic: { name: "Statistic", selector: { statistic: {} } },
stt: { name: "Speech-to-text", selector: { stt: {} } },
theme: { name: "Theme", selector: { theme: {} } },
timezone: { name: "Time zone", selector: { timezone: {} } },
trigger: { name: "Trigger", selector: { trigger: {} } },
tts: { name: "Text-to-speech", selector: { tts: {} } },
tts_voice: {
name: "Text-to-speech voice",
selector: { tts_voice: { engineId: "tts.cloud", language: "en-US" } },
},
ui_action: { name: "UI action", selector: { ui_action: {} } },
ui_clock_date_format: {
name: "Clock date format",
selector: { ui_clock_date_format: {} },
},
ui_color: { name: "UI color", selector: { ui_color: {} } },
ui_state_content: {
name: "State content",
selector: { ui_state_content: { entity_id: "light.bedroom" } },
},
ui_time_format: {
name: "Time format",
selector: { ui_time_format: {} },
},
},
},
{
@@ -605,8 +770,98 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
mockFloorRegistry(hass, FLOORS);
mockLabelRegistry(hass, LABELS);
mockHassioSupervisor(hass);
hass.addTranslations({
"component.matter.title": "Matter",
"component.zha.title": "Zigbee Home Automation",
});
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "usb"],
},
});
hass.mockWS("auth/sign_path", (params) => params);
hass.mockWS("media_player/browse_media", this._browseMedia);
hass.mockWS("usb/list_serial_ports", () => SERIAL_PORTS);
hass.mockWS("recorder/list_statistic_ids", () => [
{
statistic_id: "sensor.energy_consumption",
statistics_unit_of_measurement: "kWh",
source: "recorder",
name: null,
has_sum: true,
mean_type: StatisticMeanType.NONE,
unit_class: "energy",
},
{
statistic_id: "sensor.outside_temperature",
statistics_unit_of_measurement: "\u00b0C",
source: "recorder",
name: null,
has_sum: false,
mean_type: StatisticMeanType.ARITHMETIC,
unit_class: "temperature",
},
]);
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [
{
id: "pipeline_home",
name: "Home Assistant",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: "stt.cloud",
stt_language: "en-US",
tts_engine: "tts.cloud",
tts_language: "en-US",
tts_voice: "JennyNeural",
wake_word_entity: null,
wake_word_id: null,
},
],
preferred_pipeline: "pipeline_home",
}));
hass.mockWS("conversation/agent/list", () => ({
agents: [
{
id: "conversation.home_assistant",
name: "Home Assistant",
supported_languages: "*",
},
{
id: "conversation.openai",
name: "OpenAI Conversation",
supported_languages: ["en"],
},
],
}));
hass.mockWS("stt/engine/list", () => ({
providers: [
{
engine_id: "stt.cloud",
name: "Home Assistant Cloud",
supported_languages: ["en-US"],
deprecated: false,
},
],
}));
hass.mockWS("tts/engine/list", () => ({
providers: [
{
engine_id: "tts.cloud",
name: "Home Assistant Cloud",
supported_languages: ["en-US"],
deprecated: false,
},
],
}));
hass.mockWS("tts/engine/voices", () => ({
voices: [
{ voice_id: "JennyNeural", name: "Jenny" },
{ voice_id: "GuyNeural", name: "Guy" },
],
}));
}
public provideHass(el) {
@@ -776,6 +1031,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
<ha-selector
.hass=${this.hass}
.selector=${value!.selector}
.context=${value!.context}
.key=${key}
.label=${this._label ? value!.name : undefined}
.value=${data[key] ?? value!.default}
+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",
+10 -11
View File
@@ -39,7 +39,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.0",
"@babel/runtime": "8.0.5",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.0",
@@ -67,7 +67,7 @@
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@home-assistant/webawesome": "3.7.0-ha.1",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -84,7 +84,6 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@vibrant/color": "4.0.4",
@@ -115,7 +114,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.11",
"marked": "18.0.12",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -139,10 +138,10 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.1",
"@babel/core": "8.0.5",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.2",
"@babel/preset-env": "8.0.5",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
@@ -153,7 +152,7 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.2.2",
"@rspack/core": "2.2.3",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -195,10 +194,10 @@
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"jszip": "3.10.1",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.5.0",
"lint-staged": "17.5.1",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -213,8 +212,8 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.69.0",
"vite": "8.2.2",
"typescript-eslint": "8.70.0",
"vite": "8.3.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.0",
"webpack-stats-plugin": "1.1.3",
+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;
};
@@ -12,13 +12,24 @@ import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: "entity" | "device" | "area" | "floor";
type: EntityNameType;
}
| {
type: "text";
@@ -91,7 +102,7 @@ export const computeEntityNameList = (
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, area, floor } = getEntityContext(
const { device, parentDevice, area, floor } = getEntityContext(
stateObj,
entities,
devices,
@@ -105,6 +116,8 @@ export const computeEntityNameList = (
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "parent_device":
return parentDevice ? computeDeviceName(parentDevice) : undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
@@ -136,14 +149,20 @@ export const computeEntityPickerDisplay = (
>,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const isRTL = computeRTL(
hass.language,
@@ -152,7 +171,7 @@ export const computeEntityPickerDisplay = (
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
[areaName, entityName ? deviceName : undefined]
[areaName, parentDeviceName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
interface EntityContext {
entity: EntityRegistryDisplayEntry | null;
device: DeviceRegistryEntry | null;
parentDevice: DeviceRegistryEntry | null;
area: AreaRegistryEntry | null;
floor: FloorRegistryEntry | null;
}
@@ -31,6 +32,7 @@ export const getEntityContext = (
return {
entity: null,
device: null,
parentDevice: null,
area: null,
floor: null,
};
@@ -65,6 +67,9 @@ export const getEntityEntryContext = (
const entity = entities[entry.entity_id];
const deviceId = entry?.device_id;
const device = deviceId ? devices[deviceId] : undefined;
const parentDevice = device?.parent_device_id
? devices[device.parent_device_id]
: undefined;
const areaId =
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
const area = areaId ? areas[areaId] : undefined;
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
return {
entity: entity,
device: device || null,
parentDevice: parentDevice || null,
area: area || null,
floor: floor || null,
};
+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":
-2
View File
@@ -31,8 +31,6 @@ export type FormatEntityAttributeNameFunc = (
attribute: string
) => string;
export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
@@ -0,0 +1,48 @@
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../data/translation";
import { translationMetadata } from "../../resources/translations-metadata";
import type { HomeAssistantInternationalization } from "../../types";
import type { LocalizeFunc } from "./localize";
/**
* Builds an internationalization context value for the "lite" localize mode
* used before a `hass` object exists. Locale falls back to browser defaults and
* the lazy loaders are stubs that warn when called.
*/
export const buildLiteInternationalization = (
language: string,
localize: LocalizeFunc
): HomeAssistantInternationalization => ({
language,
selectedLanguage: language,
locale: {
language,
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
},
localize,
translationMetadata,
loadBackendTranslation: async (category, integrations, configFlow) => {
// eslint-disable-next-line no-console
console.warn(
"loadBackendTranslation called in lite i18n mode; backend strings are not available",
{ category, integrations, configFlow }
);
return localize;
},
loadFragmentTranslation: async (fragment) => {
// eslint-disable-next-line no-console
console.warn(
`loadFragmentTranslation("${fragment}") called in lite i18n mode; fragment not loaded`
);
return undefined;
},
});
@@ -44,7 +44,8 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render each row's name inside the plot (under its bar) instead of in a
// left-hand category-label column. Opt-in; used by the history panel.
// left-hand category-label column. Opt-in; used by the history panel and
// history-graph card.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@@ -79,7 +80,9 @@ export class StateHistoryChartTimeline extends LitElement {
.options=${this._chartOptions}
.height=${`${
this.data.length *
(this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) +
(this.insideLabels && (this.chunked || this.showNames)
? ROW_HEIGHT_INSIDE_LABELS
: ROW_HEIGHT) +
GRID_BOTTOM
}px`}
.data=${this._chartData as HaECSeries}
+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
+11 -8
View File
@@ -7,9 +7,12 @@ import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import {
ENTITY_NAME_TYPES,
type EntityNameItem,
type EntityNameType,
} from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state";
import type { LocalizeKeys } from "../../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
</ha-combo-box-item>
`;
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
const formatOptionValue = (item: EntityNameItem) => {
if (item.type === "text" && item.text) {
@@ -170,6 +171,7 @@ export class HaEntityNamePicker extends LitElement {
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
no-sort
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
@@ -387,6 +389,7 @@ export class HaEntityNamePicker extends LitElement {
);
if (context.device) options.add("device");
if (context.parentDevice) options.add("parent_device");
if (context.area) options.add("area");
if (context.floor) options.add("floor");
return options;
@@ -399,8 +402,8 @@ export class HaEntityNamePicker extends LitElement {
const types = this._validTypes(entityId);
const items = (
["entity", "device", "area", "floor"] as const
const items = ENTITY_NAME_TYPES.filter(
(name) => name !== "parent_device" || types.has(name)
).map<PickerComboBoxItem>((name) => {
const stateObj = this.hass.states[entityId];
const isValid = types.has(name);
@@ -464,7 +467,7 @@ export class HaEntityNamePicker extends LitElement {
const excludedValues = new Set(
this._items
.filter((item) => UNIQUE_TYPES.has(item.type))
.filter((item) => KNOWN_TYPES.has(item.type))
.map((item) => formatOptionValue(item))
);
@@ -178,6 +178,17 @@ export class HaStateContentPicker extends LitElement {
),
});
}
if (context.parentDevice) {
contextItems.push({
id: "parent_device_name",
primary: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
});
}
if (context.area) {
contextItems.push({
id: "area_name",
+41 -18
View File
@@ -52,6 +52,7 @@ const SEARCH_KEYS = [
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.parentDeviceName", weight: 6 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "statisticId", weight: 3 },
@@ -292,17 +293,27 @@ export class HaStatisticPicker extends LitElement {
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -318,6 +329,7 @@ export class HaStatisticPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
@@ -395,14 +407,20 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
@@ -410,7 +428,11 @@ export class HaStatisticPicker extends LitElement {
);
const primary = entityName || deviceName || statisticId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
@@ -427,6 +449,7 @@ export class HaStatisticPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
+19 -9
View File
@@ -197,17 +197,27 @@ export class HaAreaControlsPicker extends LitElement {
return;
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
+12
View File
@@ -761,6 +761,9 @@ export class HaCodeEditor extends ReactiveElement {
const deviceName = context.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context.area ? computeAreaName(context.area) : undefined;
const floorName = context.floor
? computeFloorName(context.floor)
@@ -795,6 +798,15 @@ export class HaCodeEditor extends ReactiveElement {
});
}
if (parentDeviceName) {
completionItems.push({
label: this._i18n!.localize(
"ui.components.device-picker.parent_device"
),
value: parentDeviceName,
});
}
if (areaName) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
+50 -32
View File
@@ -103,69 +103,87 @@ export class HaDurationInput extends LitElement {
`;
}
// Fold units we do not render into the smallest unit we do, so a value the
// user cannot see is not silently dropped on the next edit.
private get _data(): HaDurationData | undefined {
if (!this.data) {
return this.data;
}
const data = { ...this.data };
if (!this.enableDay && data.days) {
data.hours = (data.hours || 0) + data.days * 24;
delete data.days;
}
if (!this.enableMillisecond && data.milliseconds) {
data.seconds = (data.seconds || 0) + data.milliseconds / 1000;
delete data.milliseconds;
}
return data;
}
private get _negative() {
return (
this._toggleNegative ||
(this.data?.days
? this.data.days < 0
: this.data?.hours
? this.data.hours < 0
: this.data?.minutes
? this.data.minutes < 0
: this.data?.seconds
? this.data.seconds < 0
: this.data?.milliseconds
? this.data.milliseconds < 0
(this._data?.days
? this._data.days < 0
: this._data?.hours
? this._data.hours < 0
: this._data?.minutes
? this._data.minutes < 0
: this._data?.seconds
? this._data.seconds < 0
: this._data?.milliseconds
? this._data.milliseconds < 0
: false)
);
}
private get _days() {
return this.data?.days
return this._data?.days
? this.allowNegative
? Math.abs(Number(this.data.days))
: Number(this.data.days)
: this.required || this.data
? Math.abs(Number(this._data.days))
: Number(this._data.days)
: this.required || this._data
? 0
: NaN;
}
private get _hours() {
return this.data?.hours
return this._data?.hours
? this.allowNegative
? Math.abs(Number(this.data.hours))
: Number(this.data.hours)
: this.required || this.data
? Math.abs(Number(this._data.hours))
: Number(this._data.hours)
: this.required || this._data
? 0
: NaN;
}
private get _minutes() {
return this.data?.minutes
return this._data?.minutes
? this.allowNegative
? Math.abs(Number(this.data.minutes))
: Number(this.data.minutes)
: this.required || this.data
? Math.abs(Number(this._data.minutes))
: Number(this._data.minutes)
: this.required || this._data
? 0
: NaN;
}
private get _seconds() {
return this.data?.seconds
return this._data?.seconds
? this.allowNegative
? Math.abs(Number(this.data.seconds))
: Number(this.data.seconds)
: this.required || this.data
? Math.abs(Number(this._data.seconds))
: Number(this._data.seconds)
: this.required || this._data
? 0
: NaN;
}
private get _milliseconds() {
return this.data?.milliseconds
return this._data?.milliseconds
? this.allowNegative
? Math.abs(Number(this.data.milliseconds))
: Number(this.data.milliseconds)
: this.required || this.data
? Math.abs(Number(this._data.milliseconds))
: Number(this._data.milliseconds)
: this.required || this._data
? 0
: NaN;
}
@@ -236,8 +254,8 @@ export class HaDurationInput extends LitElement {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
if (this.data) {
const value = { ...this.data };
if (this._data) {
const value = { ...this._data };
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
+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")
+16
View File
@@ -0,0 +1,16 @@
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import { customElement, property } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import { HaSvgIcon } from "./ha-svg-icon";
@customElement("ha-icon-arrow-next")
export class HaIconArrowNext extends HaSvgIcon {
@property() public override path =
mainWindow.document.dir === "rtl" ? mdiArrowLeft : mdiArrowRight;
}
declare global {
interface HTMLElementTagNameMap {
"ha-icon-arrow-next": HaIconArrowNext;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
import { customElement, property } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import { HaSvgIcon } from "./ha-svg-icon";
@customElement("ha-icon-arrow-prev")
export class HaIconArrowPrev extends HaSvgIcon {
@property() public override path =
mainWindow.document.dir === "rtl" ? mdiArrowRight : mdiArrowLeft;
}
declare global {
interface HTMLElementTagNameMap {
"ha-icon-arrow-prev": HaIconArrowPrev;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { mdiChevronDoubleLeft, mdiChevronDoubleRight } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import "./ha-icon-button";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
@customElement("ha-icon-button-first")
export class HaIconButtonFirst extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@property() href?: string;
@property() target?: "_blank" | "_parent" | "_self" | "_top";
@property() rel?: string;
@property() download?: string;
@state() private _icon =
mainWindow.document.dir === "rtl"
? mdiChevronDoubleRight
: mdiChevronDoubleLeft;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
return html`
<ha-icon-button
.disabled=${this.disabled}
.label=${this.label || this._localize("ui.common.first") || "First"}
.path=${this._icon}
.href=${this.href}
.target=${this.target}
.rel=${this.rel}
.download=${this.download}
></ha-icon-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-icon-button-first": HaIconButtonFirst;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { mdiChevronDoubleLeft, mdiChevronDoubleRight } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import "./ha-icon-button";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
@customElement("ha-icon-button-last")
export class HaIconButtonLast extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@property() href?: string;
@property() target?: "_blank" | "_parent" | "_self" | "_top";
@property() rel?: string;
@property() download?: string;
@state() private _icon =
mainWindow.document.dir === "rtl"
? mdiChevronDoubleLeft
: mdiChevronDoubleRight;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
return html`
<ha-icon-button
.disabled=${this.disabled}
.label=${this.label || this._localize("ui.common.last") || "Last"}
.path=${this._icon}
.href=${this.href}
.target=${this.target}
.rel=${this.rel}
.download=${this.download}
></ha-icon-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-icon-button-last": HaIconButtonLast;
}
}
@@ -28,6 +28,11 @@ const LAWN_MOWER_ACTIONS: Partial<
service: "start_mowing",
feature: LawnMowerEntityFeature.START_MOWING,
},
idle: {
action: "start_mowing",
service: "start_mowing",
feature: LawnMowerEntityFeature.START_MOWING,
},
returning: {
action: "pause",
service: "pause",
+10 -3
View File
@@ -65,7 +65,9 @@ class HaMenuButton extends LitElement {
}
const hasNotifications =
this._hasNotifications &&
(this._narrow || this._ui.dockedSidebar === "always_hidden");
(this._narrow ||
this._ui.dockedSidebar === "always_hidden" ||
this._ui.kioskElementsHidden.has("sidebar"));
return html`
<ha-icon-button
.label=${this._localize("ui.sidebar.sidebar_toggle")}
@@ -92,9 +94,14 @@ class HaMenuButton extends LitElement {
this._unsubNotifications = undefined;
}
// A hidden sidebar is only reachable as an overlay, so it needs this
// button on wide screens too, unless the button itself is hidden.
const showButton =
this._ui?.kioskMode === false &&
(this._narrow || this._ui.dockedSidebar === "always_hidden");
!!this._ui &&
!this._ui.kioskElementsHidden.has("sidebar_button") &&
(this._narrow ||
this._ui.dockedSidebar === "always_hidden" ||
this._ui.kioskElementsHidden.has("sidebar"));
this._show = showButton || this._alwaysVisible;
@@ -45,8 +45,11 @@ export class HaChooseSelector extends LitElement {
}
if (
changedProperties.has("value") &&
changedProperties.get("value")?.active_choice &&
changedProperties.get("value")?.active_choice !== this._activeChoice
typeof this.value === "object" &&
this.value !== null &&
"active_choice" in this.value &&
this.value.active_choice in this.selector.choose.choices &&
this.value.active_choice !== this._activeChoice
) {
this._setActiveChoice();
}
@@ -150,14 +153,14 @@ export class HaChooseSelector extends LitElement {
if (this.value === null || this.value === undefined) {
return undefined;
}
return typeof this.value === "object"
return typeof this.value === "object" && "active_choice" in this.value
? this.value[choice || this.value.active_choice]
: this.value;
}
private _setActiveChoice() {
if (this.value) {
if (typeof this.value === "object") {
if (typeof this.value === "object" && "active_choice" in this.value) {
if (this.value.active_choice in this.selector.choose.choices) {
this._activeChoice = this.value.active_choice;
return;
@@ -199,6 +202,22 @@ export class HaChooseSelector extends LitElement {
];
return;
}
if (
typeofValue === "object" &&
!Array.isArray(this.value) &&
Object.keys(this.value).length > 0 &&
Object.keys(this.value).every((key) =>
["days", "hours", "minutes", "seconds", "milliseconds"].includes(
key
)
) &&
selectorTypes.includes("duration")
) {
this._activeChoice = Object.keys(this.selector.choose.choices)[
selectorTypes.indexOf("duration")
];
return;
}
}
}
this._activeChoice = Object.keys(this.selector.choose.choices)[0];
@@ -4,6 +4,7 @@ import memoizeOne from "memoize-one";
import type { DurationSelector } from "../../data/selector";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
import { createDurationData } from "../../common/datetime/create_duration_data";
@customElement("ha-selector-duration")
export class HaTimeDuration extends LitElement {
@@ -27,33 +28,8 @@ export class HaTimeDuration extends LitElement {
}
private _data = memoizeOne(
(value?: HaDurationData | string | number): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
}
(value?: HaDurationData | string | number): HaDurationData | undefined =>
createDurationData(value)
);
protected render() {
+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`
+4
View File
@@ -1327,6 +1327,9 @@ export class HaMap extends ReactiveElement {
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
border-bottom-color: rgba(80, 80, 80, 0.9) !important;
}
.maplibregl-ctrl-bottom-left {
direction: ltr;
}
.dark .leaflet-bar a {
background-color: #1c1c1c;
color: #ffffff;
@@ -1377,6 +1380,7 @@ export class HaMap extends ReactiveElement {
transparent
) !important;
text-shadow: none !important;
direction: ltr;
}
/* the theme tokens follow the page, so forced modes need the opposite values */
#map.forced-light .leaflet-control-scale-line {
@@ -129,21 +129,31 @@ export class HaMediaPlayerPicker extends LitElement {
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._entities,
this._devices,
this._areas,
this._floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -157,6 +167,7 @@ export class HaMediaPlayerPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
@@ -62,17 +62,27 @@ class HaMediaPlayerToggle extends LitElement {
isRTL: boolean,
stateObj: HassEntity
) => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
entities,
devices,
areas,
floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -0,0 +1,181 @@
import { getDeviceAreaId } from "../../common/entity/context/get_device_context";
import type {
ExtractFromTargetResultReferenced,
TargetType,
} from "../../data/target";
import type { HomeAssistant } from "../../types";
export interface TargetSubRows {
nextType: TargetType;
rows: string[];
rowEntries?: ExtractFromTargetResultReferenced[];
deviceRows: string[];
deviceRowEntries?: ExtractFromTargetResultReferenced[];
entityRows: string[];
}
const emptyEntries = (): ExtractFromTargetResultReferenced => ({
referenced_areas: [],
referenced_devices: [],
referenced_entities: [],
});
export const computeTargetSubRows = (
type: TargetType,
itemId: string,
entries: ExtractFromTargetResultReferenced,
entityRegistry: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): TargetSubRows => {
let nextType: TargetType =
type === "floor" ? "area" : type === "area" ? "device" : "entity";
if (type === "label") {
if (entries.referenced_areas.length) {
nextType = "area";
} else if (entries.referenced_devices.length) {
nextType = "device";
}
}
const deviceOf = (entityId: string) => entityRegistry[entityId]?.device_id;
const parentOf = (deviceId: string) => devices[deviceId]?.parent_device_id;
// An entity belongs to a device row when it is on that device or on one of
// its child devices, so the row can nest the children below it.
const belongsTo = (entityId: string, deviceId: string) => {
const entityDevice = deviceOf(entityId);
return (
entityDevice === deviceId ||
(!!entityDevice && parentOf(entityDevice) === deviceId)
);
};
const entitiesOf = (deviceId: string) =>
entries.referenced_entities.filter((entityId) =>
belongsTo(entityId, deviceId)
);
const rootsOf = (deviceIds: string[]) =>
deviceIds.filter((deviceId) => {
const parentId = parentOf(deviceId);
return !parentId || !deviceIds.includes(parentId);
});
const childDevices =
type === "device"
? [
...new Set(
entries.referenced_entities
.map(deviceOf)
.filter(
(deviceId): deviceId is string =>
!!deviceId &&
deviceId !== itemId &&
parentOf(deviceId) === itemId
)
),
]
: [];
const rows =
nextType === "area"
? entries.referenced_areas
: nextType === "device" && type !== "label"
? rootsOf(entries.referenced_devices)
: type === "device"
? entries.referenced_entities.filter(
(entityId) => !childDevices.includes(deviceOf(entityId) || "")
)
: type !== "label"
? entries.referenced_entities
: [];
const devicesInAreas: string[] = [];
const rowEntries =
nextType === "entity"
? undefined
: rows.map((rowItem) => {
const nextEntries = emptyEntries();
if (nextType === "area") {
const areaDevices = entries.referenced_devices.filter(
(deviceId) => {
const device = devices[deviceId];
return (
!!device &&
getDeviceAreaId(device, devices) === rowItem &&
entries.referenced_entities.some((entityId) =>
belongsTo(entityId, deviceId)
)
);
}
);
devicesInAreas.push(...areaDevices);
nextEntries.referenced_devices = rootsOf(areaDevices);
nextEntries.referenced_entities =
entries.referenced_entities.filter((entityId) => {
const entity = entityRegistry[entityId];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
areaDevices.includes(entity.device_id)
);
});
return nextEntries;
}
nextEntries.referenced_entities = entitiesOf(rowItem);
return nextEntries;
});
const entityRows =
type === "label"
? entries.referenced_entities.filter((entityId) => {
const entity = entityRegistry[entityId];
if (!entity) {
return false;
}
return (
entity.labels.includes(itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
);
})
: nextType === "device"
? entries.referenced_entities.filter(
(entityId) => entityRegistry[entityId]?.area_id === itemId
)
: [];
const deviceRows =
type === "label"
? rootsOf(
entries.referenced_devices.filter(
(deviceId) =>
!devicesInAreas.includes(deviceId) &&
devices[deviceId]?.labels.includes(itemId)
)
)
: childDevices;
const deviceRowEntries = deviceRows.length
? deviceRows.map((deviceId) => ({
...emptyEntries(),
referenced_entities: entitiesOf(deviceId),
}))
: undefined;
return {
nextType,
rows,
rowEntries,
deviceRows,
deviceRowEntries,
entityRows,
};
};
@@ -1,7 +1,5 @@
import { consume } from "@lit/context";
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiDevices,
mdiHome,
@@ -21,7 +19,6 @@ import {
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import {
@@ -30,6 +27,10 @@ import {
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import {
getDeviceArea,
getDeviceAreaId,
} from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
@@ -59,10 +60,12 @@ import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-button";
import "../ha-icon-button";
import "../ha-icon-next";
import "../ha-state-icon";
import "../ha-svg-icon";
import "../item/ha-list-item-base";
import "../item/ha-list-item-button";
import { computeTargetSubRows } from "./compute-target-sub-rows";
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
@customElement("ha-target-picker-item-row")
@@ -132,10 +135,35 @@ export class HaTargetPickerItemRow extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
private _loadedConfigEntryId?: string;
protected willUpdate(changedProps: PropertyValues<this>) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
}
if (
changedProps.has("itemId") ||
changedProps.has("type") ||
changedProps.has("hass")
) {
this._updateDomain();
}
}
private _updateDomain() {
if (this.type === "entity") {
this._setDomainName(computeDomain(this.itemId));
return;
}
if (this.type !== "device") {
return;
}
const configEntryId =
this.hass.devices?.[this.itemId]?.primary_config_entry;
if (configEntryId && configEntryId !== this._loadedConfigEntryId) {
this._loadedConfigEntryId = configEntryId;
this._getDeviceDomain(configEntryId);
}
}
protected render() {
@@ -288,19 +316,7 @@ export class HaTargetPickerItemRow extends LitElement {
></ha-icon-button>
`
: this.subEntry && this.type === "entity"
? html`
<ha-svg-icon
.path=${
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? mdiChevronLeft
: mdiChevronRight
}
slot="end"
></ha-svg-icon>
`
? html` <ha-icon-next slot="end"></ha-icon-next> `
: nothing
}
`;
@@ -376,125 +392,25 @@ export class HaTargetPickerItemRow extends LitElement {
return this._renderEmptyEntries();
}
let nextType: TargetType =
this.type === "floor"
? "area"
: this.type === "area"
? "device"
: "entity";
if (this.type === "label") {
if (entries?.referenced_areas.length) {
nextType = "area";
} else if (entries?.referenced_devices.length) {
nextType = "device";
}
}
const rows1 =
(nextType === "area"
? entries?.referenced_areas
: nextType === "device" && this.type !== "label"
? entries?.referenced_devices
: this.type !== "label"
? entries?.referenced_entities
: []) || [];
const devicesInAreas = [] as string[];
const rows1Entries =
nextType === "entity"
? undefined
: rows1.map((rowItem) => {
const nextEntries = {
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities: [] as string[],
};
if (nextType === "area") {
nextEntries.referenced_devices =
entries?.referenced_devices.filter(
(device_id) =>
this.hass.devices?.[device_id]?.area_id === rowItem &&
entries?.referenced_entities.some(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
)
) || ([] as string[]);
devicesInAreas.push(...nextEntries.referenced_devices);
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
nextEntries.referenced_devices.includes(entity.device_id)
);
}) || ([] as string[]);
return nextEntries;
}
nextEntries.referenced_entities =
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === rowItem
) || ([] as string[]);
return nextEntries;
});
const entityRows =
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
);
})
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id]?.area_id === this.itemId
)
: [];
const deviceRows =
this.type === "label" && entries
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id]?.labels.includes(this.itemId)
)
: [];
const deviceRowsEntries =
deviceRows.length === 0
? undefined
: deviceRows.map((device_id) => ({
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities:
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
) || ([] as string[]),
}));
const {
nextType,
rows,
rowEntries,
deviceRows,
deviceRowEntries,
entityRows,
} = computeTargetSubRows(
this.type,
this.itemId,
entries,
this.hass.entities,
this.hass.devices
);
const nextSubLevel = this.subLevel + 1;
return html`
${rows1.map(
${rows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
@@ -503,7 +419,7 @@ export class HaTargetPickerItemRow extends LitElement {
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.parentEntries=${rowEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
@@ -518,7 +434,7 @@ export class HaTargetPickerItemRow extends LitElement {
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.parentEntries=${deviceRowEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
@@ -599,32 +515,53 @@ export class HaTargetPickerItemRow extends LitElement {
let referencedDevices = entries.referenced_devices;
const hiddenDeviceIds: string[] = [];
// Parents kept only so a matching child device can nest under them;
// their own entities stay filtered out like a hidden device's.
const groupOnlyDeviceIds = new Set<string>();
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
const matchingDeviceIds = new Set(
referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
return (
!!device &&
!hiddenAreaIds.includes(
getDeviceAreaId(device, this.hass.devices) || ""
) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
);
})
);
const parentsOfMatching = new Set(
[...matchingDeviceIds].map(
(device_id) => this.hass.devices[device_id].parent_device_id
)
);
referencedDevices = referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, like the area filtering above.
if (!this.hass.devices[device_id]) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
if (matchingDeviceIds.has(device_id)) {
return true;
}
if (parentsOfMatching.has(device_id)) {
groupOnlyDeviceIds.add(device_id);
return true;
}
hiddenDeviceIds.push(device_id);
return false;
});
@@ -638,7 +575,10 @@ export class HaTargetPickerItemRow extends LitElement {
if (!entity) {
return false;
}
if (hiddenDeviceIds.includes(entity.device_id || "")) {
if (
hiddenDeviceIds.includes(entity.device_id || "") ||
groupOnlyDeviceIds.has(entity.device_id || "")
) {
return false;
}
if (
@@ -674,7 +614,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
}
private _itemData = memoizeOne((type: TargetType, item: string) => {
private _itemData(type: TargetType, item: string) {
if (type === "floor") {
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
return {
@@ -697,9 +637,25 @@ export class HaTargetPickerItemRow extends LitElement {
if (type === "device") {
const device: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
const context = [
area ? computeAreaName(area) : undefined,
parentDevice ? computeDeviceName(parentDevice) : undefined,
]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
return {
name: device
@@ -709,19 +665,17 @@ export class HaTargetPickerItemRow extends LitElement {
this.hass.states
)
: item,
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
context,
fallbackIconPath: mdiDevices,
notFound: !device,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(item));
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device } = stateObject
const { area, device, parentDevice } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
@@ -729,10 +683,17 @@ export class HaTargetPickerItemRow extends LitElement {
this.hass.areas,
this.hass.floors
)
: { area: undefined, device: undefined };
: { area: undefined, device: undefined, parentDevice: undefined };
const deviceName = device ? computeDeviceName(device) : undefined;
const parentDeviceName = parentDevice
? computeDeviceName(parentDevice)
: undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(
computeRTL(
@@ -760,7 +721,7 @@ export class HaTargetPickerItemRow extends LitElement {
fallbackIconPath: mdiLabel,
notFound: !label,
};
});
}
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
+2
View File
@@ -115,6 +115,7 @@ const updateUi = (
value.panelUrl !== hass.panelUrl ||
value.dockedSidebar !== hass.dockedSidebar ||
value.kioskMode !== hass.kioskMode ||
value.kioskElementsHidden !== hass.kioskElementsHidden ||
value.enableShortcuts !== hass.enableShortcuts ||
value.vibrate !== hass.vibrate ||
value.suspendWhenHidden !== hass.suspendWhenHidden
@@ -126,6 +127,7 @@ const updateUi = (
panelUrl: hass.panelUrl,
dockedSidebar: hass.dockedSidebar,
kioskMode: hass.kioskMode,
kioskElementsHidden: hass.kioskElementsHidden,
enableShortcuts: hass.enableShortcuts,
vibrate: hass.vibrate,
suspendWhenHidden: hass.suspendWhenHidden,
+24 -9
View File
@@ -31,6 +31,10 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.deviceName",
weight: 7,
},
{
name: "search_labels.parentDeviceName",
weight: 6,
},
{
name: "search_labels.areaName",
weight: 6,
@@ -129,14 +133,20 @@ export const getEntities = (
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
@@ -146,7 +156,11 @@ export const getEntities = (
}
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -159,6 +173,7 @@ export const getEntities = (
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
+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",
];
+132
View File
@@ -0,0 +1,132 @@
/**
* The parts of the UI a kiosk client can hide.
*
* A client names them over the external bus (`kiosk_mode/set`) as either
* `excluded_elements` (hide exactly these) or `included_elements` (hide
* everything except these), so the same set can be described from either side.
*/
export const KIOSK_ELEMENTS = [
/** The sidebar itself: never docked, only reachable as an overlay. */
"sidebar",
/**
* The hamburger button that opens the sidebar, in every panel's toolbar.
* In the app panel it lives in `app_panel_header`, so hiding that header
* hides this button too, whatever this element is set to.
*/
"sidebar_button",
/**
* The dashboard view tabs. The current view's title is shown instead.
* Only outside edit mode: in edit mode the tabs are how views are selected,
* moved, edited and added, so they stay. Hide `dashboard_edit_button` as well
* to keep that button from being a way back to them.
*/
"dashboard_tabs",
/** The "+" menu that adds a device, automation, area or person. */
"dashboard_add_button",
/** The search button that opens the quick bar. */
"dashboard_search_button",
/** The Assist button. */
"dashboard_assist_button",
/** The pencil that switches the dashboard into edit mode. */
"dashboard_edit_button",
/**
* The header the app panel draws above an ingress add-on iframe, including
* its sidebar button. With it hidden the iframe takes the full height; a
* client can still open the sidebar with `sidebar/show` or `sidebar/toggle`.
*/
"app_panel_header",
] as const;
export type KioskElement = (typeof KIOSK_ELEMENTS)[number];
const KIOSK_ELEMENTS_SET: ReadonlySet<string> = new Set(KIOSK_ELEMENTS);
/**
* Nothing hidden. Shared instance so context consumers that compare by
* reference don't see a change every time kiosk mode is switched off.
*/
export const NO_KIOSK_ELEMENTS_HIDDEN: ReadonlySet<KioskElement> = new Set();
/**
* What a client gets when it enables kiosk mode without naming any elements.
* This is the set kiosk mode hid back when it was a single boolean, so a client
* that only sends `enable` keeps the behavior it has today.
*/
export const DEFAULT_KIOSK_ELEMENTS_HIDDEN: ReadonlySet<KioskElement> = new Set(
[
"sidebar",
"sidebar_button",
"dashboard_add_button",
"dashboard_search_button",
"dashboard_edit_button",
"app_panel_header",
]
);
/**
* Who asked for kiosk mode. Each source's request is kept on its own and the
* hidden elements are their union, so one source switching kiosk mode off
* doesn't undo what another still asks for.
*
* - `external_app`: the companion app, over `kiosk_mode/set`.
* - `app_panel`: an ingress add-on, via `home-assistant/subscribe-properties`.
*/
export type KioskModeSource = "external_app" | "app_panel";
export interface KioskModeParams {
enable: boolean;
/** Defaults to `external_app`. */
source?: KioskModeSource;
/** Hide exactly these. Mutually exclusive with `includedElements`. */
excludedElements?: readonly string[];
/** Hide everything except these. Mutually exclusive with `excludedElements`. */
includedElements?: readonly string[];
}
const isKioskElement = (value: string): value is KioskElement =>
KIOSK_ELEMENTS_SET.has(value);
/**
* Resolve a kiosk request into the elements to hide.
*
* Element names this frontend doesn't know are ignored rather than rejected, so
* a newer client naming an element an older frontend has never heard of still
* gets the rest of its request honored.
*/
export const resolveKioskElementsHidden = ({
enable,
excludedElements,
includedElements,
}: KioskModeParams): ReadonlySet<KioskElement> => {
if (!enable) {
return NO_KIOSK_ELEMENTS_HIDDEN;
}
if (excludedElements) {
return new Set(excludedElements.filter(isKioskElement));
}
if (includedElements) {
const shown = new Set(includedElements);
return new Set(KIOSK_ELEMENTS.filter((element) => !shown.has(element)));
}
return DEFAULT_KIOSK_ELEMENTS_HIDDEN;
};
/**
* Combine the elements each kiosk source hides into the set the UI hides.
*
* Returns a source's set unchanged when it is the only one hiding anything, and
* the shared empty set when none is, so context consumers comparing by
* reference only see a change when the combined result actually changes shape.
*/
export const combineKioskElementsHidden = (
requests: Iterable<ReadonlySet<KioskElement>>
): ReadonlySet<KioskElement> => {
const nonEmpty = [...requests].filter((request) => request.size > 0);
if (nonEmpty.length === 0) {
return NO_KIOSK_ELEMENTS_HIDDEN;
}
if (nonEmpty.length === 1) {
return nonEmpty[0];
}
return new Set(nonEmpty.flatMap((request) => [...request]));
};
+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 -1
View File
@@ -3,10 +3,17 @@ import type { HomeAssistant } from "../types";
/** Transport with host and port, or with the serial device path. */
export type ModbusEndpoint = [string, string, number] | [string, string];
/** Whether a connection belongs to a config entry or to a YAML hub. */
export type ModbusConnectionSource = "config_entry" | "yaml";
export interface ModbusConnection {
endpoint: ModbusEndpoint;
connected: boolean;
/** The unit IDs each config entry holds, keyed by config entry ID. */
source: ModbusConnectionSource;
/**
* The unit IDs each holder uses, keyed by config entry ID, or by hub name
* when `source` is `"yaml"`.
*/
units: Record<string, number[]>;
}
+9 -4
View File
@@ -1,5 +1,6 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
@@ -82,10 +83,7 @@ export const formatSelectorValue = (
if (!stateObj) {
return entityId;
}
const name = hass.formatEntityName(stateObj, [
{ type: "device" },
{ type: "entity" },
]);
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
return name || entityId;
})
.join(", ");
@@ -104,6 +102,13 @@ export const formatSelectorValue = (
.join(", ");
}
if ("media" in selector) {
const media = ensureArray(value);
return media
.map((item) => item.metadata?.title || item.media_content_id)
.join(", ");
}
if ("object" in selector) {
const { fields } = selector.object ?? {};
const items = ensureArray(value);
@@ -1,16 +1,14 @@
import { mdiArrowRight } from "@mdi/js";
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumericDuration } from "../../common/datetime/format_duration";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import "../../components/ha-icon-arrow-next";
import type { HttpConfig } from "../../data/http";
import {
HTTP_CONFIG_FIELDS,
@@ -158,10 +156,6 @@ export class DialogHttpPendingConfig
const changes = this._changedFields;
const { stable, pending } = this._params.state;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-dialog
@@ -233,12 +227,7 @@ export class DialogHttpPendingConfig
<span class="old"
>${this._formatValue(key, stable[key])}</span
>
<ha-svg-icon
.path=${mdiArrowRight}
style=${styleMap({
transform: rtl ? "scaleX(-1)" : "",
})}
></ha-svg-icon>
<ha-icon-arrow-next></ha-icon-arrow-next>
<span class="new"
>${this._formatValue(key, pending![key])}</span
>
@@ -403,7 +392,7 @@ export class DialogHttpPendingConfig
.values .new {
color: var(--primary-text-color);
}
.values ha-svg-icon {
.values ha-icon-arrow-next {
--mdc-icon-size: 18px;
flex-shrink: 0;
}
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { mdiHomeImportOutline, mdiPause, mdiPlay } from "@mdi/js";
import { mdiHomeImportOutline, mdiPause, mdiPlay, mdiStop } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -29,6 +29,7 @@ import {
LawnMowerEntityFeature,
canDock,
canStartMowing,
canStop,
isMowing,
} from "../../../data/lawn_mower";
import "../../../state-control/lawn_mower/ha-state-control-lawn_mower-status";
@@ -173,6 +174,14 @@ class MoreInfoLawnMower extends LitElement {
}
}
private _handleStop() {
if (!this.stateObj) return;
forwardHaptic(this, "light");
this._api.callService("lawn_mower", "stop", {
entity_id: this.stateObj.entity_id,
});
}
private _handleDock() {
if (!this.stateObj) return;
forwardHaptic(this, "light");
@@ -188,9 +197,11 @@ class MoreInfoLawnMower extends LitElement {
const stateObj = this.stateObj;
const isUnavailable = stateObj.state === UNAVAILABLE;
const supportsStop = supportsFeature(stateObj, LawnMowerEntityFeature.STOP);
const supportsDock = supportsFeature(stateObj, LawnMowerEntityFeature.DOCK);
const hasAnyCommand = this._supportsStartPause || supportsDock;
const hasAnyCommand =
this._supportsStartPause || supportsStop || supportsDock;
return html`
<ha-more-info-state-header .stateObj=${this.stateObj}>
@@ -222,6 +233,21 @@ class MoreInfoLawnMower extends LitElement {
`
: nothing
}
${
supportsStop
? html`
<ha-control-button
.label=${this._i18n.localize(
"ui.dialogs.more_info_control.lawn_mower.stop"
)}
@click=${this._handleStop}
.disabled=${isUnavailable || !canStop(stateObj)}
>
<ha-svg-icon .path=${mdiStop}></ha-svg-icon>
</ha-control-button>
`
: nothing
}
${
supportsDock
? html`
@@ -1,6 +1,5 @@
import { consume } from "@lit/context";
import {
mdiChevronRight,
mdiFan,
mdiHomeImportOutline,
mdiMapMarker,
@@ -25,6 +24,7 @@ import "../../../components/ha-control-select-menu";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-icon";
import "../../../components/ha-svg-icon";
import "../../../components/ha-icon-next";
import {
apiContext,
entitiesContext,
@@ -489,7 +489,7 @@ class MoreInfoVacuum extends LitElement {
</p>
</div>
<div class="icon">
<ha-svg-icon .path=${mdiChevronRight}></ha-svg-icon>
<ha-icon-next></ha-icon-next>
</div>
</button>
`
+9 -3
View File
@@ -591,11 +591,17 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context?.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const breadcrumb = [
areaName,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
+16 -1
View File
@@ -82,7 +82,7 @@ class HaMoreInfoRelated extends LitElement {
? (domain as ItemType)
: "entity";
const { floor, area, device } = getEntityContext(
const { floor, area, device, parentDevice } = getEntityContext(
this._stateObj,
this.hass.entities,
this.hass.devices,
@@ -91,6 +91,13 @@ class HaMoreInfoRelated extends LitElement {
);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(
parentDevice,
this.hass.localize,
this.hass.states
)
: undefined;
const deviceName = device
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
: undefined;
@@ -124,6 +131,14 @@ class HaMoreInfoRelated extends LitElement {
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
});
}
if (parentDevice && parentDeviceName) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.parent_device",
value: parentDeviceName,
href: `/config/devices/device/${parentDevice.id}`,
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
});
}
if (device && deviceName) {
contextEntries.push({
translationKey: "ui.components.related-items.device",
@@ -1,4 +1,4 @@
import { mdiChevronLeft, mdiMenuDown } from "@mdi/js";
import { mdiMenuDown } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -9,6 +9,7 @@ import { formatLanguageCode } from "../../common/language/format_language";
import "../../components/chips/ha-assist-chip";
import "../../components/ha-dialog";
import "../../components/ha-dropdown";
import "../../components/ha-icon-button-prev";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import { getLanguageOptions } from "../../components/ha-language-picker";
@@ -152,12 +153,11 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
hideNavigationIcon
? html`<span slot="headerNavigationIcon"></span>`
: this._previousSteps.length
? html`<ha-icon-button
? html`<ha-icon-button-prev
slot="headerNavigationIcon"
.label=${this.hass.localize("ui.common.back") ?? "Back"}
.path=${mdiChevronLeft}
@click=${this._goToPreviousStep}
></ha-icon-button>`
></ha-icon-button-prev>`
: nothing
}
${
+21 -1
View File
@@ -98,7 +98,27 @@ export const handleExternalMessage = (
} else if (msg.command === "bar_code/aborted") {
barCodeListeners.forEach((listener) => listener(msg));
} else if (msg.command === "kiosk_mode/set") {
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
const { enable, excluded_elements, included_elements } = msg.payload;
// The two lists are two ways of describing the same set, so a message
// carrying both has no single meaning we could act on.
if (excluded_elements && included_elements) {
bus.fireMessage({
id: msg.id,
type: "result",
success: false,
error: {
code: "invalid_format",
message:
"Pass either excluded_elements or included_elements, not both",
},
});
return true;
}
fireEvent(window, "hass-kiosk-mode", {
enable,
excludedElements: excluded_elements,
includedElements: included_elements,
});
} else {
return false;
}
+21
View File
@@ -325,8 +325,29 @@ export interface EMIncomingMessageKioskModeSet {
id: number;
type: "command";
command: "kiosk_mode/set";
/**
* Element names are listed in `KIOSK_ELEMENTS`. Two of them depend on
* another element:
* - `app_panel_header` holds the app panel's sidebar button, so hiding the
* header hides that button even when `sidebar_button` is not hidden.
* - `dashboard_tabs` is only hidden outside edit mode, where the tabs are the
* view editor; hide `dashboard_edit_button` too to keep them out of reach.
*/
payload: {
enable: boolean;
/**
* Hide exactly these parts of the UI. Mutually exclusive with
* `included_elements`; sending both fails the command. Plain strings, as a
* newer client may name elements this frontend doesn't know; those are
* ignored (see `resolveKioskElementsHidden`).
*/
excluded_elements?: string[];
/**
* Hide every part of the UI except these. Mutually exclusive with
* `excluded_elements`; sending both fails the command. Unknown names are
* ignored, as for `excluded_elements`.
*/
included_elements?: string[];
};
}
@@ -24,6 +24,11 @@ export class MockLawnMowerEntity extends MockBaseEntity {
return;
}
if (service === "stop") {
this.update({ state: "idle" });
return;
}
if (service === "dock") {
this._transition("returning", "docked", TRANSITION_MS);
return;
+2
View File
@@ -24,6 +24,7 @@ import {
} from "../data/context";
import { updateHassGroups } from "../data/context/updateContext";
import type { IconCategory } from "../data/icons";
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../data/kiosk_mode";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
import {
DateFormat,
@@ -464,6 +465,7 @@ export const provideHass = (
vibrate: true,
debugConnection: false,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
suspendWhenHidden: false,
// @ts-ignore
async callService(domain, service, data, target, _notify, returnResponse) {
+6 -1
View File
@@ -60,7 +60,9 @@ export class HaInitPage extends LitElement {
: nothing
}
`
: html`<p class=${classMap({ "loading-text": !this.migration })}>
: html`<p
class=${classMap({ "loading-text": !this.migration, rtl: document.dir === "rtl" })}
>
${
this.migration
? html`<span class="migration-text"
@@ -124,6 +126,9 @@ export class HaInitPage extends LitElement {
.loading-text {
opacity: 0.66;
}
.rtl {
direction: rtl;
}
`;
}
+7 -3
View File
@@ -51,7 +51,9 @@ export class HomeAssistantMain extends LitElement {
protected render(): TemplateResult {
const sidebarNarrow =
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode;
this._sidebarNarrow ||
this._externalSidebar ||
this.hass.kioskElementsHidden.has("sidebar");
const isPanelReady =
this.hass.panels && this.hass.userData && this.hass.systemData;
@@ -105,7 +107,7 @@ export class HomeAssistantMain extends LitElement {
});
return;
}
if (this._sidebarNarrow || this.hass.kioskMode) {
if (this._sidebarNarrow || this.hass.kioskElementsHidden.has("sidebar")) {
this._drawerOpen = ev.detail?.open ?? !this._drawerOpen;
} else {
fireEvent(this, "hass-dock-sidebar", {
@@ -144,7 +146,9 @@ export class HomeAssistantMain extends LitElement {
this.toggleAttribute(
"modal",
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode
this._sidebarNarrow ||
this._externalSidebar ||
this.hass.kioskElementsHidden.has("sidebar")
);
}
+17 -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>
+44 -20
View File
@@ -49,8 +49,6 @@ class HaPanelApp extends LitElement {
@state() private _loadingMessage?: string;
@state() private _kioskMode = false;
@state() private _iframeLoaded = false;
// Set when the addon signals (via subscribe-properties) that it handles the
@@ -83,11 +81,6 @@ class HaPanelApp extends LitElement {
) {
this._sendPropertiesToIframe();
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && oldHass.kioskMode !== this.hass.kioskMode) {
this._kioskMode = this.hass.kioskMode;
}
}
public connectedCallback() {
@@ -110,7 +103,10 @@ class HaPanelApp extends LitElement {
this._fetchDataTimeout = undefined;
}
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", { enable: false });
fireEvent(window, "hass-kiosk-mode", {
enable: false,
source: "app_panel",
});
}
}
@@ -121,18 +117,35 @@ class HaPanelApp extends LitElement {
></hass-loading-screen>`;
}
// The sidebar button only exists inside this header, so hiding the header
// hides the button too (documented on `app_panel_header`). Keeping it would
// need a floating control over the add-on, which kiosk mode never had.
const hideHeader = this.hass.kioskElementsHidden.has("app_panel_header");
const sidebarHidden = this.hass.kioskElementsHidden.has("sidebar");
const hideMenuButton = this.hass.kioskElementsHidden.has("sidebar_button");
// Make sure this all is 1 template so hiding toolbar doesn't reload iframe
return html`
${
!this._kioskMode &&
(this.narrow || this.hass.dockedSidebar === "always_hidden")
!hideHeader &&
(this.narrow ||
this.hass.dockedSidebar === "always_hidden" ||
sidebarHidden)
? html`
<div class="header">
<ha-icon-button
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
.path=${mdiMenu}
@click=${this._toggleMenu}
></ha-icon-button>
${
hideMenuButton
? nothing
: html`
<ha-icon-button
.label=${this.hass.localize(
"ui.sidebar.sidebar_toggle"
)}
.path=${mdiMenu}
@click=${this._toggleMenu}
></ha-icon-button>
`
}
<div class="main-title">${this._addon.name}</div>
</div>
`
@@ -141,7 +154,7 @@ class HaPanelApp extends LitElement {
<iframe
class=${classMap({
loaded: this._iframeLoaded,
"kiosk-mode": this._kioskMode,
"kiosk-mode": hideHeader,
"handle-safe-area": this._handleSafeArea,
})}
title=${this._addon.name}
@@ -183,7 +196,10 @@ class HaPanelApp extends LitElement {
this._iframeLoaded = false;
// Reset state when switching apps
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", { enable: false });
fireEvent(window, "hass-kiosk-mode", {
enable: false,
source: "app_panel",
});
this._enabledKioskMode = false;
}
this._iframeSubscribeUpdates = false;
@@ -429,9 +445,14 @@ class HaPanelApp extends LitElement {
// safe area itself; we then forward the inset values below.
this._handleSafeArea = !!data.handleSafeArea;
this._sendPropertiesToIframe();
if (data.kioskMode && !this.hass.kioskMode) {
// Kept apart from the companion app's own kiosk request, so it adds
// to that selection and leaves it in place when the addon lets go.
if (data.kioskMode && !this._enabledKioskMode) {
this._enabledKioskMode = true;
fireEvent(window, "hass-kiosk-mode", { enable: true });
fireEvent(window, "hass-kiosk-mode", {
enable: true,
source: "app_panel",
});
}
break;
@@ -439,7 +460,10 @@ class HaPanelApp extends LitElement {
this._iframeSubscribeUpdates = false;
this._handleSafeArea = false;
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", { enable: false });
fireEvent(window, "hass-kiosk-mode", {
enable: false,
source: "app_panel",
});
this._enabledKioskMode = false;
}
break;
+12
View File
@@ -82,6 +82,9 @@ export class HAFullCalendar extends LitElement {
@property({ attribute: "add-fab-style" }) public addFabStyle = "on_top";
@property({ attribute: "auto-height", type: Boolean }) public autoHeight =
false;
@property({ attribute: false }) public events: CalendarEvent[] = [];
@property({ attribute: false }) public calendars: CalendarData[] = [];
@@ -314,6 +317,10 @@ export class HAFullCalendar extends LitElement {
this.calendar!.setOption("eventDisplay", this.eventDisplay);
}
if (changedProps.has("autoHeight")) {
this.calendar.setOption("height", this._height);
}
const oldHass = changedProps.get("hass") as HomeAssistant;
if (oldHass && oldHass.language !== this.hass.language) {
@@ -345,6 +352,7 @@ export class HAFullCalendar extends LitElement {
: this.hass.config.time_zone,
firstDay: firstWeekdayIndex(this.hass.locale),
initialView,
height: this._height,
eventDisplay: this.eventDisplay,
eventTimeFormat: {
hour: useAmPm(this.hass.locale) ? "numeric" : "2-digit",
@@ -364,6 +372,10 @@ export class HAFullCalendar extends LitElement {
this._fireViewChanged();
}
private get _height(): CalendarOptions["height"] {
return this.autoHeight ? "auto" : defaultFullCalendarConfig.height;
}
// Return if there are calendars that support creating events
private get _hasMutableCalendars(): boolean {
return this.calendars.some((selCal) => {
@@ -175,8 +175,6 @@ class SupervisorAppsCardContent extends LitElement {
line-height: var(--ha-line-height-condensed);
}
.footer {
border-top: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-quiet);
padding-top: var(--ha-space-2);
display: flex;
gap: var(--ha-space-2);
@@ -1,7 +1,6 @@
import { startOfYesterday } from "date-fns";
import { consume } from "@lit/context";
import {
mdiChevronRight,
mdiDelete,
mdiDevices,
mdiDotsVertical,
@@ -39,6 +38,7 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-list";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
@@ -604,12 +604,11 @@ class HaConfigAreaPage extends LitElement {
back: "1",
})}"
>
<ha-icon-button
.path=${mdiChevronRight}
<ha-icon-button-next
.label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
></ha-icon-button>
></ha-icon-button-next>
</a>
</div>
<ha-logbook
@@ -13,9 +13,11 @@ import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { mainWindow } from "../../../common/dom/get_main_window";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { isNumericState } from "../../../common/number/format_number";
import { stringCompare } from "../../../common/string/compare";
import type {
@@ -901,6 +903,12 @@ class DialogAddAutomationElement
const [targetType, targetId] = this._extractTypeAndIdFromTarget(
this._selectedTarget
);
const separator = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ";
if (targetId) {
if (targetType === "area") {
@@ -913,11 +921,21 @@ class DialogAddAutomationElement
);
}
} else if (targetType === "device") {
const areaId = this.hass.devices[targetId]?.area_id;
if (areaId) {
subtitle = computeAreaName(this.hass.areas[areaId]) || areaId;
const device = this.hass.devices[targetId];
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
if (area) {
subtitle = [
computeAreaName(area) || area.area_id,
parentDevice ? computeDeviceName(parentDevice) : undefined,
]
.filter(Boolean)
.join(separator);
} else {
const device = this.hass.devices[targetId];
subtitle = this.hass.localize(
`ui.panel.config.automation.editor.${device?.entry_type === "service" ? "services" : "unassigned_devices"}`
);
@@ -933,25 +951,28 @@ class DialogAddAutomationElement
);
} else {
const stateObj = this.hass.states[targetId];
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
subtitle = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
subtitle = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(separator);
}
}
@@ -20,6 +20,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon";
@@ -30,10 +31,7 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/item/ha-row-item";
import "../../../../components/list/ha-list-base";
import {
getAreaDeviceLookup,
getAreaEntityLookup,
} from "../../../../data/area/area_registry";
import { getAreaEntityLookup } from "../../../../data/area/area_registry";
import {
getAreasNestedInFloors,
type AreaFloorValue,
@@ -51,7 +49,10 @@ import {
registriesContext,
statesContext,
} from "../../../../data/context";
import { getDeviceEntityLookup } from "../../../../data/device/device_registry";
import {
getDeviceEntityLookup,
type DeviceRegistryEntry,
} from "../../../../data/device/device_registry";
import {
domainToName,
type DomainManifestLookup,
@@ -83,6 +84,7 @@ interface Level2Entries {
interface Level3Entries {
open: boolean;
entities: string[];
devices?: Record<string, Level3Entries>;
}
@customElement("ha-automation-add-from-target")
@@ -281,23 +283,19 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (valueId && valueType === "device") {
const areaId = this._registries.devices[valueId]?.area_id;
if (areaId) {
const floorId = this._registries.areas[areaId]?.floor_id || "";
const { entities } =
entries[`floor${TARGET_SEPARATOR}${floorId}`].areas![
`area${TARGET_SEPARATOR}${areaId}`
].devices![valueId];
return entities.length ? this._renderEntities(entities) : nothing;
const entry = this._getDeviceEntry(entries, valueId);
if (!entry) {
return nothing;
}
const device = this._registries.devices[valueId];
const isService = device.entry_type === "service";
const { entities } =
entries[`${isService ? "service" : "area"}${TARGET_SEPARATOR}`]
.devices![valueId];
return entities.length ? this._renderEntities(entities) : nothing;
const numberOfDevices = Object.keys(entry.devices ?? {}).length;
return html`
${numberOfDevices ? this._renderDevices(entry.devices!) : nothing}
${
entry.entities.length
? this._renderEntities(entry.entities)
: nothing
}
`;
}
if (valueType === "device" || valueType === "helper") {
@@ -680,7 +678,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
stringCompare(deviceNameA, deviceNameB, this.hass.locale.language)
)
.map(([deviceId, deviceName, domain]) => {
const { open, entities } = devices[deviceId];
const { open, entities, devices: children } = devices[deviceId];
const numberOfChildren = Object.keys(children ?? {}).length;
return this._renderItem(
deviceName || deviceId,
@@ -688,10 +687,15 @@ export default class HaAutomationAddFromTarget extends LitElement {
false,
this._getSelectedTargetId(this.value) ===
`device${TARGET_SEPARATOR}${deviceId}`,
!open && !!entities.length,
!open && !!(entities.length || numberOfChildren),
open,
domain ? this._renderDomainIcon(domain) : undefined,
open ? this._renderEntities(entities) : undefined
open
? html`
${numberOfChildren ? this._renderDevices(children!) : nothing}
${this._renderEntities(entities)}
`
: undefined
);
});
@@ -899,8 +903,16 @@ export default class HaAutomationAddFromTarget extends LitElement {
// #region memoized data helpers
private _getAreaDeviceLookupMemoized = memoizeOne(
(devices: HomeAssistant["devices"]) =>
getAreaDeviceLookup(Object.values(devices))
(devices: HomeAssistant["devices"]) => {
const lookup: Record<string, DeviceRegistryEntry[]> = {};
for (const device of Object.values(devices)) {
const areaId = getDeviceAreaId(device, devices);
if (areaId) {
(lookup[areaId] ??= []).push(device);
}
}
return lookup;
}
);
private _getAreaEntityLookupMemoized = memoizeOne(
@@ -974,32 +986,18 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _loadUnassignedDevices() {
const unassignedDevices = Object.values(this._registries.devices).filter(
(device) => !device.area_id
(device) =>
!device.disabled_by &&
!getDeviceAreaId(device, this._registries.devices)
);
const devices: Record<string, Level3Entries> = {};
const devices = this._buildDeviceEntries(
unassignedDevices.filter((device) => device.entry_type !== "service")
);
const services: Record<string, Level3Entries> = {};
unassignedDevices.forEach(({ id: deviceId, entry_type }) => {
const device = this._registries.devices[deviceId];
if (!device || device.disabled_by) {
return;
}
const deviceEntry = {
open: false,
entities:
this._getDeviceEntityLookupMemoized(this._registries.entities)[
deviceId
]?.map((entity) => entity.entity_id) || [],
};
if (entry_type === "service") {
services[deviceId] = deviceEntry;
return;
}
devices[deviceId] = deviceEntry;
});
const services = this._buildDeviceEntries(
unassignedDevices.filter((device) => device.entry_type === "service")
);
if (Object.keys(devices).length) {
this._entries = {
@@ -1079,31 +1077,21 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _loadArea(area: FloorComboBoxItem) {
const [, id] = area.id.split(TARGET_SEPARATOR, 2);
const referenced_devices =
this._getAreaDeviceLookupMemoized(this._registries.devices)[id] || [];
const referenced_devices = (
this._getAreaDeviceLookupMemoized(this._registries.devices)[id] || []
).filter((device) => !device.disabled_by);
const referenced_entities =
this._getAreaEntityLookupMemoized(this._registries.entities)[id] || [];
const devices: Record<string, Level3Entries> = {};
referenced_devices.forEach(({ id: deviceId }) => {
const device = this._registries.devices[deviceId];
if (!device || device.disabled_by) {
return;
}
devices[deviceId] = {
open: false,
entities:
this._getDeviceEntityLookupMemoized(this._registries.entities)[
deviceId
]?.map((entity) => entity.entity_id) || [],
};
});
const devices = this._buildDeviceEntries(referenced_devices);
const areaDeviceIds = new Set(
referenced_devices.map((device) => device.id)
);
const entities: string[] = [];
referenced_entities.forEach((entity) => {
if (!entity.device_id || !devices[entity.device_id]) {
if (!entity.device_id || !areaDeviceIds.has(entity.device_id)) {
entities.push(entity.entity_id);
}
});
@@ -1115,6 +1103,160 @@ export default class HaAutomationAddFromTarget extends LitElement {
};
}
private _buildDeviceEntries(
devices: DeviceRegistryEntry[]
): Record<string, Level3Entries> {
const deviceEntityLookup = this._getDeviceEntityLookupMemoized(
this._registries.entities
);
const entryFor = (deviceId: string): Level3Entries => ({
open: false,
entities:
deviceEntityLookup[deviceId]?.map((entity) => entity.entity_id) || [],
});
const deviceIds = new Set(devices.map((device) => device.id));
const nested = (device: DeviceRegistryEntry) =>
!!device.parent_device_id && deviceIds.has(device.parent_device_id);
const roots = devices.filter((device) => !nested(device));
const children = devices.filter(nested);
const entries: Record<string, Level3Entries> = {};
for (const device of roots) {
entries[device.id] = entryFor(device.id);
}
for (const device of children) {
(entries[device.parent_device_id!].devices ??= {})[device.id] = entryFor(
device.id
);
}
return entries;
}
private _deviceGroupPath(
device: DeviceRegistryEntry
): [level1: string, level2?: string] {
const areaId = getDeviceAreaId(device, this._registries.devices);
if (areaId) {
return [
`floor${TARGET_SEPARATOR}${this._registries.areas[areaId]?.floor_id || ""}`,
`area${TARGET_SEPARATOR}${areaId}`,
];
}
return [
`${device.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`,
];
}
private _deviceGroup(
entries: Record<string, Level1Entries>,
device: DeviceRegistryEntry
): Record<string, Level3Entries> | undefined {
const [level1, level2] = this._deviceGroupPath(device);
const entry = entries[level1];
return level2 ? entry?.areas?.[level2]?.devices : entry?.devices;
}
private _deviceChain(
group: Record<string, Level3Entries> | undefined,
device: DeviceRegistryEntry
): string[] {
const parentId = device.parent_device_id;
return parentId && group?.[parentId]?.devices?.[device.id]
? [parentId, device.id]
: [device.id];
}
private _getDeviceEntry(
entries: Record<string, Level1Entries>,
deviceId: string
): Level3Entries | undefined {
const device = this._registries.devices[deviceId];
if (!device) {
return undefined;
}
const group = this._deviceGroup(entries, device);
let level = group;
let entry: Level3Entries | undefined;
for (const key of this._deviceChain(group, device)) {
entry = level?.[key];
level = entry?.devices;
}
return entry;
}
private _setDeviceOpen(
deviceId: string,
deviceOpen: boolean | undefined,
openAncestors: boolean
) {
const device = this._registries.devices[deviceId];
if (!device) {
return;
}
const [level1, level2] = this._deviceGroupPath(device);
const group = this._deviceGroup(this._entries, device);
if (!group) {
return;
}
const chain = this._deviceChain(group, device);
const updateGroup = (
devices: Record<string, Level3Entries>,
[key, ...rest]: string[]
): Record<string, Level3Entries> => {
const entry = devices[key];
if (!entry) {
return devices;
}
const isTarget = !rest.length;
return {
...devices,
[key]: {
...entry,
open: isTarget
? (deviceOpen ?? entry.open)
: openAncestors || entry.open,
devices:
isTarget || !entry.devices
? entry.devices
: updateGroup(entry.devices, rest),
},
};
};
const level1Entry = this._entries[level1];
if (!level2) {
this._entries = {
...this._entries,
[level1]: {
...level1Entry,
open: openAncestors || level1Entry.open,
devices: updateGroup(group, chain),
},
};
return;
}
const level2Entry = level1Entry.areas![level2];
this._entries = {
...this._entries,
[level1]: {
...level1Entry,
open: openAncestors || level1Entry.open,
areas: {
...level1Entry.areas,
[level2]: {
...level2Entry,
open: openAncestors || level2Entry.open,
devices: updateGroup(group, chain),
},
},
},
};
}
private _expandTreeToItem(type: string, id: string) {
if (type === "floor" || type === "label") {
return;
@@ -1122,39 +1264,26 @@ export default class HaAutomationAddFromTarget extends LitElement {
if (type === "entity") {
const deviceId = this._registries.entities[id]?.device_id;
const device = deviceId ? this._registries.devices[deviceId] : undefined;
const deviceAreaId = (deviceId && device?.area_id) || undefined;
if (deviceId && this._registries.devices[deviceId]) {
this._setDeviceOpen(deviceId, true, true);
return;
}
if (!deviceAreaId) {
let floor: string;
let area: string;
const entity = this._registries.entities[id];
const entity = this._registries.entities[id];
if (!deviceId && entity.area_id) {
floor = `floor${TARGET_SEPARATOR}${this._registries.areas[entity.area_id]?.floor_id || ""}`;
area = `area${TARGET_SEPARATOR}${entity.area_id}`;
} else if (!deviceId) {
const domain = id.split(".", 1)[0];
const isHelper =
this.manifests![domain]?.integration_type === "helper";
floor = isHelper
? `helper${TARGET_SEPARATOR}`
: `device${TARGET_SEPARATOR}`;
area = `${isHelper ? "helper_" : "entity_"}${domain}${TARGET_SEPARATOR}`;
} else {
floor = `${device!.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`;
area = deviceId;
}
if (entity?.area_id) {
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[entity.area_id]?.floor_id || ""}`;
const area = `area${TARGET_SEPARATOR}${entity.area_id}`;
const floorEntry = this._entries[floor];
this._entries = {
...this._entries,
[floor]: {
...this._entries[floor],
...floorEntry,
open: true,
devices: {
...this._entries[floor].devices!,
areas: {
...floorEntry.areas,
[area]: {
...this._entries[floor].devices![area],
...floorEntry.areas![area],
open: true,
},
},
@@ -1163,26 +1292,23 @@ export default class HaAutomationAddFromTarget extends LitElement {
return;
}
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[deviceAreaId]?.floor_id || ""}`;
const area = `area${TARGET_SEPARATOR}${deviceAreaId}`;
const domain = id.split(".", 1)[0];
const isHelper = this.manifests![domain]?.integration_type === "helper";
const group = isHelper
? `helper${TARGET_SEPARATOR}`
: `device${TARGET_SEPARATOR}`;
const domainGroup = `${isHelper ? "helper_" : "entity_"}${domain}${TARGET_SEPARATOR}`;
const groupEntry = this._entries[group];
this._entries = {
...this._entries,
[floor]: {
...this._entries[floor],
[group]: {
...groupEntry,
open: true,
areas: {
...this._entries[floor].areas!,
[area]: {
...this._entries[floor].areas![area],
devices: {
...groupEntry.devices,
[domainGroup]: {
...groupEntry.devices![domainGroup],
open: true,
devices: {
...this._entries[floor].areas![area].devices,
[deviceId!]: {
...this._entries[floor].areas![area].devices![deviceId!],
open: true,
},
},
},
},
},
@@ -1191,38 +1317,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (type === "device") {
const deviceAreaId = this._registries.devices[id]?.area_id;
if (!deviceAreaId) {
const device = this._registries.devices[id];
const floor = `${device.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`;
this._entries = {
...this._entries,
[floor]: {
...this._entries[floor],
open: true,
},
};
return;
}
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[deviceAreaId]?.floor_id || ""}`;
const area = `area${TARGET_SEPARATOR}${deviceAreaId}`;
this._entries = {
...this._entries,
[floor]: {
...this._entries[floor],
open: true,
areas: {
...this._entries[floor].areas!,
[area]: {
...this._entries[floor].areas![area],
open: true,
},
},
},
};
this._setDeviceOpen(id, undefined, true);
return;
}
@@ -1348,51 +1443,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (type === "device" && id) {
const areaId = this._registries.devices[id]?.area_id;
if (areaId) {
const areaTargetId = `area${TARGET_SEPARATOR}${this._registries.devices[id]?.area_id ?? ""}`;
const floorId = `floor${TARGET_SEPARATOR}${(areaId && this._registries.areas[areaId]?.floor_id) || ""}`;
this._entries = {
...this._entries,
[floorId]: {
...this._entries[floorId],
areas: {
...this._entries[floorId].areas,
[areaTargetId]: {
...this._entries[floorId].areas![areaTargetId],
devices: {
...this._entries[floorId].areas![areaTargetId].devices,
[id]: {
...this._entries[floorId].areas![areaTargetId].devices[id],
open,
},
},
},
},
},
};
return;
}
const deviceType =
this._registries.devices[id]?.entry_type === "service"
? "service"
: "area";
const floorId = `${deviceType}${TARGET_SEPARATOR}`;
this._entries = {
...this._entries,
[floorId]: {
...this._entries[floorId],
devices: {
...this._entries[floorId].devices,
[id]: {
...this._entries[floorId].devices![id],
open,
},
},
},
};
this._setDeviceOpen(id, open, false);
return;
}
@@ -8,6 +8,7 @@ import type { TriggerElement } from "../ha-automation-trigger-row";
import type { HaDurationData } from "../../../../../components/ha-duration-input";
import "../../../../../components/ha-form/ha-form";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
@@ -82,13 +83,21 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
const schema = this._schema(this.hass.localize);
// Convert from string representation to ha form duration representation
const trigger_offset = this.trigger.offset;
const duration: HaDurationData = createDurationData(trigger_offset)!;
// Copy, `createDurationData` returns the input object as-is for dict values.
const duration: HaDurationData = { ...createDurationData(trigger_offset)! };
let offset_type = "after";
if (
(typeof trigger_offset === "object" && duration!.hours! < 0) ||
(typeof trigger_offset === "string" && trigger_offset.startsWith("-"))
) {
duration.hours = Math.abs(duration.hours!);
if (durationDataToSeconds(duration) < 0) {
// A negative offset negates the whole period, and the sign is shown by
// the separate before/after select instead.
if (duration.days) {
duration.days = Math.abs(duration.days);
}
duration.hours = Math.abs(duration.hours ?? 0);
duration.minutes = Math.abs(duration.minutes ?? 0);
duration.seconds = Math.abs(duration.seconds ?? 0);
if (duration.milliseconds) {
duration.milliseconds = Math.abs(duration.milliseconds);
}
offset_type = "before";
}
const data = {
@@ -113,11 +122,12 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
// Convert back to duration string representation
const duration = ev.detail.value.offset;
const offsetType = ev.detail.value.offset_type === "before" ? "-" : "";
const h = (duration.days ?? 0) * 24 + (duration.hours ?? 0);
const m = duration.minutes ?? 0;
const s = (duration.seconds ?? 0) + (duration.milliseconds ?? 0) / 1000;
const newTrigger = {
...ev.detail.value,
offset: `${offsetType}${duration.hours ?? 0}:${duration.minutes ?? 0}:${
duration.seconds ?? 0
}`,
offset: `${offsetType}${h}:${m}:${s}`,
};
delete newTrigger.offset_type;
fireEvent(this, "value-changed", { value: newTrigger });
@@ -2,9 +2,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
@@ -198,21 +196,6 @@ export class HaNumericStateTrigger extends LitElement {
};
}
private _wrapForValue(
forValue: NumericStateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): NumericStateTrigger["for"] {
@@ -240,7 +223,6 @@ export class HaNumericStateTrigger extends LitElement {
private _data = memoizeOne((trigger: NumericStateTrigger) => ({
...trigger,
entity_id: ensureArray(trigger.entity_id),
for: this._wrapForValue(trigger.for),
}));
public render() {
@@ -15,7 +15,6 @@ import {
union,
} from "superstruct";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
@@ -220,21 +219,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
return true;
}
private _wrapForValue(
forValue: StateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): StateTrigger["for"] {
@@ -251,7 +235,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
const data = {
...this.trigger,
entity_id: ensureArray(this.trigger.entity_id),
for: this._wrapForValue(this.trigger.for),
};
data.to = this._normalizeStates(this.trigger.to, data.attribute);
@@ -3,9 +3,7 @@ import { customElement, property } from "lit/decorators";
import type { TemplateTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import "../../../../../components/ha-form/ha-form";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
const SCHEMA = [
@@ -37,21 +35,6 @@ export class HaTemplateTrigger extends LitElement {
return { trigger: "template", value_template: "" };
}
private _wrapForValue(
forValue: TemplateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): TemplateTrigger["for"] {
@@ -67,7 +50,6 @@ export class HaTemplateTrigger extends LitElement {
protected render() {
const data = {
...this.trigger,
for: this._wrapForValue(this.trigger.for),
};
return html`
@@ -5,10 +5,13 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { ENTITY_NAME_TYPES } from "../../../common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { debounce } from "../../../common/util/debounce";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-filter-chip";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
@@ -32,6 +35,11 @@ import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
const PREVIEW_DEBOUNCE_MS = 200;
interface EntityIdExample {
name: string;
parts: Partial<Record<EntityIdPart, string>>;
}
@customElement("ha-config-entity-id-format")
export class HaConfigEntityIdFormat extends LitElement {
@state()
@@ -50,10 +58,12 @@ export class HaConfigEntityIdFormat extends LitElement {
@state() private _error?: string;
@state() private _preview?: string;
@state() private _previews?: string[];
@state() private _previewError = false;
@state() private _selectedExample = 0;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
@@ -79,12 +89,49 @@ export class HaConfigEntityIdFormat extends LitElement {
}
private _examples = memoizeOne(
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
area: localize("ui.panel.config.entity_id_format.card.examples.area"),
device: localize("ui.panel.config.entity_id_format.card.examples.device"),
entity: localize("ui.panel.config.entity_id_format.card.examples.entity"),
floor: localize("ui.panel.config.entity_id_format.card.examples.floor"),
})
(localize: LocalizeFunc): EntityIdExample[] => [
{
name: localize(
"ui.panel.config.entity_id_format.card.examples.thermostat.name"
),
parts: {
floor: localize(
"ui.panel.config.entity_id_format.card.examples.thermostat.floor"
),
area: localize(
"ui.panel.config.entity_id_format.card.examples.thermostat.area"
),
device: localize(
"ui.panel.config.entity_id_format.card.examples.thermostat.device"
),
entity: localize(
"ui.panel.config.entity_id_format.card.examples.thermostat.entity"
),
},
},
{
name: localize(
"ui.panel.config.entity_id_format.card.examples.power_strip.name"
),
parts: {
floor: localize(
"ui.panel.config.entity_id_format.card.examples.power_strip.floor"
),
area: localize(
"ui.panel.config.entity_id_format.card.examples.power_strip.area"
),
parent_device: localize(
"ui.panel.config.entity_id_format.card.examples.power_strip.parent_device"
),
device: localize(
"ui.panel.config.entity_id_format.card.examples.power_strip.device"
),
entity: localize(
"ui.panel.config.entity_id_format.card.examples.power_strip.entity"
),
},
},
]
);
private _debouncedUpdatePreview = debounce(
@@ -94,12 +141,16 @@ export class HaConfigEntityIdFormat extends LitElement {
private async _updatePreview() {
const examples = this._examples(this._localize);
const fullName = this._format!.map((part) => examples[part])
.filter(Boolean)
.join(" ");
try {
const { slug } = await fetchSlug(this._api, fullName);
this._preview = slug;
this._previews = await Promise.all(
examples.map(async (example) => {
const fullName = this._format!.map((part) => example.parts[part])
.filter(Boolean)
.join(" ");
const { slug } = await fetchSlug(this._api, fullName);
return slug;
})
);
this._previewError = false;
} catch (_err: any) {
this._previewError = true;
@@ -146,7 +197,6 @@ export class HaConfigEntityIdFormat extends LitElement {
)}
@value-changed=${this._formatChanged}
></ha-entity-id-format-editor>
${this._renderPreview()}
`
: nothing
}
@@ -169,28 +219,82 @@ export class HaConfigEntityIdFormat extends LitElement {
</ha-progress-button>
</div>
</ha-card>
${this._format ? this._renderExamples() : nothing}
`;
}
private _renderPreview() {
private _renderExamples() {
const examples = this._examples(this._localize);
const example = examples[this._selectedExample];
const parts = ENTITY_NAME_TYPES.filter((part) => example.parts[part]);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
${
this._previewError
? html`<ha-alert alert-type="error">
${this._localize(
"ui.panel.config.entity_id_format.card.preview_error"
)}
</ha-alert>`
: html`<code>${EXAMPLE_DOMAIN}.${this._preview ?? "…"}</code>`
}
</div>
<ha-card
outlined
.header=${this._localize(
"ui.panel.config.entity_id_format.card.preview"
)}
>
<div class="card-content examples">
<ha-chip-set>
${examples.map(
(item, index) => html`
<ha-filter-chip
no-leading-icon
data-index=${index}
.selected=${index === this._selectedExample}
.label=${item.name}
@click=${this._selectExample}
></ha-filter-chip>
`
)}
</ha-chip-set>
${
this._previewError
? html`<ha-alert alert-type="error">
${this._localize(
"ui.panel.config.entity_id_format.card.preview_error"
)}
</ha-alert>`
: html`<code
>${EXAMPLE_DOMAIN}.${
this._previews?.[this._selectedExample] ?? "…"
}</code
>`
}
<dl>
${parts.map((part) => {
const used = this._format!.includes(part);
return html`
<dt>
${this._localize(
`ui.components.entity.entity-name-picker.types.${part}`
)}
</dt>
<dd class=${used ? "" : "unused"}>
${
used
? example.parts[part]
: this._localize(
"ui.panel.config.entity_id_format.card.unused_part",
{ name: example.parts[part] }
)
}
</dd>
`;
})}
</dl>
</div>
</ha-card>
`;
}
private _selectExample(ev: Event) {
ev.preventDefault();
this._selectedExample = Number(
(ev.currentTarget as HTMLElement).dataset.index
);
}
private _formatChanged(ev: CustomEvent) {
this._format = ev.detail.value;
}
@@ -225,24 +329,36 @@ export class HaConfigEntityIdFormat extends LitElement {
haStyle,
css`
:host {
display: block;
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
}
.description {
margin-top: 0;
color: var(--secondary-text-color);
}
.preview {
.examples {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-top: var(--ha-space-5);
gap: var(--ha-space-3);
}
.preview-label {
font-weight: 500;
.examples dl {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
column-gap: var(--ha-space-3);
margin: 0;
overflow-wrap: anywhere;
}
.preview code {
.examples dt,
.examples .unused {
color: var(--secondary-text-color);
}
.examples dd {
margin: 0;
}
.examples code {
display: block;
padding: var(--ha-space-3);
padding: var(--ha-space-2);
border-radius: var(--ha-border-radius-md);
background-color: var(--secondary-background-color);
font-family: var(--ha-font-family-code);
@@ -252,6 +368,7 @@ export class HaConfigEntityIdFormat extends LitElement {
display: flex;
justify-content: space-between;
align-items: center;
padding-block-end: var(--ha-space-4);
}
`,
];
@@ -6,6 +6,7 @@ import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import { ENTITY_NAME_TYPES } from "../../../common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/chips/ha-assist-chip";
import "../../../components/chips/ha-chip-set";
@@ -22,8 +23,6 @@ import type {
} from "../../../data/entity_id_format";
import type { ValueChangedEvent } from "../../../types";
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
@@ -39,7 +38,7 @@ const decodeType = (value: string): EntityIdPart | undefined => {
value.startsWith("___") && value.endsWith("___")
? value.slice(3, -3)
: value;
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
return (ENTITY_NAME_TYPES as readonly string[]).includes(type)
? (type as EntityIdPart)
: undefined;
};
@@ -65,6 +64,7 @@ export class HaEntityIdFormatEditor extends LitElement {
<div class="container">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
no-sort
.disabled=${this.disabled}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
@@ -221,7 +221,7 @@ export class HaEntityIdFormatEditor extends LitElement {
private _getItems = memoizeOne(
(localize: LocalizeFunc): PickerComboBoxItem[] =>
STRUCTURAL_TYPES.map((type) => {
ENTITY_NAME_TYPES.map((type) => {
const primary = localize(
`ui.components.entity.entity-name-picker.types.${type}`
);
@@ -3,7 +3,6 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiCog,
mdiChevronRight,
mdiDelete,
mdiDotsVertical,
mdiDownload,
@@ -48,6 +47,7 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-icon-next";
import "../../../components/item/ha-list-item-base";
import "../../../components/item/ha-list-item-button";
@@ -959,12 +959,11 @@ export class HaConfigDevicePage extends LitElement {
back: "1",
})}"
>
<ha-icon-button
.path=${mdiChevronRight}
<ha-icon-button-next
.label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
></ha-icon-button>
></ha-icon-button-next>
</a>
</div>
<ha-logbook
@@ -31,6 +31,7 @@ const SEARCH_KEYS = [
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.parentDeviceName", weight: 6 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "id", weight: 2 },
@@ -64,14 +65,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const friendlyName = computeStateName(stateObj); // Keep this for search
@@ -89,6 +96,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
@@ -160,11 +160,17 @@ export class DialogVacuumSegmentMapping
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context?.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const breadcrumb = [
areaName,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
return html`
<ha-dialog
@@ -3,7 +3,6 @@ import { consume } from "@lit/context";
import {
mdiAlertCircle,
mdiCancel,
mdiChevronRight,
mdiDelete,
mdiDotsVertical,
mdiEye,
@@ -69,6 +68,7 @@ import "../../../components/ha-filter-states";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
@@ -906,10 +906,7 @@ export class HaConfigEntities extends LitElement {
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
<ha-icon-next slot="end"></ha-icon-next>
${this._renderLabelItems("submenu")}
</ha-dropdown-item>
<wa-divider></wa-divider>`
@@ -678,7 +678,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
([domain, items]) =>
html`<ha-integration-card
data-domain=${domain}
.hass=${this.hass}
.domain=${domain}
.items=${items}
.manifest=${this._manifests[domain]}
@@ -6,7 +6,6 @@ import {
type IntegrationManifest,
} from "../../../data/integration";
import type { HomeAssistant } from "../../../types";
import "./ha-integration-header";
import "../../../components/ha-card";
import { brandsUrl } from "../../../util/brands-url";
import { haStyle } from "../../../resources/styles";
@@ -0,0 +1,251 @@
import { mdiFileCodeOutline, mdiPackageVariant, mdiWeb } from "@mdi/js";
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { computeRTL } from "../../../common/util/compute_rtl";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import {
devicesContext,
internationalizationContext,
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import type { HomeAssistantInternationalization } from "../../../types";
import type { ConfigEntryExtended } from "./ha-config-integrations";
@customElement("ha-integration-card-footer")
export class HaIntegrationCardFooter extends LitElement {
@property({ attribute: false }) public manifest?: IntegrationManifest;
@property({ attribute: false }) public items!: ConfigEntryExtended[];
@property({ attribute: false })
public entityRegistryEntries!: EntityRegistryEntry[];
@property({ attribute: false }) public domainEntities: string[] = [];
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, boolean>({
transformer: ({ language, translationMetadata }) =>
computeRTL(language, translationMetadata.translations),
})
private _isRTL!: boolean;
protected render(): TemplateResult | typeof nothing {
const devices = this._getDevices(this.items, this._devices);
const entitiesCount = devices.length
? 0
: this._getEntityCount(
this.items,
this.entityRegistryEntries,
this.domainEntities
);
const services = !devices.some((device) => device.entry_type !== "service");
const hasIcons =
Boolean(this.manifest && !this.manifest.is_built_in) ||
Boolean(this.manifest?.iot_class?.startsWith("cloud_")) ||
Boolean(
this.manifest &&
!this.manifest.config_flow &&
!this.items.every((itm) => itm.source === "system")
);
let countBadge: TemplateResult | typeof nothing = nothing;
if (devices.length > 0) {
countBadge = html`<span class="count-badge"
>${this._localize(
`ui.panel.config.integrations.config_entry.${services ? "services" : "devices"}`,
{ count: devices.length }
)}</span
>`;
} else if (entitiesCount > 0) {
countBadge = html`<span class="count-badge"
>${this._localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entitiesCount }
)}</span
>`;
} else if (this.items.find((itm) => itm.source !== "yaml")) {
countBadge = html`<span class="count-badge"
>${this._localize(`ui.panel.config.integrations.config_entry.entries`, {
count: this.items.filter((itm) => itm.source !== "yaml").length,
})}</span
>`;
}
if (countBadge === nothing && !hasIcons) return nothing;
const placement = this._isRTL ? "right" : "left";
return html`
<div class="footer">
${countBadge}
<div class="icons">
${
this.manifest && !this.manifest.is_built_in
? html`<span
class="icon ${
this.manifest.overwrites_built_in ? "overwrites" : "custom"
}"
>
<ha-svg-icon
id="icon-custom"
.path=${mdiPackageVariant}
></ha-svg-icon>
<ha-tooltip for="icon-custom" .placement=${placement}>
${this._localize(
this.manifest.overwrites_built_in
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
: "ui.panel.config.integrations.config_entry.custom_integration"
)}
</ha-tooltip>
</span>`
: nothing
}
${
this.manifest && this.manifest.iot_class?.startsWith("cloud_")
? html`<span class="icon cloud">
<ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
<ha-tooltip for="icon-cloud" .placement=${placement}>
${this._localize(
"ui.panel.config.integrations.config_entry.depends_on_cloud"
)}
</ha-tooltip>
</span>`
: nothing
}
${
this.manifest &&
!this.manifest?.config_flow &&
!this.items.every((itm) => itm.source === "system")
? html`<span class="icon yaml">
<ha-svg-icon
id="icon-yaml"
.path=${mdiFileCodeOutline}
></ha-svg-icon>
<ha-tooltip for="icon-yaml" .placement=${placement}>
${this._localize(
"ui.panel.config.integrations.config_entry.no_config_flow"
)}
</ha-tooltip>
</span>`
: nothing
}
</div>
</div>
`;
}
private _getEntityCount = memoizeOne(
(
configEntry: ConfigEntryExtended[],
entityRegistryEntries: EntityRegistryEntry[],
domainEntities: string[]
): number => {
if (!entityRegistryEntries) {
return domainEntities.length;
}
const entryIds = configEntry
.map((entry) => entry.entry_id)
.filter(Boolean);
if (!entryIds.length) {
return domainEntities.length;
}
const entityRegEntities = entityRegistryEntries.filter(
(entity) =>
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
);
if (entityRegEntities.length === domainEntities.length) {
return domainEntities.length;
}
const entityIds = new Set<string>(
entityRegEntities.map((reg) => reg.entity_id)
);
for (const entity of domainEntities) {
entityIds.add(entity);
}
return entityIds.size;
}
);
private _getDevices = memoizeOne(
(
configEntry: ConfigEntryExtended[],
deviceRegistryEntries: ContextType<typeof devicesContext>
): DeviceRegistryEntry[] => {
if (!deviceRegistryEntries) {
return [];
}
const entryIds = configEntry.map((entry) => entry.entry_id);
return Object.values(deviceRegistryEntries).filter((device) =>
device.config_entries.some((entryId) => entryIds.includes(entryId))
);
}
);
static styles = css`
:host {
display: block;
}
.footer {
display: flex;
align-items: center;
padding-top: var(--ha-space-2);
min-height: var(--ha-space-8);
}
.count-badge {
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-m);
}
.icons {
display: flex;
margin-inline-start: auto;
}
.icon {
color: var(--label-badge-grey);
padding: var(--ha-space-1);
margin-inline-start: var(--ha-space-2);
}
.icon.custom {
color: var(--warning-color);
}
.icon.overwrites {
color: var(--error-color);
}
.icon ha-svg-icon {
width: 24px;
height: 24px;
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-integration-card-footer": HaIntegrationCardFooter;
}
}
@@ -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;
}
`,
];
}
@@ -1,5 +1,6 @@
import {
mdiCableData,
mdiFileDocumentOutline,
mdiLan,
mdiPuzzle,
mdiRefresh,
@@ -22,7 +23,10 @@ import "../../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import { domainToName } from "../../../../../data/integration";
import type { ModbusConnection } from "../../../../../data/modbus";
import type {
ModbusConnection,
ModbusConnectionSource,
} from "../../../../../data/modbus";
import {
listModbusConnections,
modbusEndpointTarget,
@@ -43,7 +47,8 @@ const isKnownTransport = (transport: string): transport is ModbusTransport =>
(TRANSPORTS as readonly string[]).includes(transport);
interface ConnectionHolder {
entryId: string;
/** A config entry ID, or a hub name on a YAML connection. */
id: string;
entry?: ConfigEntry;
units: number[];
}
@@ -51,9 +56,10 @@ interface ConnectionHolder {
interface ConnectionListItem {
icon: string;
primary: string;
transport: string;
secondary: string;
state: string;
serialDevice?: string;
source: ModbusConnectionSource;
holders: ConnectionHolder[];
connection: ModbusConnection;
}
@@ -114,22 +120,34 @@ export class ModbusConfigDashboard extends LitElement {
);
}
// A YAML hub is a link of its own rather than a hold on a shared
// connection, so the same device can be listed twice
private _secondaryName(connection: ModbusConnection): string {
const transport = this._transportName(connection.endpoint[0]);
if (connection.source !== "yaml") {
return transport;
}
return `${transport}${this.hass.localize(
"ui.panel.config.modbus.configured_in_yaml"
)}`;
}
private _connectionListItem(
connection: ModbusConnection,
entries: Record<string, ConfigEntry>
): ConnectionListItem {
const [transport] = connection.endpoint;
const serialDevice = modbusSerialDevice(connection.endpoint);
return {
icon: serialDevice ? mdiCableData : mdiLan,
primary: modbusEndpointTarget(connection.endpoint),
transport: this._transportName(transport),
secondary: this._secondaryName(connection),
state: this._stateName(connection.connected, serialDevice !== undefined),
serialDevice,
holders: Object.entries(connection.units).map(([entryId, units]) => ({
entryId,
entry: entries[entryId],
source: connection.source,
holders: Object.entries(connection.units).map(([id, units]) => ({
id,
entry: connection.source === "yaml" ? undefined : entries[id],
units,
})),
connection,
@@ -161,13 +179,20 @@ export class ModbusConfigDashboard extends LitElement {
`;
}
private _renderHolder(holder: ConnectionHolder): TemplateResult {
// An entry removed while the connection was listed has nowhere to link to
private _renderHolder(
holder: ConnectionHolder,
source: ModbusConnectionSource
): TemplateResult {
// A YAML hub is named by the key it is configured under, and an entry
// removed while the connection was listed has nowhere to link to
if (!holder.entry) {
return html`
<ha-md-list-item class="holder">
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
<div slot="headline">${holder.entryId}</div>
<ha-svg-icon
slot="start"
.path=${source === "yaml" ? mdiFileDocumentOutline : mdiPuzzle}
></ha-svg-icon>
<div slot="headline">${holder.id}</div>
${this._renderUnits(holder)}
</ha-md-list-item>
`;
@@ -179,7 +204,7 @@ export class ModbusConfigDashboard extends LitElement {
<ha-md-list-item
type="link"
class="holder"
href=${`/config/integrations/integration/${domain}#config_entry=${holder.entryId}`}
href=${`/config/integrations/integration/${domain}#config_entry=${holder.id}`}
>
<img
slot="start"
@@ -234,10 +259,10 @@ export class ModbusConfigDashboard extends LitElement {
<ha-md-list-item class="connection">
<ha-svg-icon slot="start" .path=${item.icon}></ha-svg-icon>
<div slot="headline">${item.primary}</div>
<div slot="supporting-text">${item.transport}</div>
<div slot="supporting-text">${item.secondary}</div>
${this._renderState(item)}
</ha-md-list-item>
${item.holders.map((holder) => this._renderHolder(holder))}
${item.holders.map((holder) => this._renderHolder(holder, item.source))}
${
item.serialDevice && isComponentLoaded(this.hass.config, "usb")
? this._renderSerialPortLink()
@@ -1,5 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiChevronRight } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -9,7 +8,7 @@ import type {
HASSDomEvent,
} from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-button-next";
import "../../../../../components/ha-list";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
@@ -186,15 +185,14 @@ export class ZHADeviceEndpointList extends LitElement {
${
this.showDeviceLink
? html`
<ha-icon-button
<ha-icon-button-next
slot="end"
.path=${mdiChevronRight}
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
.label=${this._i18n.localize(
"ui.panel.config.zha.groups.open_device"
)}
@click=${this._stopPropagation}
></ha-icon-button>
></ha-icon-button-next>
`
: nothing
}
@@ -214,14 +212,13 @@ export class ZHADeviceEndpointList extends LitElement {
${
this.showDeviceLink
? html`
<ha-icon-button
<ha-icon-button-next
slot="end"
.path=${mdiChevronRight}
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
.label=${this._i18n.localize(
"ui.panel.config.zha.groups.open_device"
)}
></ha-icon-button>
></ha-icon-button-next>
`
: nothing
}
@@ -1,4 +1,4 @@
import { mdiChevronLeft, mdiClose } from "@mdi/js";
import { mdiChevronLeft, mdiChevronRight, mdiClose } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -64,6 +64,7 @@ import "./zwave-js-add-node-loading";
import "./zwave-js-add-node-searching-devices";
import "./zwave-js-add-node-select-method";
import "./zwave-js-add-node-select-security-strategy";
import { mainWindow } from "../../../../../../common/dom/get_main_window";
const INCLUSION_TIMEOUT_MINUTES = 5;
@@ -184,7 +185,8 @@ class DialogZWaveJSAddNode extends LitElement {
(this._step && backButtonStages.includes(this._step)) ||
(this._step === "search_devices" && this._supportsSmartStart)
) {
icon = mdiChevronLeft;
icon =
mainWindow.document.dir === "rtl" ? mdiChevronRight : mdiChevronLeft;
}
let titleTranslationKey = "title";
@@ -103,6 +103,7 @@ class HaConfigSectionNetwork extends LitElement {
max-width: 600px;
}
.discovery-card ha-md-list {
background: none;
padding-top: 0;
}
`;
@@ -1,10 +1,4 @@
import {
mdiChevronDoubleLeft,
mdiChevronDoubleRight,
mdiChevronLeft,
mdiChevronRight,
mdiInformationOutline,
} from "@mdi/js";
import { mdiInformationOutline } from "@mdi/js";
import type { HassEvent } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -15,6 +9,10 @@ import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-next";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-icon-button-last";
import "../../../../components/ha-icon-button-first";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-yaml-editor";
@@ -169,22 +167,20 @@ class EventSubscribeCard extends LitElement {
return html`
<ha-card class="events-card">
<div class="events-toolbar">
<ha-icon-button
.path=${mdiChevronDoubleLeft}
<ha-icon-button-first
.disabled=${index >= bufferTotal - 1}
.label=${this.hass!.localize(
"ui.panel.config.tools.tabs.events.oldest_event"
)}
@click=${this._showOldest}
></ha-icon-button>
<ha-icon-button
.path=${mdiChevronLeft}
></ha-icon-button-first>
<ha-icon-button-prev
.disabled=${index >= bufferTotal - 1}
.label=${this.hass!.localize(
"ui.panel.config.tools.tabs.events.older_event"
)}
@click=${this._showOlder}
></ha-icon-button>
></ha-icon-button-prev>
<div class="event-info">
${this.hass!.localize(
"ui.panel.config.tools.tabs.events.event_fired",
@@ -218,22 +214,20 @@ class EventSubscribeCard extends LitElement {
: nothing
}
</div>
<ha-icon-button
.path=${mdiChevronRight}
<ha-icon-button-next
.disabled=${atNewest}
.label=${this.hass!.localize(
"ui.panel.config.tools.tabs.events.newer_event"
)}
@click=${this._showNewer}
></ha-icon-button>
<ha-icon-button
.path=${mdiChevronDoubleRight}
></ha-icon-button-next>
<ha-icon-button-last
.disabled=${atNewest}
.label=${this.hass!.localize(
"ui.panel.config.tools.tabs.events.newest_event"
)}
@click=${this._showNewest}
></ha-icon-button>
></ha-icon-button-last>
</div>
<ha-yaml-editor
.value=${event.event}
@@ -86,6 +86,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
const entities = this._filterEntities(
this.params.exposedEntities,
this._registries,
this._filter
);
@@ -180,6 +181,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
private _filterEntities = memoizeOne(
(
exposedEntities: Record<string, ExposeEntitySettings>,
registries: ContextType<typeof registriesContext>,
filter?: string
): FilteredEntity[] => {
const lowerFilter = filter?.toLowerCase();
@@ -196,11 +198,16 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
const nameList = computeEntityNameList(
entity,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._registries.entities,
this._registries.devices,
this._registries.areas,
this._registries.floors
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
registries.entities,
registries.devices,
registries.areas,
registries.floors
);
if (!lowerFilter) {
@@ -219,13 +226,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
continue;
}
const [, deviceName, areaName] = nameList;
const [, deviceName, parentDeviceName, areaName] = nameList;
if (deviceName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
if (parentDeviceName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
if (areaName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
@@ -238,14 +250,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
private _renderItem = (item: FilteredEntity) => {
const { entity: entityState, nameList } = item;
const [entityName, deviceName, areaName] = nameList;
const [entityName, deviceName, parentDeviceName, areaName] = nameList;
const isRTL = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const primary = entityName || deviceName || entityState.entity_id;
const context = [areaName, entityName ? deviceName : undefined]
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const showEntityId = this._config?.userData?.showEntityIdPicker;
+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,
+18 -12
View File
@@ -41,8 +41,8 @@ export const isRunRow = (item: LogbookEntry): boolean =>
classifyLogbookEntry(item) === "automation";
// How much naming detail an entity row shows, from least to most. The value is
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
// entity), `area` (area ▸ device ▸ entity).
// the broadest part shown: `none` (name hidden), `entity`, `device` (parent
// device ▸ device ▸ entity), `area` (area ▸ parent device ▸ device ▸ entity).
export type LogbookNameDetail = "none" | "entity" | "device" | "area";
export interface EntityDisplay {
@@ -60,14 +60,20 @@ export const entityDisplay = (
return {};
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || entityId;
@@ -82,11 +88,11 @@ export const entityDisplay = (
parts = [];
break;
case "device":
parts = [deviceQualifier];
parts = [parentDeviceName, deviceQualifier];
break;
case "area":
default:
parts = [areaName, deviceQualifier];
parts = [areaName, parentDeviceName, deviceQualifier];
}
const filtered = parts.filter(Boolean) as string[];
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { mdiHomeImportOutline, mdiPause, mdiPlay } from "@mdi/js";
import { mdiHomeImportOutline, mdiPause, mdiPlay, mdiStop } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -20,6 +20,7 @@ import {
LawnMowerEntityFeature,
canDock,
canStartMowing,
canStop,
} from "../../../data/lawn_mower";
import type { HomeAssistant, HomeAssistantApi } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
@@ -46,6 +47,7 @@ export const LAWN_MOWER_COMMANDS_FEATURES: Record<
LawnMowerEntityFeature.PAUSE,
LawnMowerEntityFeature.START_MOWING,
],
stop: [LawnMowerEntityFeature.STOP],
dock: [LawnMowerEntityFeature.DOCK],
};
@@ -81,6 +83,12 @@ export const LAWN_MOWER_COMMANDS_BUTTONS: Record<
!canStartMowing(stateObj),
};
},
stop: (stateObj) => ({
translationKey: "stop",
icon: mdiStop,
serviceName: "stop",
disabled: !canStop(stateObj),
}),
dock: (stateObj) => ({
translationKey: "dock",
icon: mdiHomeImportOutline,
@@ -26,12 +26,14 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import type { LovelaceCardFeature } from "../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
LovelaceCardFeatureContext,
TargetHumidityCardFeatureConfig,
} from "./types";
import "../../../components/ha-control-button-group";
import "../../../components/ha-control-number-buttons";
const supportsTargetHumidityCardFeatureFromState = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
@@ -86,9 +88,15 @@ class HuiTargetHumidityCardFeature
static getStubConfig(): TargetHumidityCardFeatureConfig {
return {
type: "target-humidity",
style: "slider",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-target-humidity-card-feature-editor");
return document.createElement("hui-target-humidity-card-feature-editor");
}
public setConfig(config: TargetHumidityCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
@@ -103,10 +111,6 @@ class HuiTargetHumidityCardFeature
}
}
private get _step() {
return this._stateObj!.attributes.target_humidity_step ?? 1;
}
private get _min() {
return this._stateObj!.attributes.min_humidity ?? 0;
}
@@ -140,13 +144,34 @@ class HuiTargetHumidityCardFeature
return nothing;
}
if (this._config.style === "buttons") {
return html`
<ha-control-button-group>
<ha-control-number-buttons
.value=${this._stateObj.attributes.humidity}
.min=${this._min}
.max=${this._max}
.disabled=${this._stateObj.state === UNAVAILABLE}
.step=${this._stateObj.attributes.target_humidity_step ?? 1}
@value-changed=${this._valueChanged}
.label=${this._formatters.formatEntityAttributeName(
this._stateObj,
"humidity"
)}
unit="%"
.locale=${this._locale}
></ha-control-number-buttons>
</ha-control-button-group>
`;
}
return html`
<ha-control-slider
.value=${this._stateObj.attributes.humidity}
.min=${this._min}
.max=${this._max}
.step=${this._step}
.disabled=${this._stateObj!.state === UNAVAILABLE}
.step=${this._stateObj.attributes.target_humidity_step ?? 1}
@value-changed=${this._valueChanged}
.label=${this._formatters.formatEntityAttributeName(
this._stateObj,
+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];
+11 -1
View File
@@ -190,6 +190,10 @@ export class HuiCalendarCard
}
const loading = !this._entityRegistry || !this._eventsLoaded;
const rows = this._config.grid_options
? this._config.grid_options.rows
: this._config.layout_options?.grid_rows;
const autoHeight = this.layout === "grid" && rows === "auto";
const views: FullCalendarView[] = [
"dayGridMonth",
@@ -206,11 +210,12 @@ export class HuiCalendarCard
}
<ha-full-calendar
class=${classMap({
"is-grid": this.layout === "grid",
"is-grid": this.layout === "grid" && !autoHeight,
"is-panel": this.layout === "panel",
"has-title": !!this._config.title,
loading: loading,
})}
?auto-height=${autoHeight}
?add-fab=${this._config.show_add_event}
add-fab-style=${this._config.show_add_event ? (this._config.add_event_style ?? "below") : nothing}
add-fab-size=${this._config.show_add_event && this._config.add_event_style !== "header" ? (this._config.add_event_size ?? "small") : nothing}
@@ -403,6 +408,11 @@ export class HuiCalendarCard
min-height: var(--calendar-height);
}
ha-full-calendar[auto-height] {
--calendar-height: auto;
height: auto;
}
ha-full-calendar.loading {
visibility: hidden;
}
@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HassEntity } from "home-assistant-js-websocket";
import { theme2hex } from "../../../common/color/convert-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { createSearchParam } from "../../../common/url/search-params";
@@ -105,10 +106,22 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
}
this._names = {};
this._entities.forEach((entity) => {
const stateObj = this.hass!.states[entity.entity];
this._names[entity.entity] = stateObj
? this.hass!.formatEntityName(stateObj, entity.name)
: entity.entity;
// Leave unset so timeline/line charts use computeHistory's Device ▸ Entity
// labels. Only YAML `name` overrides that default.
if (entity.name === undefined) {
return;
}
const stateObj =
this.hass!.states[entity.entity] ??
({
entity_id: entity.entity,
state: "unavailable",
attributes: {},
} as HassEntity);
this._names[entity.entity] = this.hass!.formatEntityName(
stateObj,
entity.name
);
});
}
@@ -315,6 +328,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
const columns = this._config.grid_options?.columns ?? 12;
const narrow = typeof columns === "number" && columns <= 12;
const hasFixedHeight = typeof this._config.grid_options?.rows === "number";
const showNames = this._config.show_names !== false;
return html`
<ha-card>
@@ -360,11 +374,8 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.names=${this._names}
up-to-now
.hoursToShow=${this._hoursToShow}
.showNames=${
this._config.show_names !== undefined
? this._config.show_names
: true
}
.showNames=${showNames}
?inside-labels=${showNames}
.logarithmicScale=${this._config.logarithmic_scale || false}
.minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis}
@@ -2,6 +2,7 @@ import { endOfDay, startOfDay } from "date-fns";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
@@ -39,6 +40,10 @@ import {
filterLowBatteryEntities,
filterUnavailableBatteryEntities,
} from "../../maintenance/strategies/maintenance-view-strategy";
import {
isSecurityAlertActive,
resolveSecurityAlertSeverity,
} from "../../security/strategies/security-alerts";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import { tileCardStyle } from "./tile/tile-card-style";
import type { HomeSummaryCard } from "./types";
@@ -179,6 +184,16 @@ export class HuiHomeSummaryCard
? `${formattedMinTemp}°`
: `${formattedMinTemp} - ${formattedMaxTemp}°`;
}
case "alerts": {
const count = (this._config.alert_entities ?? []).filter(
(alertEntity) => isSecurityAlertActive(this.hass!, alertEntity.entity)
).length;
return count
? this.hass.localize("ui.card.home-summary.count_active_alerts", {
count,
})
: "";
}
case "security": {
// Alarm and lock status
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
@@ -202,8 +217,22 @@ export class HuiHomeSummaryCard
return s === "disarmed";
});
const warningCount = (this._config.alert_entities ?? []).filter(
(alertEntity) =>
resolveSecurityAlertSeverity(
alertEntity,
this.hass!.states[alertEntity.entity]
) === "warning" &&
isSecurityAlertActive(this.hass!, alertEntity.entity)
).length;
const warningText = warningCount
? this.hass.localize("ui.card.home-summary.count_warnings", {
count: warningCount,
})
: undefined;
if (!locks.length && !alarms.length) {
return "";
return warningText ?? "";
}
const unlockedLocks = locks.filter((entityId) => {
@@ -211,23 +240,18 @@ export class HuiHomeSummaryCard
return s === "unlocked" || s === "jammed" || s === "open";
});
if (unlockedLocks.length) {
return this.hass.localize(
"ui.card.home-summary.count_locks_unlocked",
{
const statusText = unlockedLocks.length
? this.hass.localize("ui.card.home-summary.count_locks_unlocked", {
count: unlockedLocks.length,
}
);
}
if (disarmedAlarms.length) {
return this.hass.localize(
"ui.card.home-summary.count_alarms_disarmed",
{
count: disarmedAlarms.length,
}
);
}
return this.hass.localize("ui.card.home-summary.all_secure");
})
: disarmedAlarms.length
? this.hass.localize("ui.card.home-summary.count_alarms_disarmed", {
count: disarmedAlarms.length,
})
: warningText
? undefined
: this.hass.localize("ui.card.home-summary.all_secure");
return [warningText, statusText].filter(Boolean).join(", ");
}
case "media_players": {
// Playing media
@@ -338,23 +362,29 @@ export class HuiHomeSummaryCard
return nothing;
}
const color = computeCssColor(HOME_SUMMARIES_COLORS[this._config.summary]);
const summary = this._config.summary;
const isAlertsSummary = summary === "alerts";
const color = computeCssColor(HOME_SUMMARIES_COLORS[summary]);
const style = {
"--tile-color": color,
"--ha-alert-color": isAlertsSummary ? color : undefined,
};
const secondary = this._computeSummaryState();
const secondaryLoading = this._computeSecondaryLoading(
this._config.summary,
summary,
this._energyData
);
const label = getSummaryLabel(this.hass.localize, this._config.summary);
const icon = HOME_SUMMARIES_ICONS[this._config.summary];
const label = getSummaryLabel(this.hass.localize, summary);
const icon = HOME_SUMMARIES_ICONS[summary];
return html`
<ha-card style=${styleMap(style)}>
<ha-card
class=${classMap({ alert: isAlertsSummary })}
style=${styleMap(style)}
>
<ha-tile-container
.vertical=${Boolean(this._config.vertical)}
.interactive=${this._hasCardAction}
@@ -381,6 +411,24 @@ export class HuiHomeSummaryCard
css`
:host {
--tile-color: var(--state-inactive-color);
--ha-alert-pulse-opacity: 0.3;
}
ha-card.alert {
position: relative;
overflow: hidden;
--tile-color: var(--ha-alert-color);
}
ha-card.alert::before {
position: absolute;
inset: 0;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
background-color: var(--ha-alert-color);
content: "";
opacity: var(--ha-alert-pulse-opacity);
pointer-events: none;
}
ha-card.alert ha-tile-container {
position: relative;
}
`,
];
+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;
@@ -63,8 +63,8 @@ export class HuiEntityEditor extends LitElement {
this.hass.formatEntityName(
stateObj,
useDeviceName
? [{ type: "area" }]
: [{ type: "area" }, { type: "device" }],
? [{ type: "area" }, { type: "parent_device" }]
: [{ type: "area" }, { type: "parent_device" }, { type: "device" }],
{
separator: isRTL ? " ◂ " : " ▸ ",
}

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