Compare commits

...
Author SHA1 Message Date
Bram KragtenandClaude Opus 5 00f2c71231 Keep the entity name shape out of the core chunk
recorder.ts sits in the core entry chunk, so importing DEFAULT_ENTITY_NAME
from compute_entity_name_display dragged the registry-aware formatters and
their dependencies in with it and blew the core bundle budget by 4.1%.

The constant and the name types have no runtime dependencies, so they move
to their own module; compute_entity_name_display re-exports them so nothing
else has to change.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-15 15:41:43 +02:00
Bram KragtenandClaude Opus 5 66820826fa Resolve statistic labels with structured entity names
getStatisticLabel resolved names through computeStateName, so every
statistic-shaped label — the energy dashboard cards, the sankey cards,
hui-statistic-card, chart series names and the energy configuration
dialogs — showed the flat friendly_name attribute while the rest of the
UI composed names from the device, area and floor.

It now asks hass.formatEntityName for the default composition, which
also makes the entity branch of computeEnergyLabel redundant.

The metadata fallback stays: external statistics have no entity to
resolve a name against.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-15 15:21:43 +02:00
Paul Bottein 546315ca5b Share entity display and search label helpers across pickers (#54192) 2026-09-15 15:45:04 +03:00
renovate[bot] 7c98912b73 Update formatjs (#54162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 15:42:31 +03:00
Bram KragtenandClaude Opus 5 8c72e9c22d Remove unused Material Web wrappers (#54198)
`ha-outlined-button` and `ha-outlined-field` had no references anywhere
in the repository, so their custom elements were never registered at
runtime. `ha-sub-menu` was imported by five config panels, but no
template renders the tag.

Removes three `@material/web/**/internal/**` import sites; Material Web
is now only reached from chips, list, ripple and iconbutton.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-15 15:30:00 +03:00
Lucas Mindêllo de Andrade 892514ed21 Show toggle for fan entity only if TURN_ON or TURN_OFF is supported (#54188)
* fix(fan): keep toggle only if on off is supported

* feat(lovelace): add fan entity row

* code review adjusts
2026-09-15 15:26:14 +03:00
Bruno Pantaleão GonçalvesandClaude 06be6f433d Add more info opened and closed external bus messages (#54187)
Co-authored-by: Claude <[email protected]>
2026-09-15 09:29:34 +02:00
renovate[bot] e9a6e2936d Update dependency nanoid to v6 (#54189) 2026-09-15 07:13:27 +00:00
d3b7d787b4 Add trigger ID auto-generation and selection (#54130)
* Add trigger ID auto-generation and selection

* Fix trigger list index and descriptions

* Potential fix for blueprint crash

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

* Split cleanup

* Process Wendelin code review comments

* Process Marcin code review comments

* Remove unused controller config option

* Swap

* Fix minor review comments

* Update src/panels/config/automation/condition/types/ha-automation-condition-trigger.ts

* Update src/panels/config/automation/condition/types/ha-automation-condition-trigger.ts

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-09-15 09:07:03 +02:00
renovate[bot] 0662f8238f Update dependency hls.js to v1.7.3 (#54181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 06:17:17 +02:00
Jonas PeterssonandJonas Petersson 8a59516d8f Support glass break binary sensors (#53989)
* feat(security): support glass break binary sensors

* fix: add glass_break translation for device class override dropdown

* fix: add glass_break to generated device classes list

* fix: add glass_break icon to entity component icons for demo/fake data

---------

Co-authored-by: Jonas Petersson <[email protected]>
2026-09-14 17:44:15 +02:00
Petar Petrov 0afe6bb136 Cap unsupported config warning height so YAML fallback stays visible (#54177) 2026-09-14 16:52:31 +02:00
Bram KragtenandClaude Opus 5 61b487f995 Rename "For" to "For at least" and let duration inputs fill the row (#54175)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-14 15:35:18 +02:00
91abff2d0c Hide "for" on state condition when matching an attribute (#52710)
* Hide "for" on state condition when matching an attribute

The legacy state condition measures the `for` duration against
`last_changed`, which only updates on state changes, not attribute
changes. So `for` together with `attribute` is unreliable and core is
moving to reject it. Omit the `for` field when an attribute is selected,
drop any lingering duration value, and explain why via a helper.

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

* Keep "for" visible but disabled when matching an attribute

Removing the field left the explanation pointing at a control that was no
longer on screen. Keep "for" in the form, disable it while an attribute is
selected, and move the helper text under it.

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-09-14 16:32:15 +03:00
Bram KragtenandClaude Opus 5 53d16c97fe Keep helper text readable on disabled fields (#54174)
Disabled fields dimmed their helper text to the disabled ink color, which
drops it to 2.6:1 contrast in light mode (5.7:1 when enabled). Helper text
is guidance about the control rather than part of its value, and it is often
the only thing explaining why a field is disabled, so keep it at its regular
contrast. The disabled state is still carried by the label, the value, and
the missing clear button.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-14 16:17:43 +03:00
Bram KragtenandClaude Opus 5 7493bfc86c Honor configured name in bootstrap formatEntityName (#54172)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-14 14:39:26 +02:00
infernalK ca1eb4ed7d Fix TypeError when hass.panels changes while rebuild() is in flight (#54171)
_updateRoutes() awaits this.rebuild() and then reads
this.hass.panels[this._currentPage].component_name without checking that
the entry still exists. hass.panels can be replaced again while the
rebuild() await is pending (e.g. multiple integrations or HACS-managed
Lovelace resources updating panels around startup), leaving
this._currentPage pointing at a panel that is no longer present. The
resulting TypeError breaks rendering of the current dashboard.

Guard the access after the await and bail out when the panel is gone:
willUpdate already re-triggers _updateRoutes for the newer hass.panels
value in that race, so returning early is safe and avoids the crash.

Fixes #54006
2026-09-14 15:38:16 +03:00
Petar PetrovandCopilot Autofix powered by AI c1dd17c4f9 Show Energy Now view for water and gas flow without power sensors (#54100)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-14 14:34:38 +02:00
Stu Kabakoff 5c74a84d01 show up to 5 discrete buttons on fan cards (#54137)
show up to 5 discrete buttons to fan card
2026-09-14 14:14:41 +02:00
shbatmandPetar Petrov a51d255ec3 Show disabled entities when filtering entities by device or area (#53444)
* Show disabled entities when filtering entities by device or area

The device and area filters on the entities config page resolve their
selection through search/related, which omits disabled entities. Opt in
to the new include_disabled_entities option there, so that filtering by
a device or area no longer hides that device's disabled entities.

The option is only set on the entities page, so every other consumer of
findRelated keeps its current behaviour.

* Update src/panels/config/entities/ha-config-entities.ts

Co-authored-by: Petar Petrov <[email protected]>

* Update src/panels/config/entities/ha-config-entities.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-09-14 13:25:35 +02:00
Petar Petrov 8c0dec0d9a Align stacked statistics lines across recorder gaps (#54166)
ECharts stacks a time axis by data index, so a statistic with a gap or a
different range shifted every stacked line after it. Merge the stacked line
datasets onto one timeline, let ECharts sample them after stacking, and hide
the padded null rows in the tooltip.
2026-09-14 13:49:48 +03:00
renovate[bot] 2c4b3c9109 Update dependency @rsdoctor/rspack-plugin to v1.6.4 (#54170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 13:34:56 +03:00
Petar Petrov c75bd2b5f0 Fix inconsistent grid labels in energy usage graph (#54168)
Make energy usage graph grid consumption label name-first
2026-09-14 12:07:18 +02:00
Wendelin 9b1665f5bf Refactor more-info update backup row (#54119)
* Extract and refactor update-backup

* run requests in parallel

* Separate fetches

* fix
2026-09-14 10:53:46 +02:00
Wendelin 603528bf47 Landing page: provide and use i18n context (#54128)
Migrate landing page to internationalization context
2026-09-14 10:45:08 +02:00
Wendelin 030b962e26 ha-auth use i18n context (#54127)
Use i18n context in ha-auth
2026-09-14 10:39:07 +02:00
Wendelin 85a34aeeee Onboarding: use localize context (#54126)
* Migrate onboarding to use localize context

* Load onboarding fragment translations after hass initialization.
2026-09-14 10:29:02 +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
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
233 changed files with 8076 additions and 3522 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 -4
View File
@@ -9,6 +9,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
import "../../src/components/ha-drawer";
@@ -682,10 +683,7 @@ class HaGallery extends LitElement {
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
formatEntityName: computeEntityNameDisplayWithoutContext,
} as unknown as HomeAssistant;
}
@@ -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"],
},
},
];
+82 -2
View File
@@ -12,7 +12,10 @@ import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { ClimateEntityFeature } from "../../../../src/data/climate";
import { FanEntityFeature } from "../../../../src/data/fan";
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type {
GridCardConfig,
TileCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
const ENTITIES = [
{
@@ -163,6 +166,39 @@ const ENTITIES = [
FanEntityFeature.OSCILLATE,
},
},
{
entity_id: "fan.three_speed_fan",
state: "on",
attributes: {
friendly_name: "Desk fan",
device_class: "fan",
percentage: 67,
percentage_step: 100 / 3,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.four_speed_fan",
state: "on",
attributes: {
friendly_name: "Bedroom fan",
device_class: "fan",
percentage: 50,
percentage_step: 25,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.five_speed_fan",
state: "on",
attributes: {
friendly_name: "Attic fan",
device_class: "fan",
percentage: 80,
percentage_step: 20,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
const CONFIGS = [
@@ -332,6 +368,50 @@ const CONFIGS = [
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (3 speeds)",
config: {
type: "tile",
entity: "fan.three_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (4 speeds)",
config: {
type: "tile",
entity: "fan.four_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (5 speeds)",
config: {
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (half width)",
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{
type: "tile",
entity: "fan.four_speed_fan",
features: [{ type: "fan-speed" }],
},
{
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed" }],
},
],
},
},
{
heading: "Fan oscillate feature",
config: {
@@ -418,7 +498,7 @@ const CONFIGS = [
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
] satisfies DemoCardConfig<TileCardConfig | GridCardConfig>[];
@customElement("demo-lovelace-tile-card")
class DemoTile extends LitElement {
+55
View File
@@ -21,6 +21,61 @@ const ENTITIES = [
FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.one_speed_fan",
state: "on",
attributes: {
friendly_name: "One speed fan",
device_class: "fan",
percentage: 100,
percentage_step: 100,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.two_speed_fan",
state: "on",
attributes: {
friendly_name: "Two speed fan",
device_class: "fan",
percentage: 50,
percentage_step: 50,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.three_speed_fan",
state: "on",
attributes: {
friendly_name: "Three speed fan",
device_class: "fan",
percentage: 67,
percentage_step: 100 / 3,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.four_speed_fan",
state: "on",
attributes: {
friendly_name: "Four speed fan",
device_class: "fan",
percentage: 50,
percentage_step: 25,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.five_speed_fan",
state: "on",
attributes: {
friendly_name: "Five speed fan",
device_class: "fan",
percentage: 80,
percentage_step: 20,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
@customElement("demo-more-info-fan")
@@ -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",
@@ -1,8 +1,9 @@
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
import { fireEvent } from "../../../src/common/dom/fire_event";
import type {
LandingPageKeys,
@@ -34,9 +35,6 @@ const SCHEDULE_FETCH_OBSERVER_LOGS = 5;
@customElement("landing-page-logs")
class LandingPageLogs extends LitElement {
@property({ attribute: false })
public localize!: LocalizeFunc<LandingPageKeys>;
@query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml;
@query(".logs") private _logElement?: HTMLElement;
@@ -44,6 +42,10 @@ class LandingPageLogs extends LitElement {
@query("#scroll-bottom-marker")
private _scrollBottomMarkerElement?: HTMLElement;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc<LandingPageKeys>;
@state() private _show = false;
@state() private _scrolledToBottomController =
@@ -63,12 +65,12 @@ class LandingPageLogs extends LitElement {
return html`
<div class="actions">
<ha-button appearance="plain" @click=${this._toggleLogDetails}>
${this.localize(this._show ? "hide_details" : "show_details")}
${this._localize(this._show ? "hide_details" : "show_details")}
</ha-button>
${
this._show
? html`<ha-icon-button
.label=${this.localize("logs.download_logs")}
.label=${this._localize("logs.download_logs")}
.path=${mdiDownload}
@click=${this._downloadLogs}
></ha-icon-button>`
@@ -80,14 +82,14 @@ class LandingPageLogs extends LitElement {
? html`
<ha-alert
alert-type="error"
.title=${this.localize("logs.fetch_error")}
.title=${this._localize("logs.fetch_error")}
>
<ha-button
size="small"
variant="danger"
@click=${this._startLogStream}
>
${this.localize("logs.retry")}
${this._localize("logs.retry")}
</ha-button>
</ha-alert>
`
@@ -115,7 +117,7 @@ class LandingPageLogs extends LitElement {
@click=${this._scrollToBottom}
>
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="start"></ha-svg-icon>
${this.localize("logs.scroll_down_button")}
${this._localize("logs.scroll_down_button")}
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="end"></ha-svg-icon>
</ha-button>
`;
@@ -1,35 +1,37 @@
import { LitElement, css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { type CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
import { fireEvent } from "../../../src/common/dom/fire_event";
import type {
LandingPageKeys,
LocalizeFunc,
} from "../../../src/common/translations/localize";
import "../../../src/components/ha-button";
import "../../../src/components/ha-alert";
import "../../../src/components/ha-button";
import type { NetworkInterface } from "../../../src/data/hassio/network";
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import {
ALTERNATIVE_DNS_SERVERS,
setSupervisorNetworkDns,
type NetworkInfo,
} from "../data/supervisor";
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import type { NetworkInterface } from "../../../src/data/hassio/network";
import { fireEvent } from "../../../src/common/dom/fire_event";
@customElement("landing-page-network")
class LandingPageNetwork extends LitElement {
@property({ attribute: false })
public localize!: LocalizeFunc<LandingPageKeys>;
@property({ attribute: false }) public networkInfo?: NetworkInfo;
@property({ type: Boolean }) public error = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc<LandingPageKeys>;
protected render() {
if (this.error) {
return html`
<ha-alert alert-type="error">
<p>${this.localize("network_issue.error_get_network_info")}</p>
<p>${this._localize("network_issue.error_get_network_info")}</p>
</ha-alert>
`;
}
@@ -47,19 +49,21 @@ class LandingPageNetwork extends LitElement {
return html`
<ha-alert
alert-type="warning"
.title=${this.localize("network_issue.title")}
.title=${this._localize("network_issue.title")}
>
<p>
${this.localize("network_issue.description", {
${this._localize("network_issue.description", {
dns: dnsPrimaryInterfaceNameservers || "?",
})}
</p>
<p>${this.localize("network_issue.resolve_different")}</p>
<p>${this._localize("network_issue.resolve_different")}</p>
${
!dnsPrimaryInterfaceNameservers
? html`
<p>
<b>${this.localize("network_issue.no_primary_interface")} </b>
<b
>${this._localize("network_issue.no_primary_interface")}
</b>
</p>
`
: nothing
@@ -72,7 +76,7 @@ class LandingPageNetwork extends LitElement {
.index=${key}
.disabled=${!dnsPrimaryInterfaceNameservers}
@click=${this._setDns}
>${this.localize(translationKey)}</ha-button
>${this._localize(translationKey)}</ha-button
>`
)}
</div>
@@ -118,12 +122,12 @@ class LandingPageNetwork extends LitElement {
// eslint-disable-next-line no-console
console.error(err);
showAlertDialog(this, {
title: this.localize("network_issue.failed"),
title: this._localize("network_issue.failed"),
warning: true,
text: `${this.localize(
text: `${this._localize(
"network_issue.set_dns_failed"
)}${err?.message ? ` ${this.localize("network_issue.error")}: ${err.message}` : ""}`,
confirmText: this.localize("network_issue.close"),
)}${err?.message ? ` ${this._localize("network_issue.error")}: ${err.message}` : ""}`,
confirmText: this._localize("network_issue.close"),
});
}
}
+2 -4
View File
@@ -9,6 +9,7 @@ import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import { provideLiteI18nMixin } from "../../src/mixins/provide-lite-i18n-mixin";
import "../../src/onboarding/onboarding-welcome-links";
import { onBoardingStyles } from "../../src/onboarding/styles";
import { haStyle } from "../../src/resources/styles";
@@ -28,7 +29,7 @@ const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
@customElement("ha-landing-page")
class HaLandingPage extends LandingPageBaseElement {
class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
@property({ attribute: false }) public translationFragment = "landing-page";
@state() private _supervisorError = false;
@@ -82,7 +83,6 @@ class HaLandingPage extends LandingPageBaseElement {
networkIssue || this._networkInfoError
? html`
<landing-page-network
.localize=${this.localize}
.networkInfo=${this._networkInfo}
.error=${this._networkInfoError}
@dns-set=${this._fetchSupervisorInfo}
@@ -103,13 +103,11 @@ class HaLandingPage extends LandingPageBaseElement {
: nothing
}
<landing-page-logs
.localize=${this.localize}
@landing-page-error=${this._showError}
></landing-page-logs>
</div>
</ha-card>
<onboarding-welcome-links
.localize=${this.localize}
.mobileApp=${this._mobileApp}
></onboarding-welcome-links>
<div class="footer">
+22 -22
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",
@@ -52,22 +52,22 @@
"@codemirror/view": "6.43.11",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.1",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.13",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.4.0",
"@formatjs/intl-pluralrules": "6.3.13",
"@formatjs/intl-relativetimeformat": "12.3.13",
"@formatjs/intl-datetimeformat": "7.6.2",
"@formatjs/intl-displaynames": "7.3.14",
"@formatjs/intl-durationformat": "0.11.0",
"@formatjs/intl-getcanonicallocales": "3.2.12",
"@formatjs/intl-listformat": "8.3.14",
"@formatjs/intl-locale": "5.3.11",
"@formatjs/intl-numberformat": "9.4.1",
"@formatjs/intl-pluralrules": "6.3.14",
"@formatjs/intl-relativetimeformat": "12.3.14",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@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",
@@ -104,7 +103,7 @@
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.2",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
@@ -115,8 +114,9 @@
"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",
"nanoid": "6.0.1",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
"punycode": "2.3.1",
@@ -139,10 +139,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",
@@ -152,8 +152,8 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.2.2",
"@rsdoctor/rspack-plugin": "1.6.4",
"@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 +195,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 +213,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",
+20 -18
View File
@@ -23,6 +23,7 @@ import type {
} from "../data/data_entry_flow";
import "./ha-auth-form";
import type { HaAuthForm } from "./ha-auth-form";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
type State = "loading" | "error" | "step";
@@ -36,12 +37,14 @@ export class HaAuthFlow extends LitElement {
@property({ attribute: false }) public oauth2State?: string;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public step?: DataEntryFlowStep;
@property({ attribute: false }) public initStoreToken = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _storeToken = false;
@state() private _state: State = "loading";
@@ -184,8 +187,8 @@ export class HaAuthFlow extends LitElement {
>
${
this.step.type === "form"
? this.localize("ui.panel.page-authorize.form.next")
: this.localize("ui.panel.page-authorize.form.start_over")
? this._localize("ui.panel.page-authorize.form.next")
: this._localize("ui.panel.page-authorize.form.start_over")
}
</ha-button>
</div>
@@ -193,20 +196,20 @@ export class HaAuthFlow extends LitElement {
case "error":
return html`
<ha-alert alert-type="error">
${this.localize("ui.panel.page-authorize.form.error", {
${this._localize("ui.panel.page-authorize.form.error", {
error: this._errorMessage,
})}
</ha-alert>
<div class="action">
<ha-button @click=${this._startOver}>
${this.localize("ui.panel.page-authorize.form.start_over")}
${this._localize("ui.panel.page-authorize.form.start_over")}
</ha-button>
</div>
`;
case "loading":
return html`
<ha-alert alert-type="info">
${this.localize("ui.panel.page-authorize.form.working")}
${this._localize("ui.panel.page-authorize.form.working")}
</ha-alert>
`;
default:
@@ -218,8 +221,8 @@ export class HaAuthFlow extends LitElement {
switch (step.type) {
case "abort":
return html`
${this.localize("ui.panel.page-authorize.abort_intro")}:
${this.localize(
${this._localize("ui.panel.page-authorize.abort_intro")}:
${this._localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.abort.${step.reason}`
)}
`;
@@ -228,15 +231,14 @@ export class HaAuthFlow extends LitElement {
<h1>
${
!["select_mfa_module", "mfa"].includes(step.step_id)
? this.localize("ui.panel.page-authorize.welcome_home")
: this.localize("ui.panel.page-authorize.just_checking")
? this._localize("ui.panel.page-authorize.welcome_home")
: this._localize("ui.panel.page-authorize.just_checking")
}
</h1>
${this._computeStepDescription(step)}
${keyed(
step.step_id,
html`<ha-auth-form
.localize=${this.localize}
.data=${this._stepData!}
.schema=${autocompleteLoginFields(step.data_schema)}
.error=${step.errors}
@@ -256,7 +258,7 @@ export class HaAuthFlow extends LitElement {
.checked=${this._storeToken}
@change=${this._storeTokenChanged}
>
${this.localize("ui.panel.page-authorize.store_token")}
${this._localize("ui.panel.page-authorize.store_token")}
</ha-checkbox>
`
: ""
@@ -266,7 +268,7 @@ export class HaAuthFlow extends LitElement {
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
target="_blank"
rel="noreferrer noopener"
>${this.localize("ui.panel.page-authorize.forgot_password")}</a
>${this._localize("ui.panel.page-authorize.forgot_password")}</a
>
</div>
`;
@@ -336,13 +338,13 @@ export class HaAuthFlow extends LitElement {
private _computeStepDescription(step: DataEntryFlowStepForm) {
const resourceKey =
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.description` as const;
return this.localize(resourceKey, step.description_placeholders);
return this._localize(resourceKey, step.description_placeholders);
}
private _computeLabelCallback(step: DataEntryFlowStepForm) {
// Returns a callback for ha-form to calculate labels per schema object
return (schema) =>
this.localize(
this._localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.data.${schema.name}`
);
}
@@ -350,13 +352,13 @@ export class HaAuthFlow extends LitElement {
private _computeErrorCallback(step: DataEntryFlowStepForm) {
// Returns a callback for ha-form to calculate error messages
return (error) =>
this.localize(
this._localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.error.${error}`
);
}
private _unknownError() {
return this.localize("ui.panel.page-authorize.form.unknown_error");
return this._localize("ui.panel.page-authorize.form.unknown_error");
}
private _startOver() {
+6 -3
View File
@@ -1,6 +1,7 @@
/* eslint-disable lit/prefer-static-styles */
import { html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaForm } from "../components/ha-form/ha-form";
import "./ha-auth-form-string";
@@ -9,11 +10,13 @@ const localizeBaseKey = "ui.panel.page-authorize.form";
@customElement("ha-auth-form")
export class HaAuthForm extends HaForm {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected getFormProperties(): Record<string, any> {
return {
localize: this.localize,
localize: this._localize,
localizeBaseKey,
};
}
+4 -3
View File
@@ -12,6 +12,7 @@ import "../components/ha-svg-icon";
import type { AuthProvider, AuthUrlSearchParams } from "../data/auth";
import { fetchAuthProviders } from "../data/auth";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
import { provideLiteI18nMixin } from "../mixins/provide-lite-i18n-mixin";
import { registerServiceWorker } from "../util/register-service-worker";
import "./ha-auth-flow";
@@ -23,7 +24,9 @@ const appNames = {
};
@customElement("ha-authorize")
export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
export class HaAuthorize extends provideLiteI18nMixin(
litLocalizeLiteMixin(LitElement)
) {
@property({ attribute: false }) public clientId?: string;
@property({ attribute: false }) public redirectUri?: string;
@@ -183,14 +186,12 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
.redirectUri=${this.redirectUri}
.oauth2State=${this.oauth2State}
.authProvider=${this._authProvider}
.localize=${this.localize}
.initStoreToken=${this._preselectStoreToken}
></ha-auth-flow>
${
inactiveProviders!.length > 0
? html`
<ha-pick-auth-provider
.localize=${this.localize}
.clientId=${this.clientId}
.authProviders=${inactiveProviders!}
@pick-auth-provider=${this._handleAuthProviderPick}
+6 -3
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, 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-icon-next";
@@ -20,13 +21,15 @@ declare global {
export class HaPickAuthProvider extends LitElement {
@property({ attribute: false }) public authProviders: AuthProvider[] = [];
@property({ attribute: false }) public localize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
return html`
<h3>
<span
>${this.localize("ui.panel.page-authorize.pick_auth_provider")}</span
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
+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;
};
+114 -35
View File
@@ -1,44 +1,70 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
import {
computeEntityEntryName,
computeEntityName,
entityUseDeviceName,
} from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import { getEntityContext } from "./context/get_entity_context";
import {
getEntityContext,
getEntityEntryContext,
} from "./context/get_entity_context";
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
const DEFAULT_SEPARATOR = " ";
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export { DEFAULT_ENTITY_NAME, ENTITY_NAME_TYPES } from "./entity_name_config";
export type {
EntityNameItem,
EntityNameOptions,
EntityNameType,
} from "./entity_name_config";
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
// Joins items that need no entity context. Returns undefined as soon as one
// item references the registries (device, area, ...).
const computeTextOnlyName = (
items: EntityNameItem[],
options?: EntityNameOptions
): string | undefined =>
items.every((n) => n.type === "text")
? items
.map((item) => item.text)
.join(options?.separator ?? DEFAULT_SEPARATOR)
: undefined;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: EntityNameType;
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
/**
* Name formatter used before the registry-aware one is installed
* (see state/connection-mixin and state/state-display-mixin). It honours a
* configured string or text-only name and falls back to the friendly name for
* anything that needs entity context, so a configured name is never dropped
* while the real formatter is still loading.
*/
export const computeEntityNameDisplayWithoutContext = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
options?: EntityNameOptions
): string => {
if (typeof name === "string") {
return name;
}
if (!name) {
return computeStateName(stateObj);
}
return (
computeTextOnlyName(ensureArray(name), options) ??
computeStateName(stateObj)
);
};
export const computeEntityNameDisplay = (
stateObj: HassEntity,
@@ -63,8 +89,9 @@ export const computeEntityNameDisplay = (
const separator = options?.separator ?? DEFAULT_SEPARATOR;
// If all items are text, just join them
if (items.every((n) => n.type === "text")) {
return items.map((item) => item.text).join(separator);
const textOnlyName = computeTextOnlyName(items, options);
if (textOnlyName !== undefined) {
return textOnlyName;
}
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
@@ -102,18 +129,49 @@ export const computeEntityNameList = (
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityContext(
stateObj,
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
return name.map((item) =>
item.type === "entity"
? computeStateName(stateObj)
: item.type === "text"
? item.text
: undefined
);
}
return computeEntityEntryNameList(
entry,
name,
entities,
devices,
areas,
floors
);
};
export const computeEntityEntryNameList = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityEntryContext(
entry,
entities,
devices,
areas,
floors
);
const names = name.map((item) => {
return name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityName(stateObj, entities, devices);
return computeEntityEntryName(entry, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "parent_device":
@@ -128,8 +186,29 @@ export const computeEntityNameList = (
return "";
}
});
};
return names;
export const computeEntitySearchLabels = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
) => {
const { device, parentDevice, area } = getEntityContext(
stateObj,
entities,
devices,
areas,
floors
);
return {
entityName: computeEntityName(stateObj, entities, devices) || null,
friendlyName: computeStateName(stateObj) || null,
deviceName: (device && computeDeviceName(device)) || null,
parentDeviceName: (parentDevice && computeDeviceName(parentDevice)) || null,
areaName: (area && computeAreaName(area)) || null,
};
};
export interface EntityPickerDisplay {
+34
View File
@@ -0,0 +1,34 @@
/**
* Shape of a composed entity name, kept free of runtime dependencies so that
* modules in the core entry chunk can name an entity without pulling in the
* registry-aware formatters from `compute_entity_name_display`.
*/
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: EntityNameType;
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
+2 -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",
@@ -95,6 +95,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
"door",
"garage_door",
"gas",
"glass_break",
"heat",
"light",
"lock",
+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":
+1 -1
View File
@@ -33,7 +33,7 @@ export type FormatEntityAttributeNameFunc = (
export type FormatEntityNameFunc = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
name: string | EntityNameItem | EntityNameItem[] | undefined,
options?: EntityNameOptions
) => string;
@@ -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;
},
});
@@ -90,6 +90,7 @@ export class HaAutomationConditionSummary extends LitElement {
></ha-automation-row-options>`
: nothing
}
<slot name="references"></slot>
${
note
? html`
@@ -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;
+47 -1
View File
@@ -53,6 +53,41 @@ export interface StatisticsChartData {
yAxisFractionDigits: number;
}
// ECharts stacks a time axis by data index. Merge the sorted point lists so
// every stacked line has the same x at every index, padding with null.
function alignStackedLines(datasets: LineSeriesOption[]) {
const sources = datasets.map((d) => d.data as [number, number | null][]);
const cursors = sources.map(() => 0);
const counts = sources.map(() => 0);
const aligned = sources.map((): [number, number | null][] => []);
while (cursors.some((cursor, i) => cursor < sources[i].length)) {
let x = Infinity;
for (let i = 0; i < sources.length; i++) {
const point = sources[i][cursors[i]];
if (point && point[0] < x) x = point[0];
}
let slots = 1;
for (let i = 0; i < sources.length; i++) {
let count = 0;
while (sources[i][cursors[i] + count]?.[0] === x) count++;
counts[i] = count;
if (count > slots) slots = count;
}
for (let i = 0; i < sources.length; i++) {
const count = counts[i];
for (let slot = 0; slot < slots; slot++) {
aligned[i].push(
count ? sources[i][cursors[i] + Math.min(slot, count - 1)] : [x, null]
);
}
cursors[i] += count;
}
}
datasets.forEach((d, i) => {
d.data = aligned[i];
});
}
/**
* Transforms raw statistics into ECharts series for `statistics-chart`.
* Pure data processing: all environment inputs (current time, theme style,
@@ -263,8 +298,10 @@ export function generateStatisticsChartData(
),
symbol: "none",
// minmax sampling operates independently per series, breaking stacking alignment
// echarts stacks before it samples, so its lttb keeps stacks aligned
// https://github.com/apache/echarts/issues/11879
sampling: band && drawBands ? "lttb" : "minmax",
sampling:
band || (chartStacked && chartType === "line") ? "lttb" : "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
@@ -439,6 +476,15 @@ export function generateStatisticsChartData(
Array.prototype.push.apply(legendData, statLegendData);
});
if (chartType === "line" && chartStacked) {
const stacked = (totalDataSets as LineSeriesOption[]).filter(
(d) => d.data?.length
);
if (stacked.length > 1) {
alignStackedLines(stacked);
}
}
if (chartType === "bar") {
fillDataGapsAndRoundCaps(totalDataSets as BarSeriesOption[], chartStacked);
}
+2
View File
@@ -253,6 +253,8 @@ export class StatisticsChart extends LitElement {
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
// stacked lines are padded with null where a statistic has no data
if (!chartIsBar && param.value[1] === null) continue;
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
+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
@@ -136,9 +136,7 @@ export class HaEntityNamePicker extends LitElement {
${
this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
<ha-input-helper-text> ${this.helper} </ha-input-helper-text>
`
: nothing
}
@@ -329,11 +329,7 @@ export class HaStateContentPicker extends LitElement {
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
: nothing;
}
+37 -78
View File
@@ -11,9 +11,10 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import { domainToName } from "../../data/integration";
import {
getStatisticLabel,
@@ -233,11 +234,6 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const output: StatisticComboBoxItem[] = [];
statisticIds.forEach((meta) => {
@@ -291,31 +287,17 @@ export class HaStatisticPicker extends LitElement {
}
const id = meta.statistic_id;
const friendlyName = computeStateName(stateObj); // Keep this for search
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,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const { primary, secondary } = computeEntityPickerDisplay(
hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
@@ -325,14 +307,12 @@ export class HaStatisticPicker extends LitElement {
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
sorting_label: [
sortingPrefix,
searchLabels.deviceName,
searchLabels.entityName,
].join("_"),
search_labels: searchLabels,
});
});
@@ -407,35 +387,17 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
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,
this.hass.translationMetadata.translations
const { primary, secondary } = computeEntityPickerDisplay(
this.hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const primary = entityName || deviceName || statisticId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
@@ -445,15 +407,12 @@ export class HaStatisticPicker extends LitElement {
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
},
sorting_label: [
sortingPrefix,
searchLabels.deviceName,
searchLabels.entityName,
].join("_"),
search_labels: { ...searchLabels, statisticId },
};
}
+16 -13
View File
@@ -1,6 +1,7 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, 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 type { Analytics, AnalyticsPreferences } from "../data/analytics";
@@ -20,13 +21,15 @@ declare global {
@customElement("ha-analytics")
export class HaAnalytics extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public analytics?: Analytics;
@property({ attribute: "translation_key_panel" }) public translationKeyPanel:
"page-onboarding" | "config" = "config";
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
const loading = this.analytics === undefined;
const baseEnabled = !loading && this.analytics!.preferences.base;
@@ -34,12 +37,12 @@ export class HaAnalytics extends LitElement {
return html`
<ha-row-item>
<span slot="headline"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.description`
)}</span
>
@@ -51,7 +54,7 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="base"
>
${this.localize(
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}
</ha-switch>
@@ -60,12 +63,12 @@ export class HaAnalytics extends LitElement {
(preference) => html`
<ha-row-item>
<span slot="headline"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.description`
)}</span
>
@@ -77,7 +80,7 @@ export class HaAnalytics extends LitElement {
.preference=${preference}
name=${preference}
>
${this.localize(
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}
</ha-switch>
@@ -88,7 +91,7 @@ export class HaAnalytics extends LitElement {
.for="switch-${preference}"
placement="right"
>
${this.localize(
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</ha-tooltip>`
@@ -98,12 +101,12 @@ export class HaAnalytics extends LitElement {
)}
<ha-row-item>
<span slot="headline"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.description`
)}</span
>
@@ -115,7 +118,7 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="diagnostics"
>
${this.localize(
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}
</ha-switch>
+5 -30
View File
@@ -3,8 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
import { computeRTL } from "../common/util/compute_rtl";
import { computeEntityPickerDisplay } from "../common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../common/translations/localize";
import {
multiTermSortedSearch,
@@ -183,11 +182,6 @@ export class HaAreaControlsPicker extends LitElement {
const allEntityIds = Object.values(controlEntities).flat();
const uniqueEntityIds = Array.from(new Set(allEntityIds));
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
uniqueEntityIds.forEach((entityId) => {
if (isSelected(entityId)) {
return;
@@ -197,29 +191,10 @@ export class HaAreaControlsPicker extends LitElement {
return;
}
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,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const { primary, secondary } = computeEntityPickerDisplay(
this.hass!,
stateObj
);
entityItems.push({
type: "entity",
+1 -3
View File
@@ -312,9 +312,7 @@ export class HaBaseTimeInput extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
@@ -198,11 +198,7 @@ export class HaClockDateFormatPicker extends LitElement {
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
: nothing;
}
+55 -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]);
@@ -254,6 +272,11 @@ export class HaDurationInput extends LitElement {
display: flex;
align-items: center;
}
/* Full width: bigger touch targets, no ragged right edge in a form. */
ha-base-time-input {
flex: 1;
--time-input-flex: 1;
}
ha-button-toggle-group {
margin: var(--ha-space-2);
}
+15 -12
View File
@@ -27,9 +27,9 @@ declare global {
@customElement("ha-file-upload")
export class HaFileUpload extends LitElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() @consumeLocalize() private _localize?: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -92,7 +92,6 @@ export class HaFileUpload extends LitElement {
}
public render(): TemplateResult {
const localize = this.localize || this._localize!;
return html`
${
this.uploading
@@ -102,10 +101,13 @@ export class HaFileUpload extends LitElement {
>${
this.uploadingLabel ||
(this.value
? localize("ui.components.file-upload.uploading_name", {
name: this._name,
})
: localize("ui.components.file-upload.uploading"))
? this._localize(
"ui.components.file-upload.uploading_name",
{
name: this._name,
}
)
: this._localize("ui.components.file-upload.uploading"))
}</span
>
${
@@ -147,12 +149,12 @@ export class HaFileUpload extends LitElement {
slot="start"
.path=${this.icon || mdiFileUpload}
></ha-svg-icon>
${this.label || localize("ui.components.file-upload.label")}
${this.label || this._localize("ui.components.file-upload.label")}
</ha-button>
<span class="secondary"
>${
this.secondary ||
localize("ui.components.file-upload.secondary")
this._localize("ui.components.file-upload.secondary")
}</span
>
<span class="supports">${this.supports}</span>`
@@ -166,7 +168,7 @@ export class HaFileUpload extends LitElement {
</div>
<ha-icon-button
@click=${this._clearValue}
.label=${this.deleteLabel || localize("ui.common.delete")}
.label=${this.deleteLabel || this._localize("ui.common.delete")}
.path=${mdiDelete}
></ha-icon-button>
</div>`
@@ -185,7 +187,8 @@ export class HaFileUpload extends LitElement {
<ha-icon-button
@click=${this._clearValue}
.label=${
this.deleteLabel || localize("ui.common.delete")
this.deleteLabel ||
this._localize("ui.common.delete")
}
.path=${mdiDelete}
></ha-icon-button>
+11 -1
View File
@@ -60,6 +60,9 @@ export class HaFilterDevices extends LitElement {
@property() public type?: keyof RelatedResult;
@property({ type: Boolean, attribute: "include-disabled-entities" })
public includeDisabledEntities = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@property({ type: Boolean }) public narrow = false;
@@ -227,7 +230,14 @@ export class HaFilterDevices extends LitElement {
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this._api, "device", deviceId));
relatedPromises.push(
findRelated(
this._api,
"device",
deviceId,
this.includeDisabledEntities
)
);
}
}
const results = await Promise.all(relatedPromises);
+14 -2
View File
@@ -65,6 +65,9 @@ export class HaFilterFloorAreas extends LitElement {
@property() public type?: keyof RelatedResult;
@property({ type: Boolean, attribute: "include-disabled-entities" })
public includeDisabledEntities = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@@ -295,7 +298,9 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "area", areaId));
relatedPromises.push(
findRelated(this._api, "area", areaId, this.includeDisabledEntities)
);
}
}
}
@@ -303,7 +308,14 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "floor", floorId));
relatedPromises.push(
findRelated(
this._api,
"floor",
floorId,
this.includeDisabledEntities
)
);
}
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
? html`<ha-input-helper-text
>${this.helper}</ha-input-helper-text
>`
: nothing
+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")
+1 -1
View File
@@ -329,7 +329,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
return html`<ha-input-helper-text>
${
showError
? html`<span class="error">${this.errorMessage}</span> ${
+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;
}
}
+1 -6
View File
@@ -1,11 +1,9 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement } from "lit/decorators";
@customElement("ha-input-helper-text")
class InputHelperText extends LitElement {
@property({ type: Boolean, reflect: true }) disabled = false;
protected render(): TemplateResult {
return html`<slot></slot>`;
}
@@ -25,9 +23,6 @@ class InputHelperText extends LitElement {
);
line-height: normal;
}
:host([disabled]) {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
`;
}
+1 -3
View File
@@ -50,9 +50,7 @@ class HaLabeledSlider extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>`
? html`<ha-input-helper-text> ${this.helper} </ha-input-helper-text>`
: nothing
}
`;
@@ -28,6 +28,11 @@ const LAWN_MOWER_ACTIONS: Partial<
service: "start_mowing",
feature: LawnMowerEntityFeature.START_MOWING,
},
idle: {
action: "start_mowing",
service: "start_mowing",
feature: LawnMowerEntityFeature.START_MOWING,
},
returning: {
action: "pause",
service: "pause",
+1 -3
View File
@@ -239,9 +239,7 @@ export class HaMediaItemPicker extends LitElement {
}
${
hideEntityPicker && this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
-26
View File
@@ -1,26 +0,0 @@
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-outlined-button")
export class HaOutlinedButton extends OutlinedButton {
static override styles = [
sharedStyles,
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-outline: var(--outline-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-button": HaOutlinedButton;
}
}
-36
View File
@@ -1,36 +0,0 @@
import { OutlinedField } from "@material/web/field/internal/outlined-field";
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { literal } from "lit/static-html";
@customElement("ha-outlined-field")
export class HaOutlinedField extends OutlinedField {
protected readonly fieldTag = literal`ha-outlined-field`;
static override styles = [
sharedStyles,
styles,
css`
.container::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--ha-outlined-field-container-color, transparent);
opacity: var(--ha-outlined-field-container-opacity, 1);
border-start-start-radius: var(--_container-shape-start-start);
border-start-end-radius: var(--_container-shape-start-end);
border-end-start-radius: var(--_container-shape-end-start);
border-end-end-radius: var(--_container-shape-end-end);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-field": HaOutlinedField;
}
}
+1 -3
View File
@@ -164,9 +164,7 @@ export class HaSelect extends LitElement {
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
@@ -72,9 +72,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
></ha-select-box>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
@@ -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];
@@ -68,9 +68,7 @@ export class HaDateTimeSelector extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: ""
}
`;
@@ -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() {
@@ -143,9 +143,7 @@ export class HaNumberSelector extends LitElement {
</div>
${
!isBox && this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
@@ -204,9 +204,7 @@ export class HaObjectSelector extends LitElement {
></ha-yaml-editor>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: ""
} `;
}
@@ -268,9 +268,7 @@ export class HaSelectSelector extends LitElement {
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: "";
}
@@ -132,9 +132,7 @@ ${
}
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
-42
View File
@@ -1,42 +0,0 @@
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-sub-menu")
export class HaSubMenu extends SubMenu {
async show() {
super.show();
this.menu.hasOverflow = false;
}
static override styles = [
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-primary: var(--primary-text-color);
--md-sys-color-secondary: var(--secondary-text-color);
--md-sys-color-surface: var(--card-background-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-sys-color-secondary-container: rgba(
var(--rgb-primary-color),
0.15
);
--md-sys-color-on-secondary-container: var(--text-primary-color);
--mdc-icon-size: 16px;
--md-sys-color-on-primary-container: var(--primary-text-color);
--md-sys-color-on-secondary-container: var(--primary-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-sub-menu": HaSubMenu;
}
}
+1 -3
View File
@@ -208,9 +208,7 @@ class HaInputMulti extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
+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 {
@@ -7,9 +7,11 @@ import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import {
areasContext,
@@ -107,9 +109,6 @@ export class HaMediaPlayerPicker extends LitElement {
const webBrowserLabel = this._i18n.localize(
"ui.components.media-browser.web-browser"
);
const lang = this._i18n.language || "en";
const isRTL =
this._i18n.translationMetadata.translations[lang]?.isRTL || false;
return [
{
@@ -128,34 +127,22 @@ export class HaMediaPlayerPicker extends LitElement {
...Object.values(this._states)
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
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,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const { primary, secondary } = computeEntityPickerDisplay(
{
entities: this._entities,
devices: this._devices,
areas: this._areas,
floors: this._floors,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
);
return {
id: entityId,
@@ -165,12 +152,14 @@ export class HaMediaPlayerPicker extends LitElement {
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
...computeEntitySearchLabels(
stateObj,
this._entities,
this._devices,
this._areas,
this._floors
),
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId,
},
stateObj,
@@ -7,8 +7,7 @@ import memoizeOne from "memoize-one";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../common/util/compute_rtl";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import {
areasContext,
devicesContext,
@@ -54,40 +53,24 @@ class HaMediaPlayerToggle extends LitElement {
private _computeDisplayData = memoizeOne(
(
entityId: string,
entities: ContextType<typeof entitiesContext>,
devices: ContextType<typeof devicesContext>,
areas: ContextType<typeof areasContext>,
floors: ContextType<typeof floorsContext>,
isRTL: boolean,
i18n: ContextType<typeof internationalizationContext>,
stateObj: HassEntity
) => {
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
) =>
computeEntityPickerDisplay(
{
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return { primary, secondary };
}
floors,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
},
stateObj
)
);
protected render() {
@@ -104,18 +87,12 @@ class HaMediaPlayerToggle extends LitElement {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
this._entities,
this._devices,
this._areas,
this._floors,
isRTL,
this._i18n,
stateObj
);
@@ -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,
@@ -28,9 +26,11 @@ import {
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import {
getDeviceArea,
getDeviceAreaId,
} from "../../common/entity/context/get_device_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
@@ -59,10 +59,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")
@@ -313,19 +315,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
}
`;
@@ -401,125 +391,25 @@ export class HaTargetPickerItemRow extends LitElement {
return this._renderEmptyEntries();
}
let nextType: TargetType =
this.type === "floor"
? "area"
: this.type === "area"
? "device"
: "entity";
if (this.type === "label") {
if (entries?.referenced_areas.length) {
nextType = "area";
} else if (entries?.referenced_devices.length) {
nextType = "device";
}
}
const rows1 =
(nextType === "area"
? entries?.referenced_areas
: nextType === "device" && this.type !== "label"
? entries?.referenced_devices
: this.type !== "label"
? entries?.referenced_entities
: []) || [];
const devicesInAreas = [] as string[];
const rows1Entries =
nextType === "entity"
? undefined
: rows1.map((rowItem) => {
const nextEntries = {
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities: [] as string[],
};
if (nextType === "area") {
nextEntries.referenced_devices =
entries?.referenced_devices.filter(
(device_id) =>
this.hass.devices?.[device_id]?.area_id === rowItem &&
entries?.referenced_entities.some(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
)
) || ([] as string[]);
devicesInAreas.push(...nextEntries.referenced_devices);
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
nextEntries.referenced_devices.includes(entity.device_id)
);
}) || ([] as string[]);
return nextEntries;
}
nextEntries.referenced_entities =
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === rowItem
) || ([] as string[]);
return nextEntries;
});
const entityRows =
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
);
})
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id]?.area_id === this.itemId
)
: [];
const deviceRows =
this.type === "label" && entries
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id]?.labels.includes(this.itemId)
)
: [];
const deviceRowsEntries =
deviceRows.length === 0
? undefined
: deviceRows.map((device_id) => ({
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities:
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
) || ([] as string[]),
}));
const {
nextType,
rows,
rowEntries,
deviceRows,
deviceRowEntries,
entityRows,
} = computeTargetSubRows(
this.type,
this.itemId,
entries,
this.hass.entities,
this.hass.devices
);
const nextSubLevel = this.subLevel + 1;
return html`
${rows1.map(
${rows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
@@ -528,7 +418,7 @@ export class HaTargetPickerItemRow extends LitElement {
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.parentEntries=${rowEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
@@ -543,7 +433,7 @@ export class HaTargetPickerItemRow extends LitElement {
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.parentEntries=${deviceRowEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
@@ -624,32 +514,53 @@ export class HaTargetPickerItemRow extends LitElement {
let referencedDevices = entries.referenced_devices;
const hiddenDeviceIds: string[] = [];
// Parents kept only so a matching child device can nest under them;
// their own entities stay filtered out like a hidden device's.
const groupOnlyDeviceIds = new Set<string>();
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
const matchingDeviceIds = new Set(
referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
return (
!!device &&
!hiddenAreaIds.includes(
getDeviceAreaId(device, this.hass.devices) || ""
) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
);
})
);
const parentsOfMatching = new Set(
[...matchingDeviceIds].map(
(device_id) => this.hass.devices[device_id].parent_device_id
)
);
referencedDevices = referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, like the area filtering above.
if (!this.hass.devices[device_id]) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
if (matchingDeviceIds.has(device_id)) {
return true;
}
if (parentsOfMatching.has(device_id)) {
groupOnlyDeviceIds.add(device_id);
return true;
}
hiddenDeviceIds.push(device_id);
return false;
});
@@ -663,7 +574,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 (
@@ -757,40 +671,12 @@ export class HaTargetPickerItemRow extends LitElement {
}
if (type === "entity") {
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device, parentDevice } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { 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,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
const { primary, secondary } = stateObject
? computeEntityPickerDisplay(this.hass, stateObject)
: { primary: item, secondary: undefined };
return {
name: entityName || deviceName || item,
context,
name: primary,
context: secondary,
stateObject,
notFound: !stateObject && item !== "all" && item !== "none",
};
@@ -326,7 +326,7 @@ export class HaTracePathDetails extends LitElement {
this._entityReg,
currentDetail,
undefined,
false,
undefined,
this._manifests
)
: selectedType === "chooseOption"
+1 -1
View File
@@ -301,7 +301,7 @@ export interface TemplateCondition extends BaseCondition {
export interface TriggerCondition extends BaseCondition {
condition: "trigger";
id: string;
id: string | string[];
}
type ShorthandBaseCondition = Omit<BaseCondition, "condition">;
+8
View File
@@ -149,6 +149,8 @@ const formatNumericLimitValue = (
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Rows with trigger-reference chips render the IDs separately.
hideTriggerIds?: boolean;
}
export const describeTrigger = (
@@ -923,6 +925,12 @@ const tryDescribeCondition = (
return condition.alias;
}
if (condition.condition === "trigger" && options?.hideTriggerIds) {
return hass.localize(
`${conditionsTranslationBaseKey}.trigger.description.summary`
);
}
if (!condition.condition) {
const shorthands: ("and" | "or" | "not")[] = ["and", "or", "not"];
for (const key of shorthands) {
+1
View File
@@ -12,6 +12,7 @@ export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
"door",
"garage_door",
"gas",
"glass_break",
"heat",
"light",
"lock",
-7
View File
@@ -21,7 +21,6 @@ import {
} from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -328,12 +327,6 @@ export const computeEnergyLabel = (
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
+12 -36
View File
@@ -1,11 +1,12 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import type { RelatedIdSets } from "../../common/search/related-context";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
@@ -123,31 +124,11 @@ export const getEntities = (
// These values are the same for every entity, so compute them once instead
// of inside the map over (potentially thousands of) entities.
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domainNames = new Map<string, string>();
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
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);
if (domainName === undefined) {
@@ -155,14 +136,7 @@ export const getEntities = (
domainNames.set(domain, domainName);
}
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const { primary, secondary } = computeEntityPickerDisplay(hass, stateObj);
return {
id: `${idPrefix}${entityId}`,
@@ -171,12 +145,14 @@ export const getEntities = (
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
...computeEntitySearchLabels(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
),
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId: entityId,
},
stateObj: stateObj,
+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",
];
+45 -23
View File
@@ -36,23 +36,51 @@ export interface FanEntity extends HassEntityBase {
export type FanDirection = "forward" | "reverse";
export type FanSpeed = "off" | "low" | "medium" | "high" | "on";
// Named speeds are used when there are icons for every step. Fans with more
// steps use numbered speeds ("1", "2", ...) instead.
export type FanSpeed = "off" | "low" | "medium" | "high" | "on" | `${number}`;
export const FAN_SPEEDS: Partial<Record<number, FanSpeed[]>> = {
const FAN_SPEEDS_NAMED: Partial<Record<number, FanSpeed[]>> = {
2: ["off", "on"],
3: ["off", "low", "high"],
4: ["off", "low", "medium", "high"],
};
export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 6;
export function computeFanSpeedCount(stateObj: FanEntity): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
return speedCount;
}
export function computeFanSpeeds(stateObj: FanEntity): FanSpeed[] | undefined {
const speedCount = computeFanSpeedCount(stateObj);
if (speedCount > FAN_SPEED_COUNT_MAX_FOR_BUTTONS) {
return undefined;
}
return (
FAN_SPEEDS_NAMED[speedCount] ?? [
"off",
...Array.from(
{ length: speedCount - 1 },
(_, index) => `${index + 1}` as const
),
]
);
}
export const isNumberedFanSpeed = (speed: FanSpeed): speed is `${number}` =>
!isNaN(Number(speed));
export function fanPercentageToSpeed(
stateObj: FanEntity,
value: number
): FanSpeed {
const step = stateObj.attributes.percentage_step ?? 1;
const speedValue = Math.round(value / step);
const speedCount = Math.round(100 / step) + 1;
const speeds = FAN_SPEEDS[speedCount];
const speeds = computeFanSpeeds(stateObj);
return speeds?.[speedValue] ?? "off";
}
@@ -61,9 +89,8 @@ export function fanSpeedToPercentage(
speed: FanSpeed
): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
const speeds = FAN_SPEEDS[speedCount];
const speeds = computeFanSpeeds(stateObj);
if (!speeds) {
return 0;
@@ -76,27 +103,22 @@ export function fanSpeedToPercentage(
return Math.floor(speedValue * step);
}
export function computeFanSpeedCount(stateObj: FanEntity): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
return speedCount;
}
export function computeFanSpeedIcon(
stateObj: FanEntity,
speed: FanSpeed
): string {
const speedCount = computeFanSpeedCount(stateObj);
const speeds = FAN_SPEEDS[speedCount];
const index = speeds?.indexOf(speed) ?? 1;
return speed === "on"
? mdiFan
: speed === "off"
? mdiFanOff
: [mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3][index - 1];
): string | undefined {
if (speed === "on") {
return mdiFan;
}
if (speed === "off") {
return mdiFanOff;
}
if (isNumberedFanSpeed(speed)) {
return undefined;
}
const index = computeFanSpeeds(stateObj)?.indexOf(speed) ?? 1;
return [mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3][index - 1];
}
export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 4;
export function computeFanSpeedStateDisplay(
stateObj: FanEntity,
+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[]>;
}
+3 -2
View File
@@ -1,5 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import { computeStateName } from "../common/entity/compute_state_name";
import { DEFAULT_ENTITY_NAME } from "../common/entity/entity_name_config";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
import { firstWeekday } from "../common/datetime/first_weekday";
@@ -348,8 +348,9 @@ export const getStatisticLabel = (
): string => {
const entity = hass.states[statisticsId];
if (entity) {
return computeStateName(entity);
return hass.formatEntityName(entity, DEFAULT_ENTITY_NAME);
}
// External statistics have no entity to resolve a name against.
return statisticsMetaData?.name || statisticsId;
};
+11 -6
View File
@@ -6,7 +6,7 @@ import { formatListWithAnds } from "../common/string/format-list";
import { isTemplate } from "../common/string/has-template";
import type { HomeAssistant } from "../types";
import type { Condition } from "./automation";
import { describeCondition } from "./automation_i18n";
import { describeCondition, type DescribeOptions } from "./automation_i18n";
import { localizeDeviceAutomationAction } from "./device/device_automation";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import { domainToName } from "./integration";
@@ -47,7 +47,7 @@ export const describeAction = <T extends ActionType>(
entityRegistry: EntityRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
ignoreAlias = false,
options?: DescribeOptions,
manifests?: DomainManifestLookup
): string => {
try {
@@ -56,7 +56,7 @@ export const describeAction = <T extends ActionType>(
entityRegistry,
action,
actionType,
ignoreAlias,
options,
manifests
);
if (typeof description !== "string") {
@@ -79,10 +79,10 @@ const tryDescribeAction = <T extends ActionType>(
entityRegistry: EntityRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
ignoreAlias = false,
options?: DescribeOptions,
manifests?: DomainManifestLookup
): string => {
if (action.alias && !ignoreAlias) {
if (action.alias && !options?.ignoreAlias) {
return action.alias;
}
if (!actionType) {
@@ -322,7 +322,12 @@ const tryDescribeAction = <T extends ActionType>(
return hass.localize(
`${actionTranslationBaseKey}.check_condition.description.full`,
{
condition: describeCondition(action as Condition, hass, entityRegistry),
condition: describeCondition(
action as Condition,
hass,
entityRegistry,
options
),
}
);
}
+3 -1
View File
@@ -38,10 +38,12 @@ export type ItemType =
export const findRelated = (
hass: Pick<HomeAssistant, "callWS">,
itemType: ItemType,
itemId: string
itemId: string,
includeDisabledEntities = false
): Promise<RelatedResult> =>
hass.callWS<RelatedResult>({
type: "search/related",
item_type: itemType,
item_id: itemId,
...(includeDisabledEntities ? { include_disabled_entities: true } : {}),
});
@@ -102,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;
}
@@ -0,0 +1,318 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { relativeTime } from "../../../../common/datetime/relative_time";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { transform } from "../../../../common/decorators/transform";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/animation/ha-fade-in";
import "../../../../components/ha-switch";
import "../../../../components/item/ha-row-item";
import type { BackupConfig } from "../../../../data/backup";
import { fetchBackupConfig } from "../../../../data/backup";
import {
apiContext,
configContext,
internationalizationContext,
statesContext,
} from "../../../../data/context";
import type { EntitySources } from "../../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../../data/entity/entity_sources";
import { getSupervisorUpdateConfig } from "../../../../data/supervisor/update";
import type { FrontendLocaleData } from "../../../../data/translation";
import type { UpdateEntity, UpdateType } from "../../../../data/update";
import {
getUpdateType,
UpdateEntityFeature,
updateIsInstalling,
} from "../../../../data/update";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantInternationalization,
} from "../../../../types";
@customElement("ha-more-info-update-backup")
export class HaMoreInfoUpdateBackup extends LitElement {
@property({ attribute: false }) public stateObj?: UpdateEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state() private _backupConfigLoading = true;
@state() private _backupConfig?: BackupConfig;
@state() private _createBackupLoading = true;
@state() private _createBackup = false;
@state() private _entitySources?: EntitySources;
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
public get createBackup(): boolean {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return false;
}
return this._createBackup;
}
protected firstUpdated(): void {
this._setupBackup();
}
protected render() {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return nothing;
}
const createBackupTexts = this._computeCreateBackupTexts();
if (!createBackupTexts && !this._backupConfigLoading) {
return nothing;
}
return html`
<ha-row-item
.headline=${createBackupTexts ? createBackupTexts.title : undefined}
.supportingText=${
createBackupTexts ? createBackupTexts.description : undefined
}
>
${
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: nothing
}
${
this._createBackupLoading
? html`<ha-fade-in class="skeleton-end" slot="end" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>`
}
</ha-row-item>
`;
}
private async _setupBackup(): Promise<void> {
if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
this._createBackupLoading = false;
this._backupConfigLoading = false;
return;
}
let type: UpdateType | undefined = undefined;
try {
this._entitySources = await fetchEntitySourcesWithCache({
callWS: this._api.callWS,
states: this._states,
});
type = getUpdateType(this.stateObj!, this._entitySources!);
} catch (err) {
// ignore error, because the generic backup option remains available
// eslint-disable-next-line no-console
console.error(err);
}
if (type === undefined) {
this._createBackupLoading = false;
this._backupConfigLoading = false;
return;
}
this._fetchUpdateBackupConfig(type);
this._fetchBackupConfig(type);
}
private async _fetchUpdateBackupConfig(type: UpdateType) {
try {
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
const config = await getSupervisorUpdateConfig(this._api);
// for home assistant and OS updates
if (this._isHaOrOsUpdate(type)) {
this._createBackup = config.core_backup_before_update;
return;
}
if (type === "addon") {
this._createBackup = config.add_on_backup_before_update;
}
}
} catch (err) {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
this._createBackup = false;
} finally {
this._createBackupLoading = false;
}
}
private async _fetchBackupConfig(type: UpdateType): Promise<void> {
try {
if (this._isHaOrOsUpdate(type)) {
const { config } = await fetchBackupConfig(this._api);
this._backupConfig = config;
}
} catch (err) {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
} finally {
this._backupConfigLoading = false;
}
}
private _isHaOrOsUpdate(type: UpdateType): boolean {
return ["home_assistant", "home_assistant_os"].includes(type);
}
private _computeCreateBackupTexts():
{ title: string; description?: string } | undefined {
const updateType = this._entitySources
? getUpdateType(this.stateObj!, this._entitySources)
: "generic";
if (this._isHaOrOsUpdate(updateType)) {
if (this._backupConfigLoading) {
return undefined;
}
const isBackupConfigValid =
!!this._backupConfig &&
!!this._backupConfig.automatic_backups_configured &&
!!this._backupConfig.create_backup.password &&
this._backupConfig.create_backup.agent_ids.length > 0;
if (!isBackupConfigValid) {
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual"
),
description: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual_description"
),
};
}
const lastAutomaticBackupDate = this._backupConfig
?.last_completed_automatic_backup
? new Date(this._backupConfig?.last_completed_automatic_backup)
: null;
const now = new Date();
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic"
),
description: lastAutomaticBackupDate
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_last",
{
relative_time: relativeTime(
lastAutomaticBackupDate,
this._locale,
now,
true
),
}
)
: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_none"
),
};
}
// App backup
if (updateType === "addon") {
const version = this.stateObj!.attributes.installed_version;
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.app"
),
description: version
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.app_description",
{ version: version }
)
: undefined,
};
}
// Fallback to generic UI
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.generic"
),
};
}
private _createBackupChanged(ev) {
this._createBackup = ev.target.checked;
}
static styles = css`
:host {
display: block;
width: 100%;
}
ha-row-item {
width: 100%;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.skeleton-end {
width: 48px;
height: 24px;
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-update-backup": HaMoreInfoUpdateBackup;
}
}
+11 -13
View File
@@ -131,6 +131,10 @@ class MoreInfoFan extends LitElement {
return nothing;
}
const supportsOnOff =
supportsFeature(this.stateObj, FanEntityFeature.TURN_ON) ||
supportsFeature(this.stateObj, FanEntityFeature.TURN_OFF);
const supportsSpeed = supportsFeature(
this.stateObj,
FanEntityFeature.SET_SPEED
@@ -174,21 +178,15 @@ class MoreInfoFan extends LitElement {
`
}
${
supportSpeedPercentage
supportSpeedPercentage && supportsOnOff
? html`
<div class="buttons">
${
supportSpeedPercentage
? html`
<ha-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._toggle}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing
}
<ha-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._toggle}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-outlined-icon-button>
</div>
`
: nothing
@@ -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,57 +1,32 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { customElement, property, query, state } from "lit/decorators";
import { BINARY_STATE_OFF } from "../../../common/const";
import { relativeTime } from "../../../common/datetime/relative_time";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/animation/ha-fade-in";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-faded";
import "../../../components/ha-markdown";
import "../../../components/ha-spinner";
import "../../../components/ha-switch";
import "../../../components/item/ha-row-item";
import "../../../components/progress/ha-progress-bar";
import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup";
import {
apiContext,
configContext,
formattersContext,
internationalizationContext,
statesContext,
} from "../../../data/context";
import { apiContext, formattersContext } from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { getSupervisorUpdateConfig } from "../../../data/supervisor/update";
import type { FrontendLocaleData } from "../../../data/translation";
import type { UpdateEntity, UpdateType } from "../../../data/update";
import type { UpdateEntity } from "../../../data/update";
import {
getUpdateType,
latestVersionIsSkipped,
updateButtonIsDisabled,
UpdateEntityFeature,
updateIsInstalling,
updateReleaseNotes,
} from "../../../data/update";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import type { HomeAssistantApi, HomeAssistantFormatters } from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
import "../components/update/ha-more-info-update-backup";
import type { HaMoreInfoUpdateBackup } from "../components/update/ha-more-info-update-backup";
@customElement("more-info-update")
class MoreInfoUpdate extends LitElement {
@@ -61,182 +36,21 @@ class MoreInfoUpdate extends LitElement {
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
@state() private _releaseNotes?: string | null;
@state() private _error?: string;
@state() private _markdownLoading = true;
@state() private _backupConfigLoading = true;
@state() private _backupConfig?: BackupConfig;
@state() private _createBackupLoading = true;
@state() private _createBackup = false;
@state() private _entitySources?: EntitySources;
private async _fetchBackupConfig() {
try {
const { config } = await fetchBackupConfig(this._api);
this._backupConfig = config;
} catch (err) {
// ignore error, because user will get a manual backup option
// eslint-disable-next-line no-console
console.error(err);
} finally {
this._backupConfigLoading = false;
}
}
private async _fetchUpdateBackupConfig(type: UpdateType) {
try {
const config = await getSupervisorUpdateConfig(this._api);
// for home assistant and OS updates
if (this._isHaOrOsUpdate(type)) {
this._createBackup = config.core_backup_before_update;
this._createBackupLoading = false;
return;
}
if (type === "addon") {
this._createBackup = config.add_on_backup_before_update;
}
} catch (err) {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
this._createBackup = false;
} finally {
this._createBackupLoading = false;
}
}
private async _fetchEntitySources() {
this._entitySources = await fetchEntitySourcesWithCache({
callWS: this._api.callWS,
states: this._states,
});
}
private _isHaOrOsUpdate(type: UpdateType): boolean {
return ["home_assistant", "home_assistant_os"].includes(type);
}
private _computeCreateBackupTexts():
{ title: string; description?: string } | undefined {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return undefined;
}
const updateType = this._entitySources
? getUpdateType(this.stateObj, this._entitySources)
: "generic";
if (this._isHaOrOsUpdate(updateType)) {
if (this._backupConfigLoading) {
return undefined;
}
const isBackupConfigValid =
!!this._backupConfig &&
!!this._backupConfig.automatic_backups_configured &&
!!this._backupConfig.create_backup.password &&
this._backupConfig.create_backup.agent_ids.length > 0;
if (!isBackupConfigValid) {
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual"
),
description: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual_description"
),
};
}
const lastAutomaticBackupDate = this._backupConfig
?.last_completed_automatic_backup
? new Date(this._backupConfig?.last_completed_automatic_backup)
: null;
const now = new Date();
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic"
),
description: lastAutomaticBackupDate
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_last",
{
relative_time: relativeTime(
lastAutomaticBackupDate,
this._locale,
now,
true
),
}
)
: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_none"
),
};
}
// App backup
if (updateType === "addon") {
const version = this.stateObj.attributes.installed_version;
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.app"
),
description: version
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.app_description",
{ version: version }
)
: undefined,
};
}
// Fallback to generic UI
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.generic"
),
};
}
@query("ha-more-info-update-backup")
private _backupElement?: HaMoreInfoUpdateBackup;
protected render() {
if (
@@ -248,7 +62,6 @@ class MoreInfoUpdate extends LitElement {
return nothing;
}
const createBackupTexts = this._computeCreateBackupTexts();
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
return html`
@@ -347,39 +160,9 @@ class MoreInfoUpdate extends LitElement {
}
</div>
<div class="footer">
${
createBackupTexts || this._backupConfigLoading
? html`
<ha-row-item
.headline=${createBackupTexts ? createBackupTexts.title : undefined}
.supportingText=${createBackupTexts ? createBackupTexts.description : undefined}
>
${
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: nothing
}
${
this._createBackupLoading
? html`<ha-fade-in
class="skeleton-end"
slot="end"
.delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>`
}
</ha-row-item>
`
: nothing
}
<ha-more-info-update-backup
.stateObj=${this.stateObj}
></ha-more-info-update-backup>
<div class="actions">
${
this.stateObj.state === BINARY_STATE_OFF &&
@@ -440,36 +223,6 @@ class MoreInfoUpdate extends LitElement {
if (supportsFeature(this.stateObj!, UpdateEntityFeature.RELEASE_NOTES)) {
this._fetchReleaseNotes();
}
if (supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
this._fetchEntitySources()
.then(() => {
const type = getUpdateType(this.stateObj!, this._entitySources!);
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
this._fetchUpdateBackupConfig(type);
} else {
this._createBackupLoading = false;
}
if (this._isHaOrOsUpdate(type)) {
this._fetchBackupConfig();
} else {
this._backupConfigLoading = false;
}
})
.catch((err) => {
// ignore error, because the generic backup option remains available
// eslint-disable-next-line no-console
console.error(err);
this._createBackupLoading = false;
this._backupConfigLoading = false;
});
} else {
this._createBackupLoading = false;
this._backupConfigLoading = false;
}
}
private async _markdownLoaded() {
@@ -489,19 +242,12 @@ class MoreInfoUpdate extends LitElement {
}
}
get _shouldCreateBackup(): boolean {
if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
return false;
}
return this._createBackup;
}
private _handleInstall(): void {
const installData: Record<string, any> = {
entity_id: this.stateObj!.entity_id,
};
if (this._shouldCreateBackup) {
if (this._backupElement?.createBackup ?? false) {
installData.backup = true;
}
@@ -515,10 +261,6 @@ class MoreInfoUpdate extends LitElement {
this._api.callService("update", "install", installData);
}
private _createBackupChanged(ev) {
this._createBackup = ev.target.checked;
}
private _handleSkip(): void {
if (this.stateObj!.attributes.auto_update) {
showAlertDialog(this, {
@@ -589,11 +331,6 @@ class MoreInfoUpdate extends LitElement {
z-index: 10;
}
ha-row-item {
width: 100%;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.actions {
width: 100%;
display: flex;
@@ -627,11 +364,6 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box;
padding-bottom: var(--ha-space-4);
}
.skeleton-end {
width: 48px;
height: 24px;
display: block;
}
`;
}
@@ -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>
`
+53 -42
View File
@@ -29,17 +29,12 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { mainWindow } from "../../common/dom/get_main_window";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import {
computeEntityEntryName,
computeEntityName,
} from "../../common/entity/compute_entity_name";
import {
getEntityContext,
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
computeEntityEntryNameList,
computeEntityNameList,
type EntityNameItem,
} from "../../common/entity/compute_entity_name_display";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import {
getHistoryState,
@@ -130,6 +125,13 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
const BREADCRUMB_NAME: EntityNameItem[] = [
{ type: "area" },
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
];
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | Record<string, string[]> | null,
@@ -564,44 +566,27 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const showCloseIcon =
isDefaultView && this._parentEntityIds.length === 0 && !this._childView;
const context = stateObj
? getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: this._entry
? getEntityEntryContext(
this._entry,
const breadcrumb = (
stateObj
? computeEntityNameList(
stateObj,
BREADCRUMB_NAME,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: this._entry
? computeEntityEntryName(this._entry, this.hass.devices)
: entityId;
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,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
: this._entry
? computeEntityEntryNameList(
this._entry,
BREADCRUMB_NAME,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: [entityId]
).filter((v): v is string => Boolean(v));
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
@@ -1031,6 +1016,32 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._infoEditMode = false;
this._detailsYamlMode = false;
}
if (changedProps.has("_entityId")) {
this._reportShownEntityToExternalApp(
changedProps.get("_entityId") as string | null | undefined
);
}
}
private _reportShownEntityToExternalApp(
previousEntityId: string | null | undefined
) {
const external = this.hass.auth.external;
if (!external) {
return;
}
if (this._entityId) {
external.fireMessage({
type: "more_info/opened",
payload: { entity_id: this._entityId },
});
} else if (previousEntityId) {
external.fireMessage({
type: "more_info/closed",
payload: { entity_id: previousEntityId },
});
}
}
private _entryUpdated(ev: CustomEvent<ExtEntityRegistryEntry>) {
@@ -24,7 +24,6 @@ export class CloudStepSignin extends LitElement {
check-connection
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
@cloud-forgot-password=${this._forgotPassword}
@cloud-logged-in=${this._loggedIn}
></cloud-login>
@@ -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
}
${
+16
View File
@@ -184,6 +184,20 @@ interface EMOutgoingMessageAddEntityTo extends EMMessage {
};
}
interface EMOutgoingMessageMoreInfoOpened extends EMMessage {
type: "more_info/opened";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageMoreInfoClosed extends EMMessage {
type: "more_info/closed";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageFocusElement extends EMMessage {
type: "focus_element";
payload: {
@@ -216,6 +230,8 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageHaptic
| EMOutgoingMessageImportThreadCredentials
| EMOutgoingMessageMatterCommission
| EMOutgoingMessageMoreInfoOpened
| EMOutgoingMessageMoreInfoClosed
| EMOutgoingMessageSidebarShow
| EMOutgoingMessageTagWrite
| EMOutgoingMessageThemeUpdate
@@ -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;
+3
View File
@@ -465,6 +465,9 @@ export const ENTITY_COMPONENT_ICONS: Record<string, ComponentIcons> = {
on: "mdi:alert-circle",
},
},
glass_break: {
default: "mdi:glass-fragile",
},
heat: {
default: "mdi:thermometer",
state: {
+2 -4
View File
@@ -5,6 +5,7 @@ import {
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import { fireEvent } from "../common/dom/fire_event";
import { computeEntityNameDisplayWithoutContext } from "../common/entity/compute_entity_name_display";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import {
@@ -604,10 +605,7 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
formatEntityName: computeEntityNameDisplayWithoutContext,
...overrideData,
};

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