Compare commits

..

97 Commits

Author SHA1 Message Date
Aidan Timson 8cb6c0ccf9 Use automation descriptions instead of target ui 2026-06-23 12:32:13 +01:00
Aidan Timson 8006eff03c Add row targets UI to entity state and numeric state in card condition editor 2026-06-23 12:20:28 +01:00
Aidan Timson af203d640f Use helpers for related context (#52816) 2026-06-23 10:08:50 +00:00
Petar Petrov da29c8f536 Remove dead and unused hass props/bindings from migrated leaves (#52805) 2026-06-23 09:07:02 +01:00
Petar Petrov 8069596c87 Migrate registry display editors to context instead of hass (#52804) 2026-06-23 09:06:15 +01:00
Petar Petrov ace55fdb92 Migrate ha-qr-scanner to context instead of hass (#52806) 2026-06-23 09:04:51 +01:00
renovate[bot] dae8adab98 Update babel monorepo to v8 (#52758)
* Update babel monorepo to v8

* Bugfixes option has been removed and is enabled by default

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-23 09:56:55 +03:00
Paul Bottein 1522d979de Fix multi-term picker search ranking (#52807) 2026-06-23 08:15:37 +03:00
renovate[bot] 5fd253b2d3 Update dependency @types/luxon to v3.7.2 (#52812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 08:12:52 +03:00
Paulus Schoutsen f7d5195161 Fix inverted managed check for cloud webhook disable (#52813)
The manage-cloudhook dialog showed "managed by an integration and cannot
be disabled" for webhooks the user enabled manually (e.g. an automation
webhook trigger), while letting integration-managed webhooks (e.g. the
mobile app) be disabled.

Core sets `managed: true` only for cloudhooks created programmatically by
an integration (via async_create_cloudhook) and `managed: false` for hooks
the user creates through the cloud panel. The dialog negated this flag, so
the message and disable link were shown for the wrong cases. Check the flag
directly so user-created hooks expose the disable link and integration
hooks show the informational message.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 08:12:32 +03:00
karwosts b72791a9e2 time_format migration and enhancements in entities and glance cards (#52768)
* time_format migration and enhancements in entities and glance cards

* migration updates
2026-06-22 17:24:40 +02:00
Aidan Timson a3c0e8d519 Use related context in entity picker, send context on edit card/badge (#52798)
* Use related context in entity picker

* Include current item in context builder

* Fix

* Add tests

* Remove comment

* Support area context in card

* Add from rebase

* Add window.haContext.related to tests
2026-06-22 17:43:38 +03:00
Clément Notin 19fcb9d2f7 Allow to open tabs in Developer tools to new tabs (middle-click, CTRL+Click...) (#52785)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2026-06-22 14:36:49 +00:00
AlCalzone cbd90884ee Set ha-progress-ring-size before rendering (#52794)
Set ha-progress-ring-size before rendering
2026-06-22 15:33:00 +02:00
Aidan Timson fce1938f38 Add a debug to related context provider (#52793)
* Add a debug to related context provider

* context -> haContext
2026-06-22 14:34:34 +03:00
Bram Kragten 24821d6f1b Sign brand images in state-badge via connection context (#52797)
* Sign brand images in state-badge via connection context

state-badge only signed entity_picture URLs when a `hass` object was
passed, calling `hass.hassUrl()` to append the brands access token.
Components migrated to Lit contexts (e.g. ha-config-updates on the
Settings → Updates page) no longer pass `hass`, so brand icon URLs like
/api/brands/integration/<domain>/icon.png were fetched without a token,
returning 403 and triggering unauthenticated-request log entries in core.

Consume connectionContext to obtain `hassUrl` so the token is added even
when `hass` isn't provided, and skip brand URLs entirely when they can't
be signed yet so no unauthenticated request fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Remove now-unused hass property from state-badge

With brand image signing handled via connectionContext, `hass` was only
used by state-badge to reach `hassUrl`. Drop the property entirely and
remove the `.hass` binding from all call sites; the connection context
provides `hassUrl` everywhere the component renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:33:24 +03:00
Petar Petrov 677d64c915 Use a picker with entity context for energy upstream device (#52788) 2026-06-22 12:12:20 +01:00
Petar Petrov fe06772a73 Pad chart Y-axis labels to consistent decimal precision (#52787) 2026-06-22 12:10:21 +01:00
Petar Petrov 170f8c371a Migrate action/service button rows to context instead of hass (#52789) 2026-06-22 12:09:32 +01:00
Petar Petrov 12841b5ff7 Migrate state-display leaves to context instead of hass (#52791) 2026-06-22 12:08:15 +01:00
Petar Petrov 5393b05636 Migrate filter components to context instead of hass (#52792) 2026-06-22 12:07:35 +01:00
Petar Petrov a384e2dbd6 Migrate UI/config leaf components to context instead of hass (#52790) 2026-06-22 12:04:20 +01:00
AlCalzone fd4936e547 Show circular progress when interviewing Z-Wave devices (#52795)
Use circular progress for interviewing Z-Wave devices
2026-06-22 13:35:40 +03:00
Bram Kragten 44d02420ae Add support for not triggered traces (#52708) 2026-06-22 11:59:39 +02:00
karwosts ebf80ecca0 Accept enter key to submit code dialog (#52784) 2026-06-22 08:11:46 +03:00
renovate[bot] bcfcc7bd5a Update tsparticles to v4.2.1 (#52786)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 08:05:56 +03:00
renovate[bot] f7933c31d7 Update dependency @rsdoctor/rspack-plugin to v1.5.15 (#52783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 17:43:40 +02:00
HarvsG 3f4f4a5ead Clarify integration startup message in translations (#52772)
Updated the message for integration startup to clarify that not everything will be available until startup is finished.
2026-06-21 05:42:38 +00:00
renovate[bot] 44a269b87b Update tsparticles to v4.2.0 (#52776)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 08:34:48 +03:00
renovate[bot] 4f89056883 Update dependency @babel/helper-define-polyfill-provider to v1 (#52778)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 08:34:21 +03:00
Paulus Schoutsen 6a40f1965a Fix app panel flickering when waiting for app to finish starting up (#52781)
* Fix app panel flicker while waiting for app to start

Keep the loading screen up as a stable overlay while the ingress iframe
is still returning 502, and reload the iframe content in place instead
of unsetting the addon and rebuilding the whole panel each retry.

https://claude.ai/code/session_019fWWygHqYbM2H6FN9jWJu1

* Increase timeout

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 08:33:36 +03:00
renovate[bot] 7e836d6cca Update fullcalendar monorepo to v6.1.21 (#52779)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 08:32:46 +03:00
renovate[bot] 1fab54831f Update dependency @rsdoctor/rspack-plugin to v1.5.14 (#52771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-20 14:34:13 +02:00
Petar Petrov 4638582c6f Refactor energy dashboard card visibility to a single source of truth (#52673)
Make energy dashboard card visibility declarative via the catalog

Route the five energy view strategies and the dashboard strategy through
the shared ENERGY_CARD_CATALOG instead of re-deriving each card's
applicability conditions inline. A new isEnergyCardVisible() helper makes
the catalog the single source of truth for whether a card is shown, so the
strategies and the customise dialog can no longer disagree.

Behavior-preserving; adds a contract test pinning isEnergyCardVisible to
the catalog for every entry.
2026-06-20 09:06:35 +02:00
Franck Nijhof e5721fb134 Use singular verb for state condition matched with any (#52609)
A state condition with match "any" joins its entities with "or", but the
summary kept a plural verb for multiple entities, reading "If A or B are
on". With "or" English uses singular agreement: "If A or B is on". The
match "all" case joins with "and" and correctly stays plural.

Nest a select on a new matchAny flag inside the multiple-entities plural
branch so the verb agrees with the join. Other languages keep their
count-based plural (the extra argument is ignored). Add a test that renders
the actual en.json string to lock in the grammar.
2026-06-20 09:00:38 +02:00
Petar Petrov bfd8cb54c9 Split negative untracked energy into a toggleable series (#52698)
Negative "untracked" values (tracked devices reporting more than total
consumption, usually a meter resolution mismatch) rendered as confusing
below-zero bars in the devices detail graph. Move them into their own
"Over-reported consumption" series with its own legend item so users can
toggle them off, and only add the series when negatives actually exist.
2026-06-20 09:56:49 +03:00
Franck Nijhof 89bd1058df Fix gauge card dropping negative and monetary values (#52751)
The gauge card reads its display value from the formatted state parts.
A monetary value is split into multiple value parts around the currency
symbol, so the minus sign lands in its own part. Taking only the first
value part meant a value like -182.95 GBP rendered as just "-".

Join all value parts so the full number is shown again.

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-06-20 08:45:28 +02:00
Paul Bottein 405727502f Fix negative monetary (#52766)
* Fix rendering of negative monetary values

* Fix tests
2026-06-20 08:34:47 +02:00
Marcin Bauer dae105531f Fix left column resizing in add automation element dialog (#52745)
The left list column used flex: 4 against a flex: 6 right panel. Because
flex items default to min-width: auto, the right panel's content (which
varies per group) could dictate the split, so the left column width
shifted while browsing groups.

Give the left column a fixed width (flex: 0 0 360px) and let the right
panel take the rest with min-width: 0 so its content shrinks instead of
pushing the left column around.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:09:11 +03:00
Franck Nijhof 5f790a4977 Offer category and floor names to the AI suggestion model (#52760)
The metadata suggestion schema sent the internal category and floor IDs as
the select options, so the model only saw opaque IDs and never picked one.
The result processor already maps the chosen value back to an ID by name, so
the two halves disagreed.

Offer the names as the option values instead, matching what the processor
expects, so the model can actually choose a category or floor.
2026-06-20 08:05:10 +03:00
Franck Nijhof d6c16e0736 Count not-ready Z-Wave devices separately from not included (#52757)
The network status on the Z-Wave dashboard added the not-ready nodes to
the provisioning entries and labeled the total as not included. Not-ready
nodes are included though, their interview just has not completed yet, so
this was confusing.

Report not-ready nodes as not ready and keep not included for the
provisioning entries that have not joined the network yet.
2026-06-20 08:04:11 +03:00
Franck Nijhof c562f58326 Group the time and duration input fields for screen readers (#52764)
The day, hour, minute, second, and millisecond inputs were rendered as
separate fields with a label that was not associated with them, so screen
readers announced them as unrelated inputs.

Wrap the fields in a role=group and label that group with the visible label,
so they are announced together as one labeled control.
2026-06-20 07:57:29 +03:00
Franck Nijhof ce5640d13a Fix time zone picker data gaps (UTC and Asia/Sakhalin) (#52754)
* Add UTC time zone to the time zone picker

The time zone list is built from google-timezones-json, which is missing
the bare "UTC" and "Etc/UTC" zones. Both are valid IANA identifiers and a
common server default, so an instance configured to UTC showed up as an
unknown time zone in the settings.

Add the two zones to the picker options, guarded against duplicates in
case the source list starts including them.

* Accept UTC time zone in the clock card config

The clock card validated its time_zone against the raw timezone list, so
it rejected UTC even though the picker now offers it. Validate against the
shared timezone options instead, keeping a single source.

* Correct the invalid Asia/Sakhalin time zone id

google-timezones-json ships Asia/Yuzhno-Sakhalinsk, which is not a valid
IANA identifier, so selecting it failed backend validation. Map it to the
correct Asia/Sakhalin id.
2026-06-20 07:56:15 +03:00
renovate[bot] 6ddcc83638 Update CodeMirror to v6.7.1 (#52767)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 22:54:11 +02:00
Franck Nijhof 396f495c9b Discard stale results in the template developer tool (#52762)
Subscribing to a template render is asynchronous and is triggered from a few
places, so two renders could overlap. The second subscription overwrote the
handle of the first, leaving it running, and its late results overwrote the
current ones. That is why the result window sometimes showed the output of a
previous template until the editor was nudged.

Track a render id and bail out (and ignore incoming results) once a newer
render has started.
2026-06-19 21:42:54 +02:00
Petar Petrov d994fd8928 Fix Assist chat freezing when thinking details are opened mid-stream (#52753)
Fix Assist chat freezing when thinking details opened mid-stream
2026-06-19 15:49:43 +02:00
Franck Nijhof 21d8fda76d Mask password values in object selector previews (#52748) 2026-06-19 14:30:57 +02:00
Paul Bottein 49716f4151 Replace until() in icon components with a shared async controller (#52746) 2026-06-19 14:15:54 +02:00
Aidan Timson 657bef6a75 Change dialog enter code to adaptive dialog (#52747) 2026-06-19 14:45:58 +03:00
Franck Nijhof 9edd330728 Fix inverted vertical sliders in RTL languages (#52750)
The control slider flipped its value mapping whenever the document
direction was right-to-left, including for vertical sliders. RTL only
mirrors the horizontal axis, so a vertical slider ended up upside down:
the light brightness and color temperature sliders in the more info
dialog reported the opposite of what they showed (1% gave the brightest
output, 100% the dimmest).

Only mirror for right-to-left when the slider is horizontal.
2026-06-19 14:39:53 +03:00
Petar Petrov 09e83b6450 Omit empty select fields from AI metadata suggestion task (#52749) 2026-06-19 12:40:28 +02:00
Franck Nijhof 9c3f3ed05d Stop icon components leaking memory on every state update (#52743) 2026-06-19 10:36:36 +02:00
Aidan Timson aec6c8c1e4 Effective dirty state, apply to card/badge editor (#52727)
* Effective dirty state

* Effective normalise function for those with defaults not undefined
2026-06-19 11:00:12 +03:00
Franck Nijhof 82f4ae1f08 Return to the device page from the Z-Wave node config view (#52735) 2026-06-19 10:56:21 +03:00
Franck Nijhof 2809091b44 Accept backup uploads by .tar extension, not just MIME type (#52744) 2026-06-19 07:52:05 +00:00
Simon Lamon b2dda0f739 Translate exceptions in hass api calls (#52718) 2026-06-19 07:44:06 +01:00
Franck Nijhof d64845f206 Support a list of entities in the zone trigger editor (#52738) 2026-06-19 07:36:05 +01:00
Franck Nijhof 44d929bf56 Label time trigger and condition days as days of the week (#52737) 2026-06-19 07:33:24 +01:00
Franck Nijhof 56cfff6922 Show real repeat iteration number in trace details (#52736) 2026-06-19 07:32:03 +01:00
Franck Nijhof be8782d928 Include diagnostic battery binary sensors in maintenance view (#52734) 2026-06-19 07:28:41 +01:00
renovate[bot] 2eba8425a7 Update typescript-eslint monorepo to v8.61.1 (#52740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:23:14 +01:00
renovate[bot] 5ddc26df7a Update formatjs monorepo to v0.10.15 (#52739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:17:11 +02:00
renovate[bot] 97516f5625 Lock file maintenance (#52741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:16:44 +02:00
Aidan Timson e8c06b4220 Limit cover/valve card feature width to prevent overflow (#52730)
* Limit cover/valve card feature width to prevent overflow

* Remove comments and unnecessary getter
2026-06-18 17:05:27 +02:00
Paul Bottein 4fd976dc8c List main entities first on the device page (#52728)
* List main entities first on the device page

* Update src/panels/config/devices/device-detail/ha-device-entities-card.ts

Co-authored-by: Aidan Timson <aidan@timmo.dev>

---------

Co-authored-by: Aidan Timson <aidan@timmo.dev>
2026-06-18 14:38:37 +00:00
karwosts f8d8dc4eaa Fix entities timestamp editor (#52729) 2026-06-18 14:14:27 +01:00
Petar Petrov 8ccda740ee Fix date/datetime selectors on the design gallery and align datetime fields (#52726)
* Provide i18n and config contexts to gallery ha-selector demo

* Align date and time fields in datetime selector
2026-06-18 13:25:07 +02:00
Aidan Timson 8528dd8a15 Migrate more info person, sun, weather controls to lazy context (#52706) 2026-06-18 12:26:05 +03:00
Paulus Schoutsen ac2f8ebce3 Add radio frequency panel (#52464)
* Add rf panel

* Tweaks

* Align canShowPage with dev PageNavigation type

* Restore page filter check in canShowPage

* Add transmitters list to radio frequency panel

Show transmitter status and a devices data table (name, type, last used)
with links to the device info page, and use the radio tower domain icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Update src/panels/config/integrations/integration-panels/radio_frequency/radio-frequency-transmitters.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* adjust comments

* Rename radio frequency transmitters page to devices page

Mirror the infrared panel: rename the transmitters page to a devices
page, source the type column label from the integration's
entity_component name, fetch transmitters in the router and pass them to
both pages, and align user-facing copy to "devices".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Handle radio frequency transmitter load errors

Wrap the transmitter fetch in try/catch and surface failures via an
alert dialog instead of leaving an unhandled rejection.

Also drop the duplicate PageNavigation.filter declaration introduced by
the dev merge (it already exists on dev).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-18 09:25:38 +00:00
renovate[bot] 1462f65f5a Update yarn monorepo to v4.17.0 (#52725)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 12:22:58 +03:00
renovate[bot] 3e9d3d90a1 Update vitest monorepo to v4.1.9 (#52724)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 09:21:56 +00:00
Aidan Timson f28898551b Reword advanced controls to more controls in siren (#52700)
Reword advanced to more in siren
2026-06-18 08:25:34 +03:00
Aidan Timson eabbcf3a95 Migrate more info lock / alarm to lazy context (#52703)
Migrate more info lock / alarm
2026-06-18 08:24:44 +03:00
Aidan Timson 01255cebc6 Remove "advanced" from security options in zwave search (#52702) 2026-06-18 08:24:17 +03:00
Aidan Timson d20e062de9 Migrate more info vaccum / lawn mower controls to lazy context (#52704) 2026-06-18 08:23:28 +03:00
Aidan Timson 835c0fa35c Migrate more info fan and light controls to lazy context (#52705) 2026-06-18 08:22:48 +03:00
Aidan Timson e308272d89 Migrate more info media controls to lazy context (#52707)
* Migrate more info media controls to lazy context

* Remove
2026-06-18 08:21:36 +03:00
renovate[bot] d2ae376058 Update Node.js to v24.17.0 (#52721)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 05:21:21 +00:00
Paulus Schoutsen fdd57645ee Add infrared panel (#52465)
* Add IR panel

* Tweaks

* Redesign infrared panel: device dashboard + table

Show a Bluetooth-style status card with the count of online IR devices,
linking to a separate data-table page that lists devices grouped from
their proxy entities. Devices expose a type (Emitter, Receiver, or
"Receiver, Emitter") and a "Last used" column derived from the most
recent entity state timestamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Pass localize instead of hass into _data memoizeOne

* Fix Prettier formatting in infrared-devices-page.ts

* Derive infrared devices from registries instead of infrared/list

Drop the infrared/list WebSocket call and compute the device dataset
from the entity/device registries in the dashboard router, passing it
down to the dashboard and devices pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Use infrared entity_component device class names for type labels

* Remove fallback strings from localize calls in infrared devices page

* Fix device class translation

* Cleanup

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 07:20:16 +02:00
karwosts 1ef1655a4c Add time_format selector to entities card entity-row-editor (#52715) 2026-06-18 08:18:31 +03:00
Paul Bottein aa1108fc41 Translate list attributes and device class in entity details (#52716) 2026-06-18 08:17:34 +03:00
karwosts e3c6a57080 Add short / long timestamp styles (#52719) 2026-06-18 08:16:34 +03:00
karwosts 1e22649ef8 Unify timestamp state domain lists (#52717) 2026-06-18 07:11:45 +02:00
karwosts 5abd04d09a Convert remaining EntityFeatures to enums (#52720) 2026-06-18 07:10:00 +02:00
karwosts a5bf35690b Add time format to entity badge (#52713) 2026-06-17 20:24:03 +02:00
karwosts d98eb47490 Decode supported features in more-info-details (#52712)
* Decode supported features in more-info-details

* Remove 'Supported features' translation entry
2026-06-17 18:33:18 +02:00
karwosts 738e92d27d Add time_format to tile card (#52450)
* Add time_format to tile card

* Updates

* incorrect type

* Apply suggestions from code review

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* code review feedback

* handle timestamp=0

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-06-17 16:04:40 +02:00
Aidan Timson ade2e9272b Reword advanced settings to more options in helpers (#52701) 2026-06-17 15:47:24 +02:00
Simon Lamon d8ce60dfb6 Add icons to live condition test (#52458)
Add icons to live condition
2026-06-17 16:41:15 +03:00
Aidan Timson db9374925e Migrate more info climate (+ related) to lazy context (#52694)
* Migrate more info climate (+ related) to lazy context

* Remove hass
2026-06-17 13:19:46 +00:00
Aidan Timson 1bcd1293c0 Reword "advanced concept" in event trigger/action descriptions (#52699) 2026-06-17 16:07:57 +03:00
Aidan Timson b8cf061ebb Migrate more info datetime (+ related) to lazy context (#52696) 2026-06-17 16:01:01 +03:00
karwosts 6585da9a73 Fix continue_on_timeout toggle defaults in wait script actions (#52691)
Fix continue_on_timeout toggle in wait script actions
2026-06-17 13:26:21 +02:00
Paul Bottein 368df82e97 Redesign the Activity (logbook) as a timeline with entity context (#52498)
* Redesign the Activity (logbook) as a timeline with entity context

* Update color

* Refine logbook timeline layout and entry rendering

- Three layout modes in ha-logbook-entry: wide (entity → state inline),
  compact (entity/state + context/time), inline (state + cause icon + time)
- Entity name bold in wide and compact modes, consistent with tile card
- Cause icon shown inline next to the time in inline (single-entity) mode
- Unavailable state rendered as an empty circle dot
- Flash icon for entity-triggered causes
- "Show more" chevron link in logbook card, device page, and area page
- Extract _renderWide / _renderCompact / _renderInline from render()
- Scope entity-name flex layout to .line1 > .entity-name (compact only)

Co-Authored-By: Paul Bottein <paul.bottein@gmail.com>

* Show cause icon in inline logbook entries

- Show cause icon (user avatar, trigger type, integration brand) next to
  the time in single-entity inline mode
- Use ha-trigger-icon for trigger-platform causes
- Use ha-domain-icon with brand-fallback for integration causes when
  context_domain is available, falling back to mdiPuzzle
- Tooltip with cause name on hover
- Icon size 18px, user avatar 18x18px

Co-Authored-By: Paul Bottein <paul.bottein@gmail.com>

* Adjust cause icon sizes: 18px standalone, 16px inline with text

Co-Authored-By: Paul Bottein <paul.bottein@gmail.com>

* Fix somes issues

* Refine logbook timeline rendering

* Fix logbook dot alignment, header link, and graph colors

* Use deterministic colors for select/input_select in logbook timeline

Assign colors by options list index instead of encounter order so
logbook dots always match the history chart colors, regardless of JS
chunk boundaries.

* Add relative time to logbook entries

Show short relative time alongside absolute in all layouts.
Cause moves to its own third line in compact when icon mode is active.

* Replace dual time display with click-to-toggle in logbook

Clicking any time value toggles between absolute (default) and relative
short format. State lives in the renderer and propagates via Lit
re-render when shouldUpdate allows it.

Date headers now show "Today · June 15" and "Yesterday · June 14"
for recent dates via Intl.RelativeTimeFormat.

* Fix time toggle not updating entries in virtualizer mode

Use @queryAll to directly update showRelative on all visible entries
after toggling, covering the virtualizer case where Lit re-render
alone does not propagate prop changes to already-rendered items.

Also remove the !item guard in _renderRow to fix the RenderItemFunction<T>
type mismatch.

* Refine logbook compact/wide layout and cause display

- Move time column to the right in wide layout
- Right-align time in compact cause row by wrapping cause+trace in meta-main
- Hide cause icon/label for automation and script entries in compact/inline mode (only show trace link)
- Make automation/script entity name always clickable (opens more-info)

* Refactor logbook cause into typed kinds with text phrases

Replace the untyped `iconPath`/`triggerPlatform` fields on `LogbookCause`
with a `kind` discriminator (`user`, `automation`, `script`, `state`,
`scheduled`, `homeassistant`, `integration`).

In timeline layout, causes now render as readable text phrases
("By Paul", "By automation: Mode nuit", "By state change: Porte entrée",
"Scheduled", "Via HomeKit") with a `·` separator before "View trace".
Entity names in those phrases are clickable when an entity id is available.

In list/inline layout, the icon badge uses the kind to pick the right
icon (avatar, robot, script, brand domain, puzzle) — no trigger-type
icon component needed anymore.

* Add show-cause mode to logbook list layout

Add a `show-cause` boolean prop to `ha-logbook-entry`, `ha-logbook-renderer`,
and `ha-logbook` that switches list mode from a compact icon badge to a full
cause phrase on a third line.

The third line uses a fixed-width prefix span and a flex-1 truncatable entity
button so long automation/script/entity names ellipsize cleanly. The trace
link always stays right-aligned on the same line.

Enable the mode in `ha-panel-logbook` so the main activity feed shows full
cause context for every entry.

* Rename logbook model identifiers to match HA conventions and clean up

- Rename resolve*/build* → compute*, kind → type, LogbookWhat → LogbookValue,
  model.what → model.value across model, renderer, and tests
- Merge EntryRenderCtx into LogbookRenderItem (extends LogbookItem) so layout
  methods receive one flat object instead of ctx.model.xxx
- Inline _causeUser, drop dead possibleEntity branch in message formatter
- Remove unused .cause and .cause-name CSS classes; fix padding-block
  inconsistency on timeline content

* Use ha-relative-time in logbook for auto-updating relative times

Replace the static relativeTime() string with <ha-relative-time> so the
displayed time updates every 60 s without a full re-render. Add a format
prop (Intl.RelativeTimeFormatStyle) to ha-relative-time to support the
short style needed by the logbook. Fix text-overflow ellipsis in the time
column by restructuring .time to use align-items: stretch with an inner
.time-content block that owns overflow/ellipsis, and display: contents on
ha-relative-time so its text participates in the parent's inline flow.

Also rename computeLogbookItem's internal param from item to entry to
avoid shadowing the outer item variable.

* Fix automation run value detection and timeline arrow display

User-triggered automation runs had context_user_id set but no source or
context_event_type, so isAutomationRun was false and the raw backend
message "triggered" (lowercase) was shown instead of the localized
"Triggered". Add context_user_id to the isAutomationRun check so all
automation runs get the proper localized value.

Restore the state arrow (→) in the timeline for all value.type === "state"
entries, including automation runs.

* Fix ha-relative-time interval and use textContent

The 60-second auto-update interval was never started when datetime is set
via Lit property binding, because connectedCallback runs before Lit sets
properties. Move the interval start/stop logic into update() watching the
datetime property change instead.

Also replace innerHTML with textContent since the relative time string is
always plain text.

* Remove comments

* Feedback
2026-06-17 13:23:08 +02:00
Aidan Timson 1d99a5dff9 Migrate more info actions to lazy context (#52693)
* Migrate more info actions to lazy context

* Restore file while hass is still needed down the deep chain
2026-06-17 12:23:52 +03:00
Aidan Timson 0ca72b763a Migrate more info toggles to lazy context (#52692) 2026-06-17 08:33:38 +00:00
Aidan Timson 31848a1efd Migrate more info cover + valve to lazy context (#52695) 2026-06-17 11:18:43 +03:00
424 changed files with 17545 additions and 6772 deletions
-244
View File
@@ -1,244 +0,0 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
workflow_dispatch:
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
# Cache the downloaded browser build keyed on the pinned Playwright
# version (yarn.lock), so re-runs skip the ~170 MB download.
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
timeout-minutes: 15
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Merge local blob reports and post PR comment ───────────────────────────
report:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
-8
View File
@@ -54,16 +54,8 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
test/benchmarks/results/
+1 -1
View File
@@ -1 +1 @@
24.16.0
24.17.0
@@ -0,0 +1,18 @@
diff --git a/lib/cook-raw-quasi.js b/lib/cook-raw-quasi.js
index 3ea8fa7be8e357c1066d7417caeeecd841415208..6bf04ab0bed8897b5ff2898ca835867aec5cee6a 100644
--- a/lib/cook-raw-quasi.js
+++ b/lib/cook-raw-quasi.js
@@ -1,10 +1,11 @@
'use strict';
-function cookRawQuasi({transform}, raw) {
+function cookRawQuasi({transformSync}, raw) {
// This nasty hack is needed until https://github.com/babel/babel/issues/9242 is resolved.
const args = {raw};
- transform('cooked`' + args.raw + '`', {
+ // Babel 8 removed synchronous `transform`; use `transformSync` instead.
+ transformSync('cooked`' + args.raw + '`', {
babelrc: false,
configFile: false,
plugins: [
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.16.0.cjs
yarnPath: .yarn/releases/yarn-4.17.0.cjs
+1 -19
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -103,7 +104,6 @@ module.exports.babelOptions = ({
{
useBuiltIns: "usage",
corejs: dependencies["core-js"],
bugfixes: true,
shippedProposals: true,
},
],
@@ -320,22 +320,4 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
-4
View File
@@ -1,13 +1,9 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,3 +1,4 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
-7
View File
@@ -45,10 +45,3 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
-41
View File
@@ -1,41 +0,0 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+1 -21
View File
@@ -1,3 +1,4 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -267,24 +268,3 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
-20
View File
@@ -201,23 +201,3 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
-1
View File
@@ -4,7 +4,6 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+29 -69
View File
@@ -1,6 +1,6 @@
// Gulp task to generate third-party license notices.
import { readFile, access, readdir } from "fs/promises";
import { readFile, access } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
@@ -11,98 +11,58 @@ const OUTPUT_FILE = path.join(
"third-party-licenses.txt"
);
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
// The echarts package ships an Apache-2.0 NOTICE file that must be
// redistributed alongside the compiled output per Apache License §4(d).
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
// Some packages need a manual license override (e.g. they ship multiple
// license files and we must pick the right one for the bundled code).
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
//
// Each entry is pinned to a specific version. If a package is updated,
// this list must be reviewed and the version updated after verifying
// that the new version's license still matches. The build will fail if
// the pinned version is no longer installed.
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
const LICENSE_OVERRIDES = [
{
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
licenseFile: "license-mit",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
),
},
];
// Locate the directory of an installed package matching an exact version.
//
// The copy we care about may be hoisted to the top-level node_modules or
// nested under a dependency when a different version occupies the hoisted
// slot (e.g. a build-only dependency pulling in an older release). Searching
// both keeps this check independent of yarn's hoisting decisions, which can
// shift when unrelated dependencies are added.
async function findPackageDir(packageName, version) {
const candidateDirs = [path.join(NODE_MODULES, packageName)];
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
// node_modules/@scope/<dep>/node_modules/<pkg>.
let topLevel = [];
try {
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
} catch {
// node_modules unreadable — fall back to the hoisted candidate only.
}
for (const entry of topLevel) {
if (!entry.isDirectory() || entry.name === packageName) {
continue;
}
if (entry.name.startsWith("@")) {
const scopeDir = path.join(NODE_MODULES, entry.name);
// eslint-disable-next-line no-await-in-loop
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
() => []
);
for (const dep of scoped) {
if (dep.isDirectory()) {
candidateDirs.push(
path.join(scopeDir, dep.name, "node_modules", packageName)
);
}
}
} else {
candidateDirs.push(
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
);
}
}
for (const dir of candidateDirs) {
// eslint-disable-next-line no-await-in-loop
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
.then(JSON.parse)
.catch(() => null);
if (pkg?.version === version) {
return dir;
}
}
return null;
}
gulp.task("gen-licenses", async () => {
const licenseOverrides = {};
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
// eslint-disable-next-line no-await-in-loop
const packageDir = await findPackageDir(packageName, version);
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
if (!packageDir) {
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
);
}
if (packageJSON.version !== version) {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
);
}
const licensePath = path.join(packageDir, licenseFile);
try {
// eslint-disable-next-line no-await-in-loop
await access(licensePath);
-20
View File
@@ -14,7 +14,6 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -232,22 +231,3 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
-11
View File
@@ -50,15 +50,4 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+1 -6
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -337,11 +338,6 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -349,5 +345,4 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
-21
View File
@@ -1,21 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
-5
View File
@@ -1,5 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
-6
View File
@@ -234,12 +234,6 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
@@ -0,0 +1,75 @@
import type { DemoTrace } from "./types";
export const notTriggeredTrace: DemoTrace = {
trace: {
last_step: "trigger/0",
run_id: "788767ce152d3d4475134bf1107986d4",
state: "stopped",
script_execution: "not_triggered",
not_triggered: true,
timestamp: {
start: "2021-03-25T04:36:51.223337+00:00",
finish: "2021-03-25T04:36:51.223341+00:00",
},
// Not-triggered traces have no trigger description.
trigger: null,
domain: "automation",
item_id: "1781703842452",
trace: {
"trigger/0": [
{
path: "trigger/0",
timestamp: "2021-03-25T04:36:51.223340+00:00",
changed_variables: {
trigger: {
id: "0",
idx: "0",
alias: null,
platform: "light.turned_on",
},
},
result: {
reason: "new_state_not_a_match",
data: {
entity_id: "light.bed_light",
to_state: "off",
},
},
},
],
},
config: {
id: "1781703842452",
alias: "Light Turned On Notification",
description: "Send a notification when a specific light is turned on.",
triggers: [
{
trigger: "light.turned_on",
target: {
floor_id: "test",
},
options: {
for: "00:00:00",
behavior: "each",
},
},
],
conditions: [],
actions: [
{
action: "notify.notify",
data: {
message: "A light was turned on.",
},
},
],
mode: "single",
},
context: {
id: "01KVAX7CG7XBDYGJYAGA4XJHGX",
parent_id: "01KVAX7CG631JRX4H3JS5JJ11Q",
user_id: null,
},
},
logbookEntries: [],
};
@@ -24,6 +24,33 @@ const traces: DemoTrace[] = [
error: 'Variable "beer" cannot be None',
}),
mockDemoTrace({ state: "stopped", script_execution: "cancelled" }),
mockDemoTrace({
state: "stopped",
script_execution: "not_triggered",
not_triggered: true,
// Not-triggered traces have no trigger description.
trigger: null,
trace: {
"trigger/0": [
{
path: "trigger/0",
changed_variables: {
trigger: {
id: "0",
idx: "0",
alias: null,
platform: "light.turned_on",
},
},
result: {
reason: "new_state_not_a_match",
data: { entity_id: "light.bed_light", to_state: "off" },
},
timestamp: "2021-03-25T04:36:51.223693+00:00",
},
],
},
}),
];
@customElement("demo-automation-trace-timeline")
+28 -8
View File
@@ -2,17 +2,20 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, queryAll, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/trace/ha-trace-path-details";
import type { HatScriptGraph } from "../../../../src/components/trace/hat-script-graph";
import "../../../../src/components/trace/hat-script-graph";
import "../../../../src/components/trace/hat-trace-timeline";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import { basicTrace } from "../../data/traces/basic_trace";
import { motionLightTrace } from "../../data/traces/motion-light-trace";
import { notTriggeredTrace } from "../../data/traces/not-triggered-trace";
import type { DemoTrace } from "../../data/traces/types";
const traces: DemoTrace[] = [basicTrace, motionLightTrace];
const traces: DemoTrace[] = [basicTrace, motionLightTrace, notTriggeredTrace];
@customElement("demo-automation-trace")
export class DemoAutomationTrace extends LitElement {
@@ -20,18 +23,25 @@ export class DemoAutomationTrace extends LitElement {
@state() private _selected = {};
@queryAll("hat-script-graph") private _graphs!: NodeListOf<HatScriptGraph>;
protected render() {
if (!this.hass) {
return nothing;
}
return html`
${traces.map(
(trace, idx) => html`
${traces.map((trace, idx) => {
const graph = this._graphs?.[idx];
const selectedPath = this._selected[idx];
const selectedNode = selectedPath
? graph?.renderedNodes[selectedPath]
: undefined;
return html`
<ha-card .header=${trace.trace.config.alias}>
<div class="card-content">
<hat-script-graph
.trace=${trace.trace}
.selected=${this._selected[idx]}
.selected=${selectedPath}
@graph-node-selected=${this._handleGraphNodeSelected}
.sampleIdx=${idx}
></hat-script-graph>
@@ -40,15 +50,25 @@ export class DemoAutomationTrace extends LitElement {
.hass=${this.hass}
.trace=${trace.trace}
.logbookEntries=${trace.logbookEntries}
.selectedPath=${this._selected[idx]}
.selectedPath=${selectedPath}
@value-changed=${this._handleTimelineValueChanged}
.sampleIdx=${idx}
></hat-trace-timeline>
${selectedNode && graph
? html`<ha-trace-path-details
.hass=${this.hass}
.trace=${trace.trace}
.selected=${selectedNode}
.logbookEntries=${trace.logbookEntries}
.trackedNodes=${graph.trackedNodes}
.renderedNodes=${graph.renderedNodes}
></ha-trace-path-details>`
: nothing}
<button @click=${() => console.log(trace)}>Log trace</button>
</div>
</ha-card>
`
)}
`;
})}
`;
}
+32 -1
View File
@@ -1,4 +1,5 @@
import type { TemplateResult } from "lit";
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -14,6 +15,11 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import {
configContext,
internationalizationContext,
} from "../../../../src/data/context";
import { updateHassGroups } from "../../../../src/data/context/updateContext";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
@@ -496,6 +502,10 @@ const SCHEMAS: {
},
},
},
password: {
label: "Password",
selector: { text: { type: "password" } },
},
},
},
},
@@ -518,6 +528,17 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
private data = SCHEMAS.map(() => ({}));
// The date/datetime selectors and the date-picker dialog consume these
// contexts (provided by the root element in the real app). Provide them here
// so they work in the gallery.
private _i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private _configProvider = new ContextProvider(this, {
context: configContext,
});
constructor() {
super();
const hass = provideHass(this);
@@ -539,6 +560,16 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
el.hass = this.hass;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("hass") && this.hass) {
this._i18nProvider.setValue(
updateHassGroups.internationalization(this.hass)
);
this._configProvider.setValue(updateHassGroups.config(this.hass));
}
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
-1
View File
@@ -353,7 +353,6 @@ export class DemoEntityState extends LitElement {
title: "Icon",
template: (entry) => html`
<state-badge
.hass=${hass}
.stateObj=${entry.stateObj}
.stateColor=${true}
></state-badge>
+26 -32
View File
@@ -22,18 +22,13 @@
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.29.7",
"@babel/runtime": "8.0.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.3",
@@ -41,26 +36,26 @@
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.0",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-durationformat": "0.10.15",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.11",
"@formatjs/intl-pluralrules": "6.3.10",
"@formatjs/intl-relativetimeformat": "12.3.10",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@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",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
@@ -68,6 +63,7 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
@@ -76,8 +72,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.1.3",
"@tsparticles/preset-links": "4.1.3",
"@tsparticles/engine": "4.2.1",
"@tsparticles/preset-links": "4.2.1",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -131,10 +127,10 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.7",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@babel/core": "8.0.0",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.0",
"@babel/preset-env": "8.0.0",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.62.0",
@@ -142,11 +138,9 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.60.0",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rsdoctor/rspack-plugin": "1.5.15",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -157,13 +151,13 @@
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.1",
"@types/luxon": "3.7.2",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.8",
"@vitest/coverage-v8": "4.1.9",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"babel-plugin-template-html-minifier": "patch:babel-plugin-template-html-minifier@npm%3A4.1.0#~/.yarn/patches/babel-plugin-template-html-minifier-npm-4.1.0-9a3c00055a.patch",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.5.0",
@@ -202,9 +196,9 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.0",
"typescript-eslint": "8.61.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"vitest": "4.1.9",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -214,14 +208,14 @@
"lit-html": "3.3.3",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.6.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.16.0",
"packageManager": "yarn@4.17.0",
"volta": {
"node": "24.16.0"
"node": "24.17.0"
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ import { ensureArray } from "../array/ensure-array";
import { isComponentLoaded } from "./is_component_loaded";
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
isCore(page) || isLoadedIntegration(hass, page);
(isCore(page) || isLoadedIntegration(hass, page)) &&
(!page.filter || page.filter(hass));
export const isLoadedIntegration = (
hass: HomeAssistant,
+19
View File
@@ -110,6 +110,25 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
"media_player",
]);
/** Domains that use a timestamp for state. */
export const TIMESTAMP_STATE_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
/** Temperature units. */
export const UNIT_C = "°C";
export const UNIT_F = "°F";
@@ -0,0 +1,29 @@
import { Task, type TaskConfig } from "@lit/task";
import type { ReactiveControllerHost } from "lit";
/**
* A `@lit/task` Task with a sticky `resolved` flag: false until the task has
* completed once, then true. Lets callers tell "still loading" apart from
* "resolved with an empty value" without a null sentinel, while keeping the
* previous value during a re-run.
*/
export class AsyncValueTask<T extends readonly unknown[], R> extends Task<
T,
R
> {
private _resolved = false;
constructor(host: ReactiveControllerHost, config: TaskConfig<T, R>) {
super(host, {
...config,
onComplete: (value) => {
this._resolved = true;
config.onComplete?.(value);
},
});
}
public get resolved(): boolean {
return this._resolved;
}
}
+6 -5
View File
@@ -3,23 +3,24 @@ import type { FrontendLocaleData } from "../../data/translation";
import { selectUnit } from "../util/select-unit";
const formatRelTimeMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto" })
(locale: FrontendLocaleData, style: Intl.RelativeTimeFormatStyle) =>
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto", style })
);
export const relativeTime = (
from: Date,
locale: FrontendLocaleData,
to?: Date,
includeTense = true
includeTense = true,
style: Intl.RelativeTimeFormatStyle = "long"
): string => {
const diff = selectUnit(from, to, locale);
if (includeTense) {
return formatRelTimeMem(locale).format(diff.value, diff.unit);
return formatRelTimeMem(locale, style).format(diff.value, diff.unit);
}
return Intl.NumberFormat(locale.language, {
style: "unit",
unit: diff.unit,
unitDisplay: "long",
unitDisplay: style,
}).format(Math.abs(diff.value));
};
@@ -60,6 +60,17 @@ export const computeAttributeValueToParts = (
return [{ type: "value", value: localize("state.default.unknown") }];
}
// Device class attribute, return the integration's translated name
if (attribute === "device_class" && typeof attributeValue === "string") {
const domain = computeStateDomain(stateObj);
const deviceClassName = localize(
`component.${domain}.entity_component.${attributeValue}.name`
);
if (deviceClassName) {
return [{ type: "value", value: deviceClassName }];
}
}
// Number value, return formatted number
if (typeof attributeValue === "number") {
const domain = computeStateDomain(stateObj);
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { unitFromParts } from "./value_parts";
interface EntityUnitStubConfig {
entity: string;
@@ -40,5 +41,5 @@ export const computeEntityUnitDisplay = (
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
: hass.formatEntityStateToParts(stateObj);
return parts.find((part) => part.type === "unit")?.value ?? "";
return unitFromParts(parts);
};
+4 -21
View File
@@ -21,29 +21,11 @@ import {
isNumericSensorDeviceClass,
SENSOR_TIMESTAMP_DEVICE_CLASSES,
} from "../../data/sensor";
import { TIMESTAMP_STATE_DOMAINS } from "../const";
// Domains whose state is a timezone-agnostic date and/or time string.
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
// Domains whose state is a timestamp.
const TIMESTAMP_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
@@ -178,7 +160,8 @@ const computeStateToPartsFromEntityAttributes = (
const type = MONETARY_TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
// Merge consecutive value parts so the number stays a single part
// (e.g. "-" + "12" + "." + "00" → "-12.00")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
@@ -273,7 +256,7 @@ const computeStateToPartsFromEntityAttributes = (
// state is a timestamp
if (
TIMESTAMP_DOMAINS.has(domain) ||
TIMESTAMP_STATE_DOMAINS.has(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
) {
-23
View File
@@ -1,23 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { supportsFeature } from "./supports-feature";
export type FeatureClassNames<T extends number = number> = Partial<
Record<T, string>
>;
// Expects classNames to be an object mapping feature-bit -> className
export const featureClassNames = (
stateObj: HassEntity,
classNames: FeatureClassNames
) => {
if (!stateObj || !stateObj.attributes.supported_features) {
return "";
}
return Object.keys(classNames)
.map((feature) =>
supportsFeature(stateObj, Number(feature)) ? classNames[feature] : ""
)
.filter((attr) => attr !== "")
.join(" ");
};
+56
View File
@@ -0,0 +1,56 @@
import { AITaskEntityFeature } from "../../data/ai_task";
import { AlarmControlPanelEntityFeature } from "../../data/alarm_control_panel";
import { AssistSatelliteEntityFeature } from "../../data/assist_satellite";
import { CalendarEntityFeature } from "../../data/calendar";
import { CameraEntityFeature } from "../../data/camera";
import { ClimateEntityFeature } from "../../data/climate";
import { ConversationEntityFeature } from "../../data/conversation";
import { CoverEntityFeature } from "../../data/cover";
import { FanEntityFeature } from "../../data/fan";
import { HumidifierEntityFeature } from "../../data/humidifier";
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
import { LightEntityFeature } from "../../data/light";
import { LockEntityFeature } from "../../data/lock";
import { MediaPlayerEntityFeature } from "../../data/media-player";
import { NotifyEntityFeature } from "../../data/notify";
import { RemoteEntityFeature } from "../../data/remote";
import { SirenEntityFeature } from "../../data/siren";
import { TodoListEntityFeature } from "../../data/todo";
import { UpdateEntityFeature } from "../../data/update";
import { VacuumEntityFeature } from "../../data/vacuum";
import { ValveEntityFeature } from "../../data/valve";
import { WaterHeaterEntityFeature } from "../../data/water_heater";
import { WeatherEntityFeature } from "../../data/weather";
export type FeatureEnum = Record<string | number, string | number>;
const DOMAIN_ENUMS = {
ai_task: AITaskEntityFeature,
alarm_control_panel: AlarmControlPanelEntityFeature,
assist_satellite: AssistSatelliteEntityFeature,
calendar: CalendarEntityFeature,
camera: CameraEntityFeature,
climate: ClimateEntityFeature,
conversation: ConversationEntityFeature,
cover: CoverEntityFeature,
fan: FanEntityFeature,
humidifier: HumidifierEntityFeature,
lawn_mower: LawnMowerEntityFeature,
light: LightEntityFeature,
lock: LockEntityFeature,
media_player: MediaPlayerEntityFeature,
notify: NotifyEntityFeature,
remote: RemoteEntityFeature,
siren: SirenEntityFeature,
todo: TodoListEntityFeature,
update: UpdateEntityFeature,
vacuum: VacuumEntityFeature,
valve: ValveEntityFeature,
water_heater: WaterHeaterEntityFeature,
weather: WeatherEntityFeature,
};
export function getFeatures(domain: string): FeatureEnum | undefined {
const enumObj = DOMAIN_ENUMS[domain] as FeatureEnum;
return enumObj;
}
+84 -76
View File
@@ -22,16 +22,13 @@ export const FIXED_DOMAIN_STATES = {
assist_satellite: ["idle", "listening", "responding", "processing"],
automation: ["on", "off"],
binary_sensor: ["on", "off"],
button: [],
calendar: ["on", "off"],
camera: ["idle", "recording", "streaming"],
cover: ["closed", "closing", "open", "opening"],
device_tracker: ["home", "not_home"],
fan: ["on", "off"],
humidifier: ["on", "off"],
infrared: [],
input_boolean: ["on", "off"],
input_button: [],
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
light: ["on", "off"],
lock: [
@@ -56,7 +53,6 @@ export const FIXED_DOMAIN_STATES = {
plant: ["ok", "problem"],
radio_frequency: [],
remote: ["on", "off"],
scene: [],
schedule: ["on", "off"],
script: ["on", "off"],
siren: ["on", "off"],
@@ -290,6 +286,81 @@ export const getStatesDomain = (
return result;
};
// Maps a value attribute (or the main state, keyed `_`) to the attribute listing
// its options. Naming is irregular per domain, so it's mapped explicitly.
export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
string,
Record<string, string>
> = {
climate: {
_: "hvac_modes",
fan_mode: "fan_modes",
preset_mode: "preset_modes",
swing_mode: "swing_modes",
swing_horizontal_mode: "swing_horizontal_modes",
},
event: {
event_type: "event_types",
},
fan: {
preset_mode: "preset_modes",
},
humidifier: {
mode: "available_modes",
},
input_select: {
_: "options",
},
select: {
_: "options",
},
light: {
effect: "effect_list",
color_mode: "supported_color_modes",
},
media_player: {
sound_mode: "sound_mode_list",
source: "source_list",
},
remote: {
current_activity: "activity_list",
},
sensor: {
_: "options",
},
vacuum: {
fan_speed: "fan_speed_list",
},
water_heater: {
_: "operation_list",
operation_mode: "operation_list",
},
};
const DOMAIN_VALUE_ATTRIBUTES: Record<
string,
Record<string, string>
> = Object.fromEntries(
Object.entries(DOMAIN_OPTIONS_ATTRIBUTES).map(([domain, mapping]) => [
domain,
Object.fromEntries(
Object.entries(mapping).map(([value, list]) => [list, value])
),
])
);
// value attribute (or main state) → its options-list attribute
export const getOptionsAttribute = (
domain: string,
attribute?: string
): string | undefined => DOMAIN_OPTIONS_ATTRIBUTES[domain]?.[attribute ?? "_"];
// options-list attribute → its value attribute (`_` = main state)
export const getValueAttribute = (
domain: string,
optionsAttribute: string
): string | undefined => DOMAIN_VALUE_ATTRIBUTES[domain]?.[optionsAttribute];
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
@@ -302,78 +373,15 @@ export const getStates = (
result.push(...getStatesDomain(hass, domain, attribute));
// Dynamic values based on the entities
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "event":
if (attribute === "event_type") {
result.push(...state.attributes.event_types);
}
break;
case "fan":
if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
}
break;
case "humidifier":
if (attribute === "mode") {
result.push(...state.attributes.available_modes);
}
break;
case "input_select":
case "select":
if (!attribute) {
result.push(...state.attributes.options);
}
break;
case "light":
if (attribute === "effect" && state.attributes.effect_list) {
result.push(...state.attributes.effect_list);
} else if (
attribute === "color_mode" &&
state.attributes.supported_color_modes
) {
result.push(...state.attributes.supported_color_modes);
}
break;
case "media_player":
if (attribute === "sound_mode") {
result.push(...state.attributes.sound_mode_list);
} else if (attribute === "source") {
result.push(...state.attributes.source_list);
}
break;
case "remote":
if (attribute === "current_activity") {
result.push(...state.attributes.activity_list);
}
break;
case "sensor":
if (!attribute && state.attributes.device_class === "enum") {
result.push(...state.attributes.options);
}
break;
case "vacuum":
if (attribute === "fan_speed") {
result.push(...state.attributes.fan_speed_list);
}
break;
case "water_heater":
if (!attribute || attribute === "operation_mode") {
result.push(...state.attributes.operation_list);
}
break;
const optionsAttribute = getOptionsAttribute(domain, attribute);
if (optionsAttribute) {
const options = state.attributes[optionsAttribute];
// Sensors only expose their options when their device class is `enum`.
const enumSensor =
domain !== "sensor" || state.attributes.device_class === "enum";
if (enumSensor && Array.isArray(options)) {
result.push(...options);
}
}
return [...new Set(result)];
+2 -10
View File
@@ -1,21 +1,13 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { computeDomain } from "./compute_domain";
import { TIMESTAMP_STATE_DOMAINS } from "../const";
export function stateActive(stateObj: HassEntity, state?: string): boolean {
const domain = computeDomain(stateObj.entity_id);
const compareState = state !== undefined ? state : stateObj?.state;
if (
[
"button",
"event",
"infrared",
"input_button",
"radio_frequency",
"scene",
].includes(domain)
) {
if (TIMESTAMP_STATE_DOMAINS.has(domain)) {
return compareState !== UNAVAILABLE;
}
+29
View File
@@ -0,0 +1,29 @@
import type { ValuePart } from "../../types";
// Joins every part except the unit, keeping native order so the sign and
// grouping stay with the value (e.g. "-2,548.14").
export const valueFromParts = (parts: ValuePart[]): string =>
parts
.filter((part) => part.type !== "unit")
.map((part) => part.value)
.join("")
.trim();
export const unitFromParts = (parts: ValuePart[]): string =>
parts.find((part) => part.type === "unit")?.value ?? "";
export type UnitPosition = "before" | "after";
// Whether the unit sits before or after the value in the locale's native order
// (e.g. "$5" / "€ 5" → "before", "5 €" / "5 %" → "after").
export const unitPosition = (parts: ValuePart[]): UnitPosition => {
const unitIndex = parts.findIndex((part) => part.type === "unit");
if (unitIndex === -1) {
return "after";
}
const lastValueIndex = parts.reduceRight(
(acc, part, i) => (acc === -1 && part.type === "value" ? i : acc),
-1
);
return unitIndex < lastValueIndex ? "before" : "after";
};
+23 -7
View File
@@ -1,5 +1,5 @@
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { RelatedResult } from "../../data/search";
import type { ItemType, RelatedResult } from "../../data/search";
export interface RelatedIdSets {
areas: Set<string>;
@@ -8,14 +8,30 @@ export interface RelatedIdSets {
}
/**
* Build a set of related IDs for a given related result.
* Build a set of related IDs, merging in the current (queried) item.
* `search/related` does not echo the queried item back, but it is the closest
* related item (e.g. a card editor's own entity), so it is merged into the
* matching group when it is an area, device, or entity.
* @param related - The related result to build the sets from.
* @returns The related ID sets.
* @param current - The queried item to merge in.
* @returns The related ID sets, including the current item.
*/
export const buildRelatedIdSets = (related?: RelatedResult): RelatedIdSets => ({
areas: new Set(related?.area || []),
devices: new Set(related?.device || []),
entities: new Set(related?.entity || []),
export const buildRelatedIdSets = (
related?: RelatedResult,
current?: { itemType: ItemType; itemId: string }
): RelatedIdSets => ({
areas: new Set([
...(related?.area || []),
...(current?.itemType === "area" ? [current.itemId] : []),
]),
devices: new Set([
...(related?.device || []),
...(current?.itemType === "device" ? [current.itemId] : []),
]),
entities: new Set([
...(related?.entity || []),
...(current?.itemType === "entity" ? [current.itemId] : []),
]),
});
/**
-2
View File
@@ -17,8 +17,6 @@ export type LocalizeKeys =
| `ui.common.${string}`
| `ui.components.calendar.event.rrule.${string}`
| `ui.components.selectors.file.${string}`
| `ui.components.logbook.messages.detected_device_classes.${string}`
| `ui.components.logbook.messages.cleared_device_classes.${string}`
| `ui.dialogs.entity_registry.editor.${string}`
| `ui.dialogs.more_info_control.lawn_mower.${string}`
| `ui.dialogs.more_info_control.vacuum.${string}`
+26
View File
@@ -0,0 +1,26 @@
/**
* Return a shallow copy of an object with every key removed whose value is
* `undefined` or equals that key's default, so a key left at its default
* (whether absent or explicit) does not count as a difference. A key's default
* comes from `defaults` when present, otherwise `false`.
*
* Non-plain-object values are returned unchanged; only top-level keys are
* compared.
*/
export const stripDefaults = <T>(
value: T,
defaults?: Record<string, unknown>
): T => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const defaultValue = defaults && key in defaults ? defaults[key] : false;
if (val === undefined || val === defaultValue) {
continue;
}
result[key] = val;
}
return result as T;
};
@@ -4,10 +4,10 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
/**
* Call a function with result caching per entity.
* @param cacheKey key to store the cache on hass object
* @param cacheKey key to namespace the cache
* @param cacheTime time to cache the results
* @param func function to fetch the data
* @param hass Home Assistant object
* @param hass Home Assistant object (or slice) the cache is keyed on
* @param entityId entity to fetch data for
* @param args extra arguments to pass to the function to fetch the data
* @returns
@@ -15,8 +15,12 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
export const timeCacheEntityPromiseFunc = async <T>(
cacheKey: string,
cacheTime: number,
func: (hass: HomeAssistant, entityId: string, ...args: any[]) => Promise<T>,
hass: HomeAssistant,
func: (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
entityId: string,
...args: any[]
) => Promise<T>,
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
entityId: string,
...args: any[]
): Promise<T> => {
@@ -39,11 +43,11 @@ export const timeCacheEntityPromiseFunc = async <T>(
// When successful, set timer to clear cache
() =>
setTimeout(() => {
cache![entityId] = undefined;
cache[entityId] = undefined;
}, cacheTime),
// On failure, clear cache right away
() => {
cache![entityId] = undefined;
cache[entityId] = undefined;
}
);
@@ -1,5 +1,12 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-svg-icon";
import {
mdiAlertCircle,
mdiCheckCircle,
mdiCloseCircle,
mdiHelpCircle,
} from "@mdi/js";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -19,46 +26,59 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
private get _iconPath() {
switch (this.state) {
case "pass":
return mdiCheckCircle;
case "fail":
return mdiCloseCircle;
case "invalid":
return mdiAlertCircle;
default:
return mdiHelpCircle;
}
}
protected render() {
return html`
<div
id="indicator"
role="status"
tabindex="0"
aria-label=${this.label}
></div>
<div id="indicator" role="status" tabindex="0" aria-label=${this.label}>
<ha-svg-icon .path=${this._iconPath}></ha-svg-icon>
</div>
`;
}
static styles = css`
:host {
position: absolute;
top: -5px;
inset-inline-end: -6px;
top: -8px;
inset-inline-end: -8px;
display: inline-block;
}
#indicator {
width: 10px;
height: 10px;
width: 16px;
height: 16px;
display: grid;
place-items: center;
border-radius: var(--ha-border-radius-circle);
border: var(--ha-border-width-md) solid;
box-sizing: border-box;
background-color: var(--card-background-color);
box-shadow: 0 0 0 2px var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
#indicator ha-svg-icon {
width: 16px;
height: 16px;
--mdc-icon-size: 16px;
}
:host([state="pass"]) #indicator {
background-color: var(--ha-color-green-60);
border-color: var(--ha-color-green-60);
color: var(--ha-color-green-60);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-orange-60);
color: var(--ha-color-orange-60);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-red-60);
color: var(--ha-color-red-60);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-neutral-60);
color: var(--ha-color-neutral-60);
}
`;
}
@@ -1,16 +1,18 @@
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "./ha-progress-button";
import type { HomeAssistant } from "../../types";
import { apiContext } from "../../data/context";
import { fireEvent } from "../../common/dom/fire_event";
import type { Appearance } from "../ha-button";
@customElement("ha-call-service-button")
class HaCallServiceButton extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ type: Boolean }) public disabled = false;
@@ -56,7 +58,7 @@ class HaCallServiceButton extends LitElement {
this.shadowRoot!.querySelector("ha-progress-button")!;
try {
await this.hass.callService(
await this._api.callService(
this.domain,
this.service,
this.data,
@@ -445,6 +445,7 @@ export class StateHistoryChartLine extends LitElement {
private _formatYAxisLabel = (value: number) => {
const label = formatNumber(value, this.hass.locale, {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
+1
View File
@@ -552,6 +552,7 @@ export class StatisticsChart extends LitElement {
private _formatYAxisLabel = (value: number) =>
formatNumber(value, this.hass.locale, {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
+5 -3
View File
@@ -79,9 +79,11 @@ function computeTimelineEnumColor(
const domain = computeStateDomain(stateObj);
const states =
FIXED_DOMAIN_STATES[domain] ||
(domain === "sensor" &&
stateObj.attributes.device_class === "enum" &&
stateObj.attributes.options) ||
((domain === "sensor" && stateObj.attributes.device_class === "enum") ||
domain === "select" ||
domain === "input_select"
? stateObj.attributes.options
: undefined) ||
[];
const idx = states.indexOf(state);
if (idx === -1) {
+59 -19
View File
@@ -1,4 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume } from "@lit/context";
import { mdiPlus, mdiShape } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -6,10 +7,14 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { RelatedIdSets } from "../../common/search/related-context";
import { relatedContext } from "../../data/context";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
entityComboBoxKeys,
getEntities,
markEntitiesRelated,
sortEntitiesByRelatedRank,
type EntityComboBoxItem,
} from "../../data/entity/entity_picker";
import { domainToName } from "../../data/integration";
@@ -131,6 +136,20 @@ export class HaEntityPicker extends LitElement {
@state() private _pendingEntityId?: string;
@state()
@consume({ context: relatedContext, subscribe: true })
private _relatedIdSets?: RelatedIdSets;
private get _hasRelatedContext(): boolean {
const related = this._relatedIdSets;
return (
!!related &&
(related.entities.size > 0 ||
related.devices.size > 0 ||
related.areas.size > 0)
);
}
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
this._pendingEntityId &&
@@ -161,11 +180,7 @@ export class HaEntityPicker extends LitElement {
: undefined;
if (stateObj) {
return html`
<state-badge
slot="start"
.stateObj=${stateObj}
.hass=${this.hass}
></state-badge>
<state-badge slot="start" .stateObj=${stateObj}></state-badge>
`;
}
if (extraOption.icon_path) {
@@ -216,11 +231,7 @@ export class HaEntityPicker extends LitElement {
);
return html`
<state-badge
.hass=${this.hass}
.stateObj=${stateObj}
slot="start"
></state-badge>
<state-badge .stateObj=${stateObj} slot="start"></state-badge>
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
@@ -250,7 +261,6 @@ export class HaEntityPicker extends LitElement {
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`}
<span slot="headline">${item.primary}</span>
@@ -333,8 +343,22 @@ export class HaEntityPicker extends LitElement {
})
);
private _sortByRelatedContext = memoizeOne(
(
items: EntityComboBoxItem[],
related: RelatedIdSets,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
language: string
): EntityComboBoxItem[] =>
sortEntitiesByRelatedRank(
markEntitiesRelated(items, related, entities, devices),
language
)
);
private _getItems = () => {
const items = this._getEntitiesMemoized(
const entityItems = this._getEntitiesMemoized(
this.hass,
this.includeDomains,
this.excludeDomains,
@@ -345,14 +369,23 @@ export class HaEntityPicker extends LitElement {
this.excludeEntities,
this.value
);
const sortedItems = this._hasRelatedContext
? this._sortByRelatedContext(
entityItems,
this._relatedIdSets!,
this.hass.entities,
this.hass.devices,
this.hass.locale.language
)
: entityItems;
if (this.extraOptions?.length) {
const resolvedExtras = this.extraOptions.map((opt) => ({
...opt,
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
}));
return [...resolvedExtras, ...items];
return [...resolvedExtras, ...sortedItems];
}
return items;
return sortedItems;
};
private _shouldHideClearIcon() {
@@ -384,6 +417,7 @@ export class HaEntityPicker extends LitElement {
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys}
.noSort=${this._hasRelatedContext}
use-top-label
.addButtonLabel=${this.addButton
? (this.addButtonLabel ??
@@ -402,17 +436,23 @@ export class HaEntityPicker extends LitElement {
search,
filteredItems
) => {
// Float related items to the top by closeness, keeping search relevance
// order within each tier.
const items = this._hasRelatedContext
? sortEntitiesByRelatedRank(filteredItems)
: filteredItems;
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex(
const index = items.findIndex(
(item) => item.stateObj?.entity_id === search
);
if (index === -1) {
return filteredItems;
return items;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
const [exactMatch] = items.splice(index, 1);
items.unshift(exactMatch);
return items;
};
public async open() {
+6 -5
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFlash, mdiFlashOff } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
@@ -6,9 +7,9 @@ import { customElement, property, state } from "lit/decorators";
import { STATES_OFF } from "../../common/const";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { apiContext } from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-formfield";
import "../ha-icon-button";
import "../ha-switch";
@@ -29,8 +30,8 @@ const isOn = (stateObj?: HassEntity) =>
@customElement("ha-entity-toggle")
export class HaEntityToggle extends LitElement {
// hass is not a property so that we only re-render on stateObj changes
public hass?: HomeAssistant;
@consume({ context: apiContext, subscribe: true })
private _api?: ContextType<typeof apiContext>;
@property({ attribute: false }) public stateObj?: HassEntity;
@@ -118,7 +119,7 @@ export class HaEntityToggle extends LitElement {
// result in the entity to be turned on. Since the state is not changing,
// the resync is not called automatic.
private async _callService(turnOn): Promise<void> {
if (!this.hass || !this.stateObj) {
if (!this._api || !this.stateObj) {
return;
}
forwardHaptic(this, "light");
@@ -149,7 +150,7 @@ export class HaEntityToggle extends LitElement {
this._isOn = turnOn;
try {
await this.hass.callService(serviceDomain, service, {
await this._api.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
} finally {
+39 -20
View File
@@ -1,3 +1,5 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
@@ -6,13 +8,19 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { arrayLiteralIncludes } from "../../common/array/literal-includes";
import secondsToDuration from "../../common/datetime/seconds_to_duration";
import {
consumeEntityRegistryEntry,
consumeLocalize,
} from "../../common/decorators/consume-context-entry";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { unitFromParts, valueFromParts } from "../../common/entity/value_parts";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import type { LocalizeFunc } from "../../common/translations/localize";
import { formattersContext } from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { timerTimeRemaining } from "../../data/timer";
import type { HomeAssistant } from "../../types";
import "../ha-label-badge";
import "../ha-state-icon";
@@ -40,7 +48,15 @@ const getTruncatedKey = (domainKey: string, stateKey: string) => {
@customElement("ha-state-label-badge")
export class HaStateLabelBadge extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consumeEntityRegistryEntry({ entityIdPath: ["state", "entity_id"] })
private _entry?: EntityRegistryDisplayEntry;
@property({ attribute: false }) public state?: HassEntity;
@@ -77,10 +93,8 @@ export class HaStateLabelBadge extends LitElement {
return html`
<ha-label-badge
class="warning"
label=${this.hass!.localize("state_badge.default.error")}
description=${this.hass!.localize(
"state_badge.default.entity_not_found"
)}
label=${this._localize("state_badge.default.error")}
description=${this._localize("state_badge.default.entity_not_found")}
>
<ha-svg-icon .path=${mdiAlert}></ha-svg-icon>
</ha-label-badge>
@@ -94,7 +108,7 @@ export class HaStateLabelBadge extends LitElement {
// 4. Icon determined via entity state
// 5. Value string as fallback
const domain = computeStateDomain(entityState);
const entry = this.hass?.entities[entityState.entity_id];
const entry = this._entry;
const showIcon =
this.icon || this._computeShowIcon(domain, entityState, entry);
@@ -163,20 +177,23 @@ export class HaStateLabelBadge extends LitElement {
case "sun":
case "timer":
return null;
// @ts-expect-error we don't break and go to default
case "sensor":
if (entry?.platform === "moon") {
return null;
}
// eslint-disable-next-line: disable=no-fallthrough
break;
default:
return entityState.state === UNAVAILABLE ||
entityState.state === UNKNOWN
? "—"
: this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "value"
)?.value;
break;
}
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return "—";
}
if (!this._formatters) {
return null;
}
return valueFromParts(
this._formatters.formatEntityStateToParts(entityState)
);
}
private _computeShowIcon(
@@ -211,11 +228,11 @@ export class HaStateLabelBadge extends LitElement {
) {
// For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return this.hass!.localize(`state_badge.default.${entityState.state}`);
return this._localize(`state_badge.default.${entityState.state}`);
}
const domainStateKey = getTruncatedKey(domain, entityState.state);
if (domainStateKey) {
return this.hass!.localize(`state_badge.${domainStateKey}`);
return this._localize(`state_badge.${domainStateKey}`);
}
// Person and device tracker state can be zone name
if (domain === "person" || domain === "device_tracker") {
@@ -224,10 +241,12 @@ export class HaStateLabelBadge extends LitElement {
if (domain === "timer") {
return secondsToDuration(_timerTimeRemaining);
}
if (!this._formatters) {
return null;
}
return (
this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "unit"
)?.value || null
unitFromParts(this._formatters.formatEntityStateToParts(entityState)) ||
null
);
}
+1 -6
View File
@@ -343,11 +343,7 @@ export class HaStatisticPicker extends LitElement {
return html`
${item.stateObj
? html`
<state-badge
.hass=${this.hass}
.stateObj=${item.stateObj}
slot="start"
></state-badge>
<state-badge .stateObj=${item.stateObj} slot="start"></state-badge>
`
: item.icon_path
? html`
@@ -488,7 +484,6 @@ export class HaStatisticPicker extends LitElement {
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
+28 -15
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -14,13 +15,12 @@ import {
import { iconColorCSS } from "../../common/style/icon_color_css";
import { cameraUrlWithWidthHeight } from "../../data/camera";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { HomeAssistant } from "../../types";
import { connectionContext } from "../../data/context";
import { isBrandUrl } from "../../util/brands-url";
import "../ha-state-icon";
@customElement("state-badge")
export class StateBadge extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public overrideIcon?: string;
@@ -36,6 +36,10 @@ export class StateBadge extends LitElement {
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@property({ type: Boolean, reflect: true }) public icon = true;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state() private _iconStyle: Record<string, string | undefined> = {};
connectedCallback(): void {
@@ -106,14 +110,15 @@ export class StateBadge extends LitElement {
></ha-state-icon>`;
}
public willUpdate(changedProps: PropertyValues<this>) {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (
!changedProps.has("stateObj") &&
!changedProps.has("overrideImage") &&
!changedProps.has("overrideIcon") &&
!changedProps.has("stateColor") &&
!changedProps.has("color")
!changedProps.has("color") &&
!changedProps.has("_connection")
) {
return;
}
@@ -133,12 +138,10 @@ export class StateBadge extends LitElement {
stateObj.attributes.entity_picture) &&
!this.overrideIcon
) {
let imageUrl =
let imageUrl = this._resolveImageUrl(
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
stateObj.attributes.entity_picture
);
if (domain === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}
@@ -179,11 +182,7 @@ export class StateBadge extends LitElement {
}
}
} else if (this.overrideImage) {
let imageUrl = this.overrideImage;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
backgroundImage = `url(${imageUrl})`;
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
this.icon = false;
}
}
@@ -192,6 +191,20 @@ export class StateBadge extends LitElement {
this.style.backgroundImage = backgroundImage;
}
// Sign the image URL via the connection context so brand images
// (/api/brands/...) get their access token. Without a way to sign, a brands
// request would be rejected (and logged/blocked by core), so skip it until
// we can sign.
private _resolveImageUrl(url: string | undefined): string {
if (!url) {
return "";
}
if (this._connection) {
return this._connection.hassUrl(url);
}
return isBrandUrl(url) ? "" : url;
}
protected getClass() {
const cls = new Map(
["has-no-radius", "has-media-image", "has-image"].map((_cls) => [
-1
View File
@@ -24,7 +24,6 @@ class StateInfo extends LitElement {
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
return html`<state-badge
.hass=${this.hass}
.stateObj=${this.stateObj}
.stateColor=${true}
.color=${this.color}
@@ -173,7 +173,6 @@ export class HaAreaControlsPicker extends LitElement {
domainItems = multiTermSortedSearch(
domainItems,
searchString,
this._domainSearchKeys,
(item) => item.id,
fuseIndex
);
@@ -226,7 +225,6 @@ export class HaAreaControlsPicker extends LitElement {
entityItems = multiTermSortedSearch(
entityItems,
searchString,
this._entitySearchKeys,
(item) => item.id,
fuseIndex
);
+12 -5
View File
@@ -1,10 +1,11 @@
import { consume, type ContextType } from "@lit/context";
import { mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { getAreaContext } from "../common/entity/context/get_area_context";
import type { HomeAssistant } from "../types";
import { areasContext, floorsContext } from "../data/context";
import "./ha-expansion-panel";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
@@ -17,7 +18,13 @@ export interface AreasDisplayValue {
@customElement("ha-areas-display-editor")
export class HaAreasDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@consume({ context: areasContext, subscribe: true })
@state()
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floors!: ContextType<typeof floorsContext>;
@property() public label?: string;
@@ -35,10 +42,10 @@ export class HaAreasDisplayEditor extends LitElement {
public showNavigationButton = false;
protected render(): TemplateResult {
const areas = Object.values(this.hass.areas);
const areas = Object.values(this._areas);
const items: DisplayItem[] = areas.map((area) => {
const { floor } = getAreaContext(area, this.hass.floors);
const { floor } = getAreaContext(area, this._floors);
return {
value: area.area_id,
label: area.name,
@@ -1,15 +1,19 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import type { LocalizeFunc } from "../common/translations/localize";
import { areasContext, floorsContext } from "../data/context";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { ValueChangedEvent } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-items-display-editor";
@@ -30,7 +34,17 @@ const UNASSIGNED_FLOOR = "__unassigned__";
@customElement("ha-areas-floors-display-editor")
export class HaAreasFloorsDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: areasContext, subscribe: true })
@state()
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floors!: ContextType<typeof floorsContext>;
@property() public label?: string;
@@ -51,13 +65,14 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
protected render(): TemplateResult {
const groupedAreasItems = this._groupedAreasItems(
this.hass.areas,
this.hass.floors
this._areas,
this._floors
);
const filteredFloors = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
this._floors,
this.value?.floors_display?.order,
this._localize
).filter(
(floor) =>
// Only include floors that have areas assigned to them
@@ -124,15 +139,14 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
private _groupedAreasItems = memoizeOne(
(
hassAreas: HomeAssistant["areas"],
// update items if floors change
_hassFloors: HomeAssistant["floors"]
areas: ContextType<typeof areasContext>,
floors: ContextType<typeof floorsContext>
): Record<string, DisplayItem[]> => {
const areas = Object.values(hassAreas);
const areaList = Object.values(areas);
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
const groupedItems: Record<string, DisplayItem[]> = areaList.reduce(
(acc, area) => {
const { floor } = getAreaContext(area, this.hass.floors);
const { floor } = getAreaContext(area, floors);
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
if (!acc[floorId]) {
@@ -155,23 +169,24 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
private _sortedFloors = memoizeOne(
(
hassFloors: HomeAssistant["floors"],
order: string[] | undefined
floors: ContextType<typeof floorsContext>,
order: string[] | undefined,
localize: LocalizeFunc
): FloorRegistryEntry[] => {
const floors = getFloors(hassFloors, order);
const noFloors = floors.length === 0;
floors.push({
const sortedFloors = getFloors(floors, order);
const noFloors = sortedFloors.length === 0;
sortedFloors.push({
floor_id: UNASSIGNED_FLOOR,
name: noFloors
? this.hass.localize("ui.panel.lovelace.strategy.areas.areas")
: this.hass.localize("ui.panel.lovelace.strategy.areas.other_areas"),
? localize("ui.panel.lovelace.strategy.areas.areas")
: localize("ui.panel.lovelace.strategy.areas.other_areas"),
icon: null,
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
});
return floors;
return sortedFloors;
}
);
@@ -180,8 +195,9 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
const floorIds = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
this._floors,
this.value?.floors_display?.order,
this._localize
).map((floor) => floor.floor_id);
const newOrder = [...floorIds];
const movedFloorId = newOrder.splice(oldIndex, 1)[0];
@@ -204,8 +220,9 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const currentFloorId = (ev.currentTarget as any).floorId;
const floorIds = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
this._floors,
this.value?.floors_display?.order,
this._localize
).map((floor) => floor.floor_id);
const oldAreaDisplay = this.value?.areas_display ?? {};
@@ -223,14 +240,14 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
continue;
}
const hidden = oldHidden.filter((areaId) => {
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (hidden?.length) {
newHidden.push(...hidden);
}
const order = oldOrder.filter((areaId) => {
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (order?.length) {
+52 -24
View File
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import {
mdiAlertCircle,
mdiChevronDown,
@@ -10,7 +11,9 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import {
runAssistPipeline,
type AssistPipeline,
@@ -18,10 +21,19 @@ import {
type ConversationChatLogToolResultDelta,
type PipelineRunEvent,
} from "../data/assist_pipeline";
import {
configContext,
connectionContext,
statesContext,
} from "../data/context";
import { ConversationEntityFeature } from "../data/conversation";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import type {
HomeAssistant,
HomeAssistantConfig,
HomeAssistantConnection,
} from "../types";
import { AudioRecorder } from "../util/audio-recorder";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
@@ -47,8 +59,6 @@ interface AssistMessage {
@customElement("ha-assist-chat")
export class HaAssistChat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public pipeline?: AssistPipeline;
@property({ type: Boolean, attribute: "disable-speech" })
@@ -71,6 +81,22 @@ export class HaAssistChat extends LitElement {
@state() private _processing = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@state()
@consume({ context: configContext, subscribe: true })
private _config!: HomeAssistantConfig;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: HomeAssistantConnection;
private _conversationId: string | null = null;
private _audioRecorder?: AudioRecorder;
@@ -86,7 +112,7 @@ export class HaAssistChat extends LitElement {
this._conversation = [
{
who: "hass",
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
text: this._localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
@@ -124,9 +150,9 @@ export class HaAssistChat extends LitElement {
const controlHA = !this.pipeline
? false
: this.pipeline.prefer_local_intents ||
(this.hass.states[this.pipeline.conversation_engine]
(this._states[this.pipeline.conversation_engine]
? supportsFeature(
this.hass.states[this.pipeline.conversation_engine],
this._states[this.pipeline.conversation_engine],
ConversationEntityFeature.CONTROL
)
: true);
@@ -139,7 +165,7 @@ export class HaAssistChat extends LitElement {
? nothing
: html`
<ha-alert>
${this.hass.localize(
${this._localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
@@ -180,7 +206,7 @@ export class HaAssistChat extends LitElement {
.path=${mdiCommentProcessingOutline}
></ha-svg-icon>
<span class="thinking-label">
${this.hass.localize(
${this._localize(
"ui.dialogs.voice_command.show_details"
)}
</span>
@@ -251,7 +277,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
id="message-input"
@keyup=${this._handleKeyUp}
@input=${this._handleInput}
.label=${this.hass.localize(`ui.dialogs.voice_command.input_label`)}
.label=${this._localize(`ui.dialogs.voice_command.input_label`)}
>
<div slot="end">
${this._showSendButton || !supportsSTT
@@ -261,7 +287,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
.path=${mdiSend}
@click=${this._handleSendMessage}
.disabled=${this._processing}
.label=${this.hass.localize(
.label=${this._localize(
"ui.dialogs.voice_command.send_text"
)}
>
@@ -282,7 +308,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
.path=${mdiMicrophone}
@click=${this._handleListeningButton}
.disabled=${this._processing}
.label=${this.hass.localize(
.label=${this._localize(
"ui.dialogs.voice_command.start_listening"
)}
>
@@ -374,10 +400,12 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
private _handleToggleThinking(ev: Event) {
const index = (ev.currentTarget as any).index;
this._conversation[index] = {
...this._conversation[index],
thinking_expanded: !this._conversation[index].thinking_expanded,
};
// Mutate the message in place rather than replacing it. The streaming
// processor keeps a reference to this same object and mutates it as deltas
// arrive; swapping in a new object would detach the in-flight message from
// the processor and freeze the chat (see #52501).
const message = this._conversation[index];
message.thinking_expanded = !message.thinking_expanded;
this.requestUpdate("_conversation");
}
@@ -391,21 +419,21 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
text:
// New lines matter for messages
// prettier-ignore
html`${this.hass.localize(
html`${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_browser"
)}
${this.hass.localize(
${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation",
{
documentation_link: html`<a
target="_blank"
rel="noopener noreferrer"
href=${documentationUrl(
this.hass,
this._config,
"/docs/configuration/securing/#remote-access"
)}
>${this.hass.localize(
>${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
@@ -443,7 +471,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
try {
const unsub = await runAssistPipeline(
this.hass,
this._connection,
(event: PipelineRunEvent) => {
if (event.type === "run-start") {
this._stt_binary_handler_id =
@@ -539,7 +567,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
}
private _sendAudioChunk(chunk: Int16Array) {
this.hass.connection.socket!.binaryType = "arraybuffer";
this._connection.connection.socket!.binaryType = "arraybuffer";
// eslint-disable-next-line eqeqeq
if (this._stt_binary_handler_id == undefined) {
@@ -550,7 +578,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
data[0] = this._stt_binary_handler_id;
data.set(new Uint8Array(chunk.buffer), 1);
this.hass.connection.socket!.send(data);
this._connection.connection.socket!.send(data);
}
private _unloadAudio = () => {
@@ -570,7 +598,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
hassMessageProcesser.addMessage();
try {
const unsub = await runAssistPipeline(
this.hass,
this._connection,
(event) => {
if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
@@ -593,7 +621,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
);
} catch {
hassMessageProcesser.setError(
this.hass.localize("ui.dialogs.voice_command.error")
this._localize("ui.dialogs.voice_command.error")
);
} finally {
this._processing = false;
+67 -19
View File
@@ -1,16 +1,21 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import {
configContext,
connectionContext,
entitiesContext,
} from "../data/context";
import { attributeIcon } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-attribute-icon")
export class HaAttributeIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property() public attribute?: string;
@@ -19,6 +24,59 @@ export class HaAttributeIcon extends LitElement {
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([
icon,
config,
connection,
entities,
stateObj,
attribute,
attributeValue,
]) => {
if (
icon ||
!config ||
!connection ||
!entities ||
!stateObj ||
!attribute
) {
return initialState;
}
return attributeIcon(
config.config,
connection.connection,
entities,
stateObj,
attribute,
attributeValue
);
},
args: () =>
[
this.icon,
this._config,
this._connection,
this._entities,
this.stateObj,
this.attribute,
this.attributeValue,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -28,23 +86,13 @@ export class HaAttributeIcon extends LitElement {
return nothing;
}
if (!this.hass) {
if (!this._config || !this._connection || !this._entities) {
return nothing;
}
const icon = attributeIcon(
this.hass,
this.stateObj,
this.attribute,
this.attributeValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return nothing;
});
return html`${until(icon)}`;
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: nothing;
}
}
+43 -12
View File
@@ -1,11 +1,19 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { getValueAttribute } from "../common/entity/get_states";
import { valueFromParts } from "../common/entity/value_parts";
import { formattersContext } from "../data/context";
const isObjectValue = (value: unknown): boolean =>
(Array.isArray(value) && value.some((val) => val instanceof Object)) ||
(!Array.isArray(value) && value instanceof Object);
@customElement("ha-attribute-value")
class HaAttributeValue extends LitElement {
@state()
@@ -18,6 +26,17 @@ class HaAttributeValue extends LitElement {
@property({ type: Boolean, attribute: "hide-unit" }) public hideUnit = false;
private _yamlTask = new AsyncValueTask(this, {
task: async ([attributeValue]) => {
if (!isObjectValue(attributeValue)) {
return initialState;
}
const { dump } = await import("js-yaml");
return dump(attributeValue);
},
args: () => [this.stateObj?.attributes[this.attribute]] as const,
});
protected render() {
if (!this.stateObj) {
return nothing;
@@ -47,13 +66,28 @@ class HaAttributeValue extends LitElement {
}
}
if (
(Array.isArray(attributeValue) &&
attributeValue.some((val) => val instanceof Object)) ||
(!Array.isArray(attributeValue) && attributeValue instanceof Object)
) {
const yaml = import("js-yaml").then(({ dump }) => dump(attributeValue));
return html`<pre>${until(yaml, "")}</pre>`;
if (isObjectValue(attributeValue)) {
return html`<pre>${this._yamlTask.value ?? ""}</pre>`;
}
// Options-list attributes (effect_list, preset_modes, …) translated through
// their value attribute, or the main state for lists like hvac_modes.
if (Array.isArray(attributeValue)) {
const domain = computeStateDomain(this.stateObj);
const valueAttribute = getValueAttribute(domain, this.attribute);
if (valueAttribute) {
return attributeValue
.map((item) =>
valueAttribute === "_"
? this._formatters!.formatEntityState(this.stateObj!, item)
: this._formatters!.formatEntityAttributeValue(
this.stateObj!,
valueAttribute,
item
)
)
.join(", ");
}
}
if (this.hideUnit) {
@@ -61,10 +95,7 @@ class HaAttributeValue extends LitElement {
this.stateObj!,
this.attribute
);
return parts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("");
return valueFromParts(parts);
}
return this._formatters!.formatEntityAttributeValue(
+8 -2
View File
@@ -153,10 +153,16 @@ export class HaBaseTimeInput extends LitElement {
protected render(): TemplateResult {
return html`
${this.label
? html`<label>${this.label}${this.required ? " *" : ""}</label>`
? html`<label id="label"
>${this.label}${this.required ? " *" : ""}</label
>`
: nothing}
<div class="time-input-wrap-wrap">
<div class="time-input-wrap">
<div
class="time-input-wrap"
role="group"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${this.enableDay
? html`
<ha-input
+11 -12
View File
@@ -1,10 +1,12 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import type { HomeAssistant } from "../types";
import { internationalizationContext } from "../data/context";
@customElement("ha-big-number")
export class HaBigNumber extends LitElement {
@@ -15,17 +17,16 @@ export class HaBigNumber extends LitElement {
@property({ attribute: "unit-position" })
public unitPosition: "top" | "bottom" = "top";
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false })
public formatOptions: Intl.NumberFormatOptions = {};
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
protected render() {
const formatted = formatNumber(
this.value,
this.hass?.locale,
this.formatOptions
);
const locale = this._i18n!.locale;
const formatted = formatNumber(this.value, locale, this.formatOptions);
const [integer] = formatted.includes(".")
? formatted.split(".")
: formatted.split(",");
@@ -33,9 +34,7 @@ export class HaBigNumber extends LitElement {
const temperatureDecimal = formatted.replace(integer, "");
const formattedValue = `${this.value}${
this.unit
? `${blankBeforeUnit(this.unit, this.hass?.locale)}${this.unit}`
: ""
this.unit ? `${blankBeforeUnit(this.unit, locale)}${this.unit}` : ""
}`;
const unitBottom = this.unitPosition === "bottom";
+36 -15
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
@@ -7,7 +8,7 @@ import memoizeOne from "memoize-one";
import { computeStateName } from "../common/entity/compute_state_name";
import { supportsFeature } from "../common/entity/supports-feature";
import {
CAMERA_SUPPORT_STREAM,
CameraEntityFeature,
type CameraCapabilities,
type CameraEntity,
computeMJPEGStreamUrl,
@@ -17,7 +18,7 @@ import {
STREAM_TYPE_WEB_RTC,
type StreamType,
} from "../data/camera";
import type { HomeAssistant } from "../types";
import { apiContext, configContext, connectionContext } from "../data/context";
import "./ha-hls-player";
import "./ha-web-rtc-player";
@@ -30,7 +31,17 @@ interface Stream {
@customElement("ha-camera-stream")
export class HaCameraStream extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property({ attribute: false }) public stateObj?: CameraEntity;
@@ -58,21 +69,33 @@ export class HaCameraStream extends LitElement {
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
public willUpdate(changedProps: PropertyValues<this>): void {
private _thumbnailApi = memoizeOne(
(
api: ContextType<typeof apiContext>,
connection: ContextType<typeof connectionContext>
) => ({
callWS: api.callWS,
hassUrl: connection.hassUrl,
})
);
public willUpdate(changedProps: PropertyValues): void {
const entityChanged =
changedProps.has("stateObj") &&
this.stateObj &&
(changedProps.get("stateObj") as CameraEntity | undefined)?.entity_id !==
this.stateObj.entity_id;
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const oldConfig = changedProps.get("_config") as
| ContextType<typeof configContext>
| undefined;
const backendStarted =
changedProps.has("hass") &&
this.hass &&
changedProps.has("_config") &&
this._config &&
this.stateObj &&
oldHass &&
this.hass.config.state === STATE_RUNNING &&
oldHass.config?.state !== STATE_RUNNING;
oldConfig &&
this._config.config.state === STATE_RUNNING &&
oldConfig.config?.state !== STATE_RUNNING;
if (entityChanged || backendStarted) {
this._getCapabilities();
@@ -137,7 +160,6 @@ export class HaCameraStream extends LitElement {
.allowExoPlayer=${this.allowExoPlayer}
.muted=${this.muted}
.controls=${this.controls}
.hass=${this.hass}
.entityid=${this.stateObj.entity_id}
.posterUrl=${this._posterUrl}
@streams=${this._handleHlsStreams}
@@ -153,7 +175,6 @@ export class HaCameraStream extends LitElement {
playsinline
.muted=${this.muted}
.controls=${this.controls}
.hass=${this.hass}
.entityid=${this.stateObj.entity_id}
.posterUrl=${this._posterUrl}
@streams=${this._handleWebRtcStreams}
@@ -170,12 +191,12 @@ export class HaCameraStream extends LitElement {
this._capabilities = undefined;
this._hlsStreams = undefined;
this._webRtcStreams = undefined;
if (!supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM)) {
if (!supportsFeature(this.stateObj!, CameraEntityFeature.STREAM)) {
this._capabilities = { frontend_stream_types: [] };
return;
}
this._capabilities = await fetchCameraCapabilities(
this.hass!,
this._api,
this.stateObj!.entity_id
);
}
@@ -183,7 +204,7 @@ export class HaCameraStream extends LitElement {
private async _getPosterUrl(): Promise<void> {
try {
this._posterUrl = await fetchThumbnailUrlWithCache(
this.hass!,
this._thumbnailApi(this._api, this._connection),
this.stateObj!.entity_id,
this.clientWidth,
this.clientHeight
+19 -13
View File
@@ -12,10 +12,11 @@ import {
mdiWeatherSunny,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassConfig, Connection } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -57,6 +58,17 @@ export class HaConditionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, condition]) => {
if (icon || !connection || !config || !condition) {
return initialState;
}
return conditionIcon(connection, config, condition);
},
args: () =>
[this.icon, this._connection, this._config, this.condition] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -70,18 +82,12 @@ export class HaConditionIcon extends LitElement {
return this._renderFallback();
}
const icon = conditionIcon(
this._connection,
this._config,
this.condition
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+4 -1
View File
@@ -388,7 +388,10 @@ export class HaControlSlider extends LitElement {
private _isVisuallyInverted() {
let inverted = this.inverted;
if (mainWindow.document.dir === "rtl") {
// RTL only mirrors the horizontal axis. A vertical slider always fills
// bottom-to-top regardless of text direction, so it must not be flipped,
// otherwise its value mapping ends up upside down in RTL languages.
if (!this.vertical && mainWindow.document.dir === "rtl") {
inverted = !inverted;
}
+32 -16
View File
@@ -1,7 +1,8 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { configContext, connectionContext, uiContext } from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
@@ -36,6 +37,30 @@ export class HaDomainIcon extends LitElement {
@consume({ context: uiContext, subscribe: true })
private _hassUi?: ContextType<typeof uiContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, domain, deviceClass, domainState]) => {
if (icon || !connection || !config || !domain) {
return initialState;
}
return domainIcon(
connection.connection,
config.config,
domain,
deviceClass,
domainState
);
},
args: () =>
[
this.icon,
this._connection,
this._hassConfig,
this.domain,
this.deviceClass,
this.state,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -49,21 +74,12 @@ export class HaDomainIcon extends LitElement {
return this._renderFallback();
}
const icon = domainIcon(
this._connection.connection,
this._hassConfig.config,
this.domain,
this.deviceClass,
this.state
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+57 -17
View File
@@ -1,10 +1,18 @@
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeEntityStates } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateName } from "../common/entity/compute_state_name";
import {
configContext,
connectionContext,
entitiesContext,
} from "../data/context";
import { entityIcon } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
@@ -15,7 +23,21 @@ export interface EntitiesDisplayValue {
@customElement("ha-entities-display-editor")
export class HaEntitiesDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeEntityStates({ entityIdPath: ["entitiesIds"] })
private _entityStates?: Record<string, HassEntity>;
@consume({ context: entitiesContext, subscribe: true })
@state()
private _entitiesReg!: ContextType<typeof entitiesContext>;
@consume({ context: configContext, subscribe: true })
@state()
private _config!: ContextType<typeof configContext>;
@consume({ context: connectionContext, subscribe: true })
@state()
private _connection!: ContextType<typeof connectionContext>;
@property() public label?: string;
@@ -32,20 +54,13 @@ export class HaEntitiesDisplayEditor extends LitElement {
@property({ type: Boolean }) public required = false;
protected render(): TemplateResult {
const entities = this.entitiesIds
.map((entityId) => this.hass.states[entityId])
.filter(Boolean);
const items: DisplayItem[] = entities.map((entity) => ({
value: entity.entity_id,
label: computeStateName(entity),
icon: entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
entity
),
}));
const items = this._items(
this.entitiesIds,
this._entityStates,
this._entitiesReg,
this._config,
this._connection
);
const value: DisplayValue = {
order: this.value?.order ?? [],
@@ -61,6 +76,31 @@ export class HaEntitiesDisplayEditor extends LitElement {
`;
}
private _items = memoizeOne(
(
entitiesIds: string[],
entityStates: Record<string, HassEntity> | undefined,
entitiesReg: ContextType<typeof entitiesContext>,
config: ContextType<typeof configContext>,
connection: ContextType<typeof connectionContext>
): DisplayItem[] => {
const entities = entitiesIds
.map((entityId) => entityStates?.[entityId])
.filter((stateObj): stateObj is HassEntity => Boolean(stateObj));
return entities.map((entity) => ({
value: entity.entity_id,
label: computeStateName(entity),
icon: entityIcon(
entitiesReg,
config.config,
connection.connection,
entity
),
}));
}
);
private _itemDisplayChanged(ev) {
ev.stopPropagation();
const value = ev.detail.value as DisplayValue;
+18 -6
View File
@@ -1,13 +1,18 @@
import { consume } from "@lit/context";
import { mdiDelete, mdiFileUpload } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { fireEvent } from "../common/dom/fire_event";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { internationalizationContext } from "../data/context";
import type { FrontendLocaleData } from "../data/translation";
import type { HomeAssistantInternationalization } from "../types";
import { bytesToString } from "../util/bytes-to-string";
import "./ha-button";
import "./ha-icon-button";
@@ -22,10 +27,17 @@ declare global {
@customElement("ha-file-upload")
export class HaFileUpload extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() @consumeLocalize() private _localize?: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@property() public accept!: string;
@property() public icon?: string;
@@ -80,7 +92,7 @@ export class HaFileUpload extends LitElement {
}
public render(): TemplateResult {
const localize = this.localize || this.hass!.localize;
const localize = this.localize || this._localize!;
return html`
${this.uploading
? html`<div class="container">
@@ -95,8 +107,8 @@ export class HaFileUpload extends LitElement {
>
${this.progress
? html`<div class="progress">
${this.progress}${this.hass &&
blankBeforePercent(this.hass!.locale)}%
${this.progress}${this._locale &&
blankBeforePercent(this._locale)}%
</div>`
: nothing}
</div>
+57 -18
View File
@@ -1,15 +1,23 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { deepEqual } from "../common/util/deep-equal";
import {
apiContext,
devicesContext,
internationalizationContext,
statesContext,
} from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@@ -24,7 +32,24 @@ interface HaFilterDevicesItem extends HaListVirtualizedItem {
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: devicesContext, subscribe: true })
@state()
private _devicesReg!: ContextType<typeof devicesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public value?: string[];
@@ -75,7 +100,7 @@ export class HaFilterDevices extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.devices.caption")}
${this._localize("ui.panel.config.devices.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -95,7 +120,13 @@ export class HaFilterDevices extends LitElement {
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(this.hass.devices, this._filter || "")}
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
@@ -121,13 +152,24 @@ export class HaFilterDevices extends LitElement {
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
...(this.value ?? []),
this._devices(this.hass.devices, this._filter || "")[ev.detail].id,
this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)[ev.detail].id,
];
}
private _handleRemoved(ev: CustomEvent<number>) {
const id = this._devices(this.hass.devices, this._filter || "")[ev.detail]
.id;
const id = this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)[ev.detail].id;
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
@@ -153,27 +195,24 @@ export class HaFilterDevices extends LitElement {
private _devices = memoizeOne(
(
devices: HomeAssistant["devices"],
filter: string
devices: ContextType<typeof devicesContext>,
filter: string,
localize: LocalizeFunc,
states: ContextType<typeof statesContext>,
language: string | undefined
): HaFilterDevicesItem[] => {
const values = Object.values(devices);
return values
.map((device) => ({
id: device.id,
interactive: true,
name: computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
),
name: computeDeviceNameDisplay(device, localize, states),
}))
.filter(
({ name }) =>
!filter || name.toLowerCase().includes(filter.toLowerCase())
)
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
.sort((a, b) => stringCompare(a.name, b.name, language));
}
);
@@ -194,7 +233,7 @@ export class HaFilterDevices extends LitElement {
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this.hass, "device", deviceId));
relatedPromises.push(findRelated(this._api, "device", deviceId));
}
}
const results = await Promise.all(relatedPromises);
+58 -25
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -5,12 +6,14 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { domainToName } from "../data/integration";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
@@ -20,7 +23,17 @@ import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-domains")
export class HaFilterDomains extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@@ -43,7 +56,7 @@ export class HaFilterDomains extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.domains.caption")}
${this._localize("ui.panel.config.domains.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -65,7 +78,13 @@ export class HaFilterDomains extends LitElement {
multi
>
${repeat(
this._domains(this.hass.states, this._filter, this.value),
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
@@ -78,7 +97,7 @@ export class HaFilterDomains extends LitElement {
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this.hass.localize, domain)}
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list> `
@@ -87,26 +106,34 @@ export class HaFilterDomains extends LitElement {
`;
}
private _domains = memoizeOne((states, filter, _value) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
});
private _domains = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
_value
) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
});
return Array.from(domains.values())
.map((domain) => ({
domain,
name: domainToName(this.hass.localize, domain),
}))
.filter(
(entry) =>
!filter ||
entry.domain.toLowerCase().includes(filter) ||
entry.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, this.hass.locale.language))
.map((entry) => entry.domain);
});
return Array.from(domains.values())
.map((domain) => ({
domain,
name: domainToName(localize, domain),
}))
.filter(
(entry) =>
!filter ||
entry.domain.toLowerCase().includes(filter) ||
entry.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
.map((entry) => entry.domain);
}
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
@@ -129,7 +156,13 @@ export class HaFilterDomains extends LitElement {
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const domains = this._domains(this.hass.states, this._filter, this.value);
const domains = this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
);
const visibleDomains = new Set(domains);
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
+29 -11
View File
@@ -1,18 +1,25 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { deepEqual } from "../common/util/deep-equal";
import {
apiContext,
internationalizationContext,
statesContext,
} from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
@@ -22,7 +29,20 @@ import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-entities")
export class HaFilterEntities extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public value?: string[];
@@ -62,7 +82,7 @@ export class HaFilterEntities extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.entities.caption")}
${this._localize("ui.panel.config.entities.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -82,9 +102,10 @@ export class HaFilterEntities extends LitElement {
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._entities(
this.hass.states,
this._states,
this.type,
this._filter || "",
this._i18n.locale.language,
this.value
)}
.keyFunction=${this._keyFunction}
@@ -163,9 +184,10 @@ export class HaFilterEntities extends LitElement {
private _entities = memoizeOne(
(
states: HomeAssistant["states"],
states: ContextType<typeof statesContext>,
type: this["type"],
filter: string,
language: string | undefined,
_value
) => {
const values = Object.values(states);
@@ -180,11 +202,7 @@ export class HaFilterEntities extends LitElement {
.includes(filter))
)
.sort((a, b) =>
stringCompare(
computeStateName(a),
computeStateName(b),
this.hass.locale.language
)
stringCompare(computeStateName(a), computeStateName(b), language)
);
}
);
@@ -203,7 +221,7 @@ export class HaFilterEntities extends LitElement {
for (const entityId of this.value) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "entity", entityId));
relatedPromises.push(findRelated(this._api, "entity", entityId));
}
}
+38 -12
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove, mdiTextureBox } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -5,14 +6,21 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
import { deepEqual } from "../common/util/deep-equal";
import type { LocalizeFunc } from "../common/translations/localize";
import {
apiContext,
areasContext,
floorsContext,
internationalizationContext,
} from "../data/context";
import { getFloorAreaLookup } from "../data/floor_registry";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-icon";
@@ -26,7 +34,24 @@ import type { HaListSelectable } from "./list/ha-list-selectable";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: areasContext, subscribe: true })
@state()
private _areasReg!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floorsReg!: ContextType<typeof floorsContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public value?: {
floors?: string[];
@@ -55,7 +80,7 @@ export class HaFilterFloorAreas extends LitElement {
}
protected render() {
const areas = this._areas(this.hass.areas, this.hass.floors);
const areas = this._areas(this._areasReg, this._floorsReg);
return html`
<ha-expansion-panel
@@ -65,7 +90,7 @@ export class HaFilterFloorAreas extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.areas.caption")}
${this._localize("ui.panel.config.areas.caption")}
${this.value?.areas?.length || this.value?.floors?.length
? html`<div class="badge">
${(this.value?.areas?.length || 0) +
@@ -85,9 +110,7 @@ export class HaFilterFloorAreas extends LitElement {
multi
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
aria-label=${this._localize("ui.panel.config.areas.caption")}
>
${repeat(
areas?.floors || [],
@@ -141,8 +164,8 @@ export class HaFilterFloorAreas extends LitElement {
.type=${"areas"}
class=${classMap({
rtl: computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
),
floor: hasFloor,
})}
@@ -225,7 +248,10 @@ export class HaFilterFloorAreas extends LitElement {
}
private _areas = memoizeOne(
(areaReg: HomeAssistant["areas"], floorReg: HomeAssistant["floors"]) => {
(
areaReg: ContextType<typeof areasContext>,
floorReg: ContextType<typeof floorsContext>
) => {
const areas = Object.values(areaReg);
const floors = Object.values(floorReg);
const floorAreaLookup = getFloorAreaLookup(areas);
@@ -261,7 +287,7 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "area", areaId));
relatedPromises.push(findRelated(this._api, "area", areaId));
}
}
}
@@ -269,7 +295,7 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "floor", floorId));
relatedPromises.push(findRelated(this._api, "floor", floorId));
}
}
}
+23 -13
View File
@@ -1,4 +1,4 @@
import { consume } from "@lit/context";
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -6,13 +6,14 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
import { stringCompare } from "../common/string/compare";
import { labelsContext } from "../data/context";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
@@ -25,14 +26,20 @@ import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-labels")
export class HaFilterLabels extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: labelsContext, subscribe: true })
@state()
private _labels?: LabelRegistryEntry[];
@@ -45,7 +52,12 @@ export class HaFilterLabels extends LitElement {
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
(labels: LabelRegistryEntry[], filter: string | undefined, _value) =>
(
labels: LabelRegistryEntry[],
filter: string | undefined,
language: string | undefined,
_value
) =>
labels
.filter(
(label) =>
@@ -54,11 +66,7 @@ export class HaFilterLabels extends LitElement {
label.label_id.toLowerCase().includes(filter)
)
.sort((a, b) =>
stringCompare(
a.name || a.label_id,
b.name || b.label_id,
this.hass.locale.language
)
stringCompare(a.name || a.label_id, b.name || b.label_id, language)
)
);
@@ -71,7 +79,7 @@ export class HaFilterLabels extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.labels.caption")}
${this._localize("ui.panel.config.labels.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -96,6 +104,7 @@ export class HaFilterLabels extends LitElement {
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
@@ -129,7 +138,7 @@ export class HaFilterLabels extends LitElement {
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this.hass.localize("ui.panel.config.labels.manage_labels")}
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>`
: nothing}
`;
@@ -169,6 +178,7 @@ export class HaFilterLabels extends LitElement {
const filteredLabels = this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
);
-3
View File
@@ -5,7 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
@@ -14,8 +13,6 @@ import "./ha-list";
@customElement("ha-filter-states")
export class HaFilterStates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@@ -8,7 +8,6 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
@@ -22,8 +21,6 @@ import "../panels/config/voice-assistants/expose/expose-assistant-icon";
@customElement("ha-filter-voice-assistants")
export class HaFilterVoiceAssistants extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@@ -78,7 +75,6 @@ export class HaFilterVoiceAssistants extends LitElement {
<voice-assistant-brand-icon
slot="graphic"
.voiceAssistantId=${voiceAssistantId}
.hass=${this.hass}
>
</voice-assistant-brand-icon>
${voiceAssistants[voiceAssistantId].name}
+30 -10
View File
@@ -1,13 +1,16 @@
import { consume, type ContextType } from "@lit/context";
import type HlsType from "hls.js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { nextRender } from "../common/util/render-status";
import { fetchStreamUrl } from "../data/camera";
import type { HomeAssistant } from "../types";
import { apiContext, configContext, connectionContext } from "../data/context";
import "./ha-alert";
type HlsLite = Omit<
@@ -17,7 +20,21 @@ type HlsLite = Omit<
@customElement("ha-hls-player")
class HaHLSPlayer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property() public entityid?: string;
@@ -140,7 +157,7 @@ class HaHLSPlayer extends LitElement {
this._cleanUp();
this._resetError();
if (!isComponentLoaded(this.hass.config, "stream")) {
if (!isComponentLoaded(this._config.config, "stream")) {
this._setFatalError("Streaming component is not loaded.");
return;
}
@@ -149,9 +166,12 @@ class HaHLSPlayer extends LitElement {
return;
}
try {
const { url } = await fetchStreamUrl(this.hass!, this.entityid);
const { url } = await fetchStreamUrl(
{ callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
this.entityid
);
this._url = this.hass.hassUrl(url);
this._url = this._connection.hassUrl(url);
this._cleanUp();
this._resetError();
this._startHls();
@@ -184,13 +204,13 @@ class HaHLSPlayer extends LitElement {
if (!hlsSupported) {
this._setFatalError(
this.hass.localize("ui.components.media-browser.video_not_supported")
this._localize("ui.components.media-browser.video_not_supported")
);
return;
}
const useExoPlayer =
this.allowExoPlayer && this.hass.auth.external?.config.hasExoPlayer;
this.allowExoPlayer && this._config.auth.external?.config.hasExoPlayer;
const masterPlaylist = await (await masterPlaylistPromise).text();
if (!this.isConnected) {
@@ -236,7 +256,7 @@ class HaHLSPlayer extends LitElement {
window.addEventListener("resize", this._resizeExoPlayer);
this.updateComplete.then(() => nextRender()).then(this._resizeExoPlayer);
this._videoEl.style.visibility = "hidden";
await this.hass!.auth.external!.fireMessage({
await this._config.auth.external!.fireMessage({
type: "exoplayer/play_hls",
payload: {
url,
@@ -250,7 +270,7 @@ class HaHLSPlayer extends LitElement {
return;
}
const rect = this._videoEl.getBoundingClientRect();
this.hass!.auth.external!.fireMessage({
this._config.auth.external!.fireMessage({
type: "exoplayer/resize",
payload: {
left: rect.left,
@@ -362,7 +382,7 @@ class HaHLSPlayer extends LitElement {
}
if (this._exoPlayer) {
window.removeEventListener("resize", this._resizeExoPlayer);
this.hass!.auth.external!.fireMessage({ type: "exoplayer/stop" });
this._config.auth.external!.fireMessage({ type: "exoplayer/stop" });
this._exoPlayer = false;
}
if (this._videoEl) {
+17 -8
View File
@@ -1,10 +1,13 @@
import { consume, type ContextType } from "@lit/context";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import { apiContext, formattersContext } from "../data/context";
import "./ha-button";
import type { LawnMowerEntity, LawnMowerEntityState } from "../data/lawn_mower";
import { LawnMowerEntityFeature } from "../data/lawn_mower";
import type { HomeAssistant } from "../types";
interface LawnMowerAction {
action: string;
@@ -39,13 +42,19 @@ const LAWN_MOWER_ACTIONS: Partial<
@customElement("ha-lawn_mower-action-button")
class HaLawnMowerActionButton extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public stateObj!: LawnMowerEntity;
public render() {
const state = this.stateObj.state;
const action = LAWN_MOWER_ACTIONS[state];
const action = LAWN_MOWER_ACTIONS[this.stateObj.state];
if (action && supportsFeature(this.stateObj, action.feature)) {
return html`
@@ -55,14 +64,14 @@ class HaLawnMowerActionButton extends LitElement {
.service=${action.service}
size="s"
>
${this.hass.localize(`ui.card.lawn_mower.actions.${action.action}`)}
${this._localize(`ui.card.lawn_mower.actions.${action.action}`)}
</ha-button>
`;
}
return html`
<ha-button appearance="plain" disabled>
${this.hass.formatEntityState(this.stateObj)}
${this._formatters?.formatEntityState(this.stateObj)}
</ha-button>
`;
}
@@ -71,7 +80,7 @@ class HaLawnMowerActionButton extends LitElement {
ev.stopPropagation();
const stateObj = this.stateObj;
const service = ev.target.service;
this.hass.callService("lawn_mower", service, {
this._api.callService("lawn_mower", service, {
entity_id: stateObj.entity_id,
});
}
-1
View File
@@ -243,7 +243,6 @@ export class HaNavigationPicker extends LitElement {
items = multiTermSortedSearch(
items,
searchString,
DEFAULT_SEARCH_KEYS,
(item) => item.id,
fuseIndex
);
-1
View File
@@ -492,7 +492,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems,
searchString,
this.searchKeys || DEFAULT_SEARCH_KEYS,
(item) => item.id,
index
);
-1
View File
@@ -78,7 +78,6 @@ export class HaPictureUpload extends LitElement {
return html`
<ha-file-upload
.hass=${this.hass}
.icon=${mdiImagePlus}
.label=${this.label ||
this.hass.localize("ui.components.picture-upload.label")}
+27 -25
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiCamera } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -8,9 +9,11 @@ import { customElement, property, query, state } from "lit/decorators";
// WebAssembly port of ZXing:
import { prepareZXingModule } from "barcode-detector";
import type QrScanner from "qr-scanner";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { configContext } from "../data/context";
import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-button";
import "./ha-dropdown";
@@ -33,7 +36,13 @@ prepareZXingModule({
@customElement("ha-qr-scanner")
class HaQrScanner extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@property() public description?: string;
@@ -106,7 +115,7 @@ class HaQrScanner extends LitElement {
${this._error || this._warning}
${this._error
? html`<ha-button @click=${this._retry} slot="action">
${this.hass.localize("ui.components.qr-scanner.retry")}
${this._localize("ui.components.qr-scanner.retry")}
</ha-button>`
: nothing}
</ha-alert>`
@@ -126,7 +135,7 @@ class HaQrScanner extends LitElement {
? html`<ha-dropdown @wa-select=${this._handleDropdownSelect}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize(
.label=${this._localize(
"ui.components.qr-scanner.select_camera"
)}
.path=${mdiCamera}
@@ -146,28 +155,24 @@ class HaQrScanner extends LitElement {
</div>`
: html`<ha-alert alert-type="warning">
${!window.isSecureContext
? this.hass.localize(
"ui.components.qr-scanner.only_https_supported"
)
: this.hass.localize("ui.components.qr-scanner.not_supported")}
? this._localize("ui.components.qr-scanner.only_https_supported")
: this._localize("ui.components.qr-scanner.not_supported")}
</ha-alert>
<p>${this.hass.localize("ui.components.qr-scanner.manual_input")}</p>
<p>${this._localize("ui.components.qr-scanner.manual_input")}</p>
<div class="row">
<ha-input
.label=${this.hass.localize(
"ui.components.qr-scanner.enter_qr_code"
)}
.label=${this._localize("ui.components.qr-scanner.enter_qr_code")}
@keyup=${this._manualKeyup}
@paste=${this._manualPaste}
></ha-input>
<ha-button @click=${this._manualSubmit}>
${this.hass.localize("ui.common.submit")}
${this._localize("ui.common.submit")}
</ha-button>
</div>`}`;
}
private get _nativeBarcodeScanner(): boolean {
return Boolean(this.hass.auth.external?.config.hasBarCodeScanner);
return Boolean(this._config.auth.external?.config.hasBarCodeScanner);
}
private async _loadQrScanner() {
@@ -182,7 +187,7 @@ class HaQrScanner extends LitElement {
const QrScanner = (await import("qr-scanner")).default;
if (!(await QrScanner.hasCamera())) {
this._reportError(
this.hass.localize("ui.components.qr-scanner.no_camera_found")
this._localize("ui.components.qr-scanner.no_camera_found")
);
return;
}
@@ -270,7 +275,7 @@ class HaQrScanner extends LitElement {
if (msg.command === "bar_code/scan_result") {
if (msg.payload.format !== "qr_code") {
this._notifyExternalScanner(
this.hass.localize("ui.components.qr-scanner.wrong_code", {
this._localize("ui.components.qr-scanner.wrong_code", {
format: msg.payload.format,
rawValue: msg.payload.rawValue,
})
@@ -288,20 +293,17 @@ class HaQrScanner extends LitElement {
}
return true;
});
this.hass.auth.external!.fireMessage({
this._config.auth.external!.fireMessage({
type: "bar_code/scan",
payload: {
title:
this.title ||
this.hass.localize("ui.components.qr-scanner.app.title"),
this.title || this._localize("ui.components.qr-scanner.app.title"),
description:
this.description ||
this.hass.localize("ui.components.qr-scanner.app.description"),
this._localize("ui.components.qr-scanner.app.description"),
alternative_option_label:
this.alternativeOptionLabel ||
this.hass.localize(
"ui.components.qr-scanner.app.alternativeOptionLabel"
),
this._localize("ui.components.qr-scanner.app.alternativeOptionLabel"),
},
});
}
@@ -309,7 +311,7 @@ class HaQrScanner extends LitElement {
private _closeExternalScanner() {
this._removeListener?.();
this._removeListener = undefined;
this.hass.auth.external!.fireMessage({
this._config.auth.external!.fireMessage({
type: "bar_code/close",
});
}
@@ -318,7 +320,7 @@ class HaQrScanner extends LitElement {
if (!this._nativeBarcodeScanner) {
return;
}
this.hass.auth.external!.fireMessage({
this._config.auth.external!.fireMessage({
type: "bar_code/notify",
payload: {
message,
+20 -8
View File
@@ -12,6 +12,8 @@ import type { HomeAssistantInternationalization } from "../types";
class HaRelativeTime extends ReactiveElement {
@property({ attribute: false }) public datetime?: string | Date;
@property() public format: Intl.RelativeTimeFormatStyle = "long";
@property({ type: Boolean }) public capitalize = false;
@state()
@@ -36,13 +38,15 @@ class HaRelativeTime extends ReactiveElement {
return this;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._updateRelative();
}
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
if (changedProps.has("datetime")) {
if (this.datetime) {
this._startInterval();
} else {
this._clearInterval();
}
}
this._updateRelative();
}
@@ -66,15 +70,23 @@ class HaRelativeTime extends ReactiveElement {
}
if (!this.datetime) {
this.innerHTML = this._i18n.localize("ui.components.relative_time.never");
this.textContent = this._i18n.localize(
"ui.components.relative_time.never"
);
} else {
const date =
typeof this.datetime === "string"
? parseISO(this.datetime)
: this.datetime;
const relTime = relativeTime(date, this._i18n.locale);
this.innerHTML = this.capitalize
const relTime = relativeTime(
date,
this._i18n.locale,
undefined,
true,
this.format
);
this.textContent = this.capitalize
? capitalizeFirstLetter(relTime)
: relTime;
}
@@ -23,7 +23,6 @@ export class HaAreasDisplaySelector extends LitElement {
protected render() {
return html`
<ha-areas-display-editor
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -4,7 +4,6 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { ColorTempSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-labeled-slider";
import { generateColorTemperatureGradient } from "../../dialogs/more-info/components/lights/light-color-temp-picker";
import {
@@ -15,8 +14,6 @@ import {
@customElement("ha-selector-color_temp")
export class HaColorTempSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ColorTempSelector;
@property() public value?: string;
@@ -86,7 +86,10 @@ export class HaDateTimeSelector extends LitElement {
static styles = css`
.input {
display: flex;
align-items: center;
/* Align the input fields by their top edge so the date field's underline
lines up with the time field, since ha-date-input reserves extra space
below for its hint while ha-time-input does not. */
align-items: flex-start;
flex-direction: row;
}
@@ -37,7 +37,6 @@ export class HaFileSelector extends LitElement {
protected render() {
return html`
<ha-file-upload
.hass=${this.hass}
.accept=${this.selector.file?.accept}
.icon=${mdiFile}
.label=${this.label}
+36 -12
View File
@@ -1,6 +1,8 @@
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassEntity } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../../common/controllers/async-value-task";
import { fireEvent } from "../../common/dom/fire_event";
import { entityIcon } from "../../data/icons";
import type { IconSelector } from "../../data/selector";
@@ -28,23 +30,45 @@ export class HaIconSelector extends LitElement {
icon_entity?: string;
};
protected render() {
private get _stateObj(): HassEntity | undefined {
const iconEntity = this.context?.icon_entity;
return iconEntity ? this.hass.states[iconEntity] : undefined;
}
const stateObj = iconEntity ? this.hass.states[iconEntity] : undefined;
private _placeholderTask = new AsyncValueTask(this, {
task: ([
placeholder,
attributeIcon,
entities,
config,
connection,
stateObj,
]) => {
if (placeholder || attributeIcon || !stateObj) {
return initialState;
}
return entityIcon(entities, config, connection, stateObj);
},
args: () => {
const stateObj = this._stateObj;
return [
this.selector.icon?.placeholder,
stateObj?.attributes.icon,
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj,
] as const;
},
});
protected render() {
const stateObj = this._stateObj;
const placeholder =
this.selector.icon?.placeholder ||
stateObj?.attributes.icon ||
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
));
(stateObj && this._placeholderTask.value);
return html`
<ha-icon-picker
@@ -84,7 +84,6 @@ export class HaLocationSelector extends LitElement {
<p>${this.label ? this.label : ""}</p>
<ha-locations-editor
class="flex"
.hass=${this.hass}
.helper=${this.helper}
.locations=${this._location(this.selector, this.value)}
@location-updated=${this._locationChanged}
@@ -1,5 +1,6 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiClose, mdiConnection, mdiMemory, mdiPencil, mdiUsb } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
@@ -405,11 +406,12 @@ export class HaSerialPortSelector extends LitElement {
}
let groupItems: SerialPickerItem[] = grouped[type];
if (searchString) {
const fuseIndex = Fuse.createIndex(DEFAULT_SEARCH_KEYS, groupItems);
groupItems = multiTermSortedSearch(
groupItems,
searchString,
DEFAULT_SEARCH_KEYS,
(item) => item.id
(item) => item.id,
fuseIndex
);
}
if (!groupItems.length) {
@@ -3,15 +3,13 @@ import { customElement, property, query } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { StringSelector } from "../../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent } from "../../types";
import "../ha-textarea";
import "../input/ha-input";
import "../input/ha-input-multi";
@customElement("ha-selector-text")
export class HaTextSelector extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: any;
@property() public name?: string;
@@ -23,7 +23,6 @@ export class HaThemeSelector extends LitElement {
protected render() {
return html`
<ha-theme-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -0,0 +1,32 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-time-format-picker";
@customElement("ha-selector-ui_time_format")
export class HaSelectorUiTimeFormat extends LitElement {
@property() public value?: string;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`
<ha-time-format-picker
.label=${this.label}
.value=${this.value}
.helper=${this.helper}
.disabled=${this.disabled}
>
</ha-time-format-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-ui_time_format": HaSelectorUiTimeFormat;
}
}
@@ -67,6 +67,7 @@ const LOAD_ELEMENTS = {
ui_action: () => import("./ha-selector-ui-action"),
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
};
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
+19 -11
View File
@@ -1,8 +1,9 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -34,6 +35,17 @@ export class HaServiceIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service]) => {
if (icon || !connection || !config || !service) {
return initialState;
}
return serviceIcon(connection, config, service);
},
args: () =>
[this.icon, this._connection, this._config, this.service] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -47,16 +59,12 @@ export class HaServiceIcon extends LitElement {
return this._renderFallback();
}
const icon = serviceIcon(this._connection, this._config, this.service).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+25 -14
View File
@@ -1,8 +1,9 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import { serviceSectionIcon } from "../data/icons";
@@ -31,6 +32,23 @@ export class HaServiceSectionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service, section]) => {
if (icon || !connection || !config || !service || !section) {
return initialState;
}
return serviceSectionIcon(connection, config, service, section);
},
args: () =>
[
this.icon,
this._connection,
this._config,
this.service,
this.section,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -44,19 +62,12 @@ export class HaServiceSectionIcon extends LitElement {
return this._renderFallback();
}
const icon = serviceSectionIcon(
this._connection,
this._config,
this.service,
this.section
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+47 -17
View File
@@ -1,8 +1,9 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
configContext,
@@ -37,11 +38,47 @@ export class HaStateIcon extends LitElement {
@consume({ context: entitiesContext, subscribe: true })
protected _entities?: ContextType<typeof entitiesContext>;
protected render() {
const overrideIcon =
private get _overrideIcon(): string | undefined {
return (
this.icon ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
this.stateObj?.attributes.icon;
this.stateObj?.attributes.icon
);
}
private _iconTask = new AsyncValueTask(this, {
task: ([
overrideIcon,
entities,
config,
connection,
stateObj,
stateValue,
]) => {
if (overrideIcon || !entities || !config || !connection || !stateObj) {
return initialState;
}
return entityIcon(
entities,
config.config,
connection.connection,
stateObj,
stateValue
);
},
args: () =>
[
this._overrideIcon,
this._entities,
this._config,
this._connection,
this.stateObj,
this.stateValue,
] as const,
});
protected render() {
const overrideIcon = this._overrideIcon;
if (overrideIcon) {
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
}
@@ -51,19 +88,12 @@ export class HaStateIcon extends LitElement {
if (!this._config || !this._connection || !this._entities) {
return this._renderFallback();
}
const icon = entityIcon(
this._entities,
this._config.config,
this._connection.connection,
this.stateObj,
this.stateValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
-2
View File
@@ -1116,7 +1116,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return multiTermSortedSearch(
items,
searchTerm,
weightedKeys,
(item) => item.id,
fuseIndex
);
@@ -1233,7 +1232,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<state-badge
slot="start"
.stateObj=${(item as EntityComboBoxItem).stateObj}
.hass=${this.hass}
></state-badge>
`
: type === "device" && (item as DevicePickerItem).domain
+15 -7
View File
@@ -1,10 +1,12 @@
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { internationalizationContext, uiContext } from "../data/context";
import type { ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
@@ -23,7 +25,13 @@ export class HaThemePicker extends LitElement {
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@property({ attribute: false }) public hass?: HomeAssistant;
@state()
@consume({ context: uiContext, subscribe: true })
private _ui?: ContextType<typeof uiContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -56,8 +64,8 @@ export class HaThemePicker extends LitElement {
private _getItems = () =>
this._getThemeOptions(
this.hass?.themes.themes || {},
this.hass?.locale.language || "en",
this._ui?.themes.themes || {},
this._i18n?.locale.language || "en",
this.includeDefault
);
@@ -70,10 +78,10 @@ export class HaThemePicker extends LitElement {
return html`
<ha-generic-picker
.label=${this.label ??
this.hass?.localize("ui.components.theme-picker.theme") ??
this._i18n?.localize("ui.components.theme-picker.theme") ??
"Theme"}
.placeholder=${this.noThemeLabel ??
this.hass?.localize("ui.components.theme-picker.no_theme")}
this._i18n?.localize("ui.components.theme-picker.no_theme")}
.helper=${this.helper}
.value=${this.value}
.valueRenderer=${this._valueRenderer}
-1
View File
@@ -73,7 +73,6 @@ export class HaThemeSettings extends LitElement {
${this.showThemePicker
? html`
<ha-theme-picker
.hass=${this.hass}
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
+136
View File
@@ -0,0 +1,136 @@
import memoizeOne from "memoize-one";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import "./ha-select";
import type { TimestampRenderingFormat } from "../panels/lovelace/components/types";
import { TIMESTAMP_RENDERING_FORMATS } from "../panels/lovelace/components/types";
@customElement("ha-time-format-picker")
export class HaTimeFormatPicker extends LitElement {
@property() public value?: TimestampRenderingFormat;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
private _options = memoizeOne((localize: LocalizeFunc) =>
[{ label: localize("ui.common.auto"), value: "auto" }].concat(
TIMESTAMP_RENDERING_FORMATS.map((format) => ({
label:
localize(`ui.components.time-format-picker.formats.${format}`) ||
format,
value: format,
}))
)
);
private _styleOptions = memoizeOne((localize: LocalizeFunc) => [
{ label: localize("ui.common.auto"), value: "auto" },
{
label: localize("ui.components.time-format-picker.styles.short"),
value: "short",
},
{
label: localize("ui.components.time-format-picker.styles.long"),
value: "long",
},
]);
protected render() {
const type = typeof this.value === "object" ? this.value.type : this.value;
const style = typeof this.value === "object" ? this.value.style : undefined;
return html`
<div class="row">
<ha-select
.label=${this.label ?? ""}
.value=${type || "auto"}
.helper=${this.helper ?? ""}
.disabled=${this.disabled}
@selected=${this._selectChanged}
.options=${this._options(this._localize)}
>
</ha-select>
${this.value
? html`
<ha-select
.label=${this._localize(
"ui.components.time-format-picker.style"
)}
.value=${style || "auto"}
.disabled=${this.disabled}
@selected=${this._styleChanged}
.options=${this._styleOptions(this._localize)}
>
</ha-select>
`
: nothing}
</div>
`;
}
private _selectChanged(ev) {
ev.stopPropagation();
if (ev.detail?.value === "auto" && this.value !== undefined) {
fireEvent(this, "value-changed", {
value: undefined,
});
return;
}
if (this.value && typeof this.value === "object" && this.value.style) {
fireEvent(this, "value-changed", {
value: {
type: ev.detail.value,
style: this.value.style,
},
});
return;
}
fireEvent(this, "value-changed", {
value: ev.detail.value,
});
}
private _styleChanged(ev) {
ev.stopPropagation();
const type = typeof this.value === "object" ? this.value.type : this.value;
if (ev.detail?.value === "auto") {
fireEvent(this, "value-changed", {
value: type,
});
return;
}
fireEvent(this, "value-changed", {
value: {
type: type,
style: ev.detail.value,
},
});
}
static styles = css`
.row {
display: flex;
gap: 12px;
}
.row > * {
flex: 1;
min-width: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-time-format-picker": HaTimeFormatPicker;
}
}

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