Compare commits

..

80 Commits

Author SHA1 Message Date
Petar Petrov 7aab3d1baf text tweak 2026-06-22 14:08:22 +03:00
Petar Petrov 6dbf33b9cd Add bottom margin to ha-alert in pending-config dialog
Without it the changes list sits flush against the reverted-state info
alert.
2026-06-22 13:59:09 +03:00
Petar Petrov a6a5eb2050 Show auto-revert countdown in HTTP pending-config dialog
Surface the new revert_at deadline (core PR #174428) in the popup that
opens after saving HTTP server settings. While pending, the dialog shows
a ticking 'Settings will revert in M:SS.' line; when the deadline passes
it switches into a reverted state with an info alert and a Close button.
Confirm/Revert remain available until the deadline.
2026-06-22 13:43:55 +03:00
Petar Petrov 757079983a Merge remote-tracking branch 'origin/dev' into port-8123 2026-06-22 13:32:39 +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
Petar Petrov cb65657479 HTTP config: confirm or revert pending changes after restart (#52452)
* HTTP config: prompt admin to confirm or revert pending changes after restart

After saving HTTP server settings, core now writes the new config as pending,
restarts Home Assistant, and keeps the previous stable config as a recovery
fallback. The frontend now:

- Reads {stable, pending} from http/config and adds a promoteHttpConfig call.
- Asks the admin to confirm before saving (since saving now auto-restarts).
- After reconnect, shows a non-dismissable popup for admins with the changed
  fields, where Confirm promotes pending -> stable and Revert clears pending
  (which triggers another restart).
- Renders an info banner above the form whenever pending is set.

* Drop pending banner and pending handling from HTTP form

The popup blocks any other interaction while pending exists, so the form
is only reachable when pending is null. Stop fetching/displaying the
pending config and the unconfirmed-config banner.

* Use HTTP settings in dialog title
2026-06-17 14:35:51 +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
Petar Petrov d99526ff60 Add HTTP server settings to the network panel (#51981)
* Fix focus loss in ha-input-multi when items change

* Add HTTP server settings to the network panel

* Surface fetch errors and validate before saving in HTTP config form

* Update src/panels/config/network/ha-config-http-form.ts

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

* Group HTTP form fields into collapsible sections

* Add bottom margin to ha-input-multi add button

* Only apply add-button margin when helper text is present

* Update HTTP config form to new WebSocket API

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-06-04 13:06:14 +03:00
309 changed files with 15077 additions and 8917 deletions
-308
View File
@@ -1,308 +0,0 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
# BrowserStack runs are gated by the `e2e-browserstack` label or manual
# dispatch — see the e2e-browserstack job below. Local Chromium always runs.
workflow_dispatch:
inputs:
run-browserstack:
description: "Run BrowserStack suite"
type: boolean
default: true
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
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
#
# Gated on:
# - manual dispatch with the run-browserstack input enabled, OR
# - a PR with the `e2e-browserstack` label applied.
# This keeps CI fast on normal PRs while still allowing on-demand runs.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch' && inputs.run-browserstack) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e-browserstack'))
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
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 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 (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
# Only depends on the local job — BrowserStack reports live on the
# BrowserStack Automate dashboard and don't feed into the local blob report.
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
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
-53
View File
@@ -1,53 +0,0 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+1 -18
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");
@@ -320,22 +321,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);
+20 -31
View File
@@ -22,16 +22,7 @@
"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:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"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:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk 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",
@@ -45,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",
@@ -72,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",
@@ -80,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",
@@ -136,7 +128,7 @@
},
"devDependencies": {
"@babel/core": "7.29.7",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
@@ -146,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",
@@ -165,11 +155,10 @@
"@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",
"browserslist-useragent-regexp": "4.1.4",
"browserstack-node-sdk": "1.53.2",
"del": "8.0.1",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
@@ -207,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"
@@ -219,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";
};
-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);
}
`;
}
+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) {
+8 -11
View File
@@ -8,6 +8,7 @@ import { arrayLiteralIncludes } from "../../common/array/literal-includes";
import secondsToDuration from "../../common/datetime/seconds_to_duration";
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 { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
@@ -163,20 +164,18 @@ 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 "—";
}
return valueFromParts(this.hass!.formatEntityStateToParts(entityState));
}
private _computeShowIcon(
@@ -225,9 +224,7 @@ export class HaStateLabelBadge extends LitElement {
return secondsToDuration(_timerTimeRemaining);
}
return (
this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "unit"
)?.value || null
unitFromParts(this.hass!.formatEntityStateToParts(entityState)) || null
);
}
+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() {
+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) {
+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;
}
@@ -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;
}
+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
@@ -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() {
+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;
}
}
+34 -6
View File
@@ -13,12 +13,40 @@ const SEARCH_KEYS = [
{ name: "secondary", weight: 8 },
];
export const getTimezoneOptions = (): PickerComboBoxItem[] =>
Object.entries(timezones as Record<string, string>).map(([key, value]) => ({
id: key,
primary: value,
secondary: key,
}));
// google-timezones-json is missing the bare "UTC" and "Etc/UTC" zones, even
// though both are valid IANA identifiers and common server defaults. Without
// them a "UTC" configuration shows up as an unknown time zone. Add them back.
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
];
// google-timezones-json also ships an invalid IANA identifier. Correct it so
// the zone can be selected (the backend rejects the invalid id).
const TIMEZONE_ID_CORRECTIONS: Record<string, string> = {
"Asia/Yuzhno-Sakhalinsk": "Asia/Sakhalin",
};
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
const options: PickerComboBoxItem[] = Object.entries(
timezones as Record<string, string>
).map(([key, value]) => {
const id = TIMEZONE_ID_CORRECTIONS[key] ?? key;
return {
id,
primary: value,
secondary: id,
};
});
for (const timezone of ADDITIONAL_TIMEZONES) {
if (!options.some((option) => option.id === timezone.id)) {
options.push(timezone);
}
}
return options;
};
@customElement("ha-timezone-picker")
export class HaTimeZonePicker extends LitElement {
+6
View File
@@ -2,6 +2,7 @@ import {
mdiAlertCircleOutline,
mdiCheckCircleOutline,
mdiChevronDown,
mdiCircleOffOutline,
mdiHelpCircleOutline,
mdiProgressClock,
mdiProgressWrench,
@@ -84,6 +85,11 @@ class HaTracePicker extends LitElement {
"ui.panel.config.automation.trace.picker.debugged"
);
item.icon_path = mdiProgressWrench;
} else if (trace.not_triggered) {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.not_triggered"
);
item.icon_path = mdiCircleOffOutline;
} else if (trace.script_execution === "finished") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.finished",
+19 -11
View File
@@ -18,10 +18,11 @@ import {
mdiWebhook,
} 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 { 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";
@@ -71,6 +72,17 @@ export class HaTriggerIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, trigger]) => {
if (icon || !connection || !config || !trigger) {
return initialState;
}
return triggerIcon(connection, config, trigger);
},
args: () =>
[this.icon, this._connection, this._config, this.trigger] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -84,16 +96,12 @@ export class HaTriggerIcon extends LitElement {
return this._renderFallback();
}
const icon = triggerIcon(this._connection, this._config, this.trigger).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() {
+18 -8
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
@@ -13,7 +14,7 @@ import {
webRtcOffer,
type WebRtcOfferEvent,
} from "../data/camera";
import type { HomeAssistant } from "../types";
import { apiContext, connectionContext } from "../data/context";
import "./ha-alert";
/**
@@ -23,7 +24,13 @@ import "./ha-alert";
*/
@customElement("ha-web-rtc-player")
class HaWebRtcPlayer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@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;
@@ -130,7 +137,7 @@ class HaWebRtcPlayer extends LitElement {
return;
}
if (!this.hass || !this.entityid) {
if (!this._api || !this._connection || !this.entityid) {
return;
}
@@ -141,7 +148,7 @@ class HaWebRtcPlayer extends LitElement {
this._logEvent("start clientConfig");
this._clientConfig = await fetchWebRtcClientConfiguration(
this.hass,
this._api,
this.entityid
);
@@ -230,8 +237,11 @@ class HaWebRtcPlayer extends LitElement {
this._logEvent("start webRtcOffer", offer_sdp);
try {
this._unsub = webRtcOffer(this.hass, this.entityid, offer_sdp, (event) =>
this._handleOfferEvent(event)
this._unsub = webRtcOffer(
this._connection,
this.entityid,
offer_sdp,
(event) => this._handleOfferEvent(event)
);
} catch (err: any) {
this._error = "Failed to start WebRTC stream: " + err.message;
@@ -257,7 +267,7 @@ class HaWebRtcPlayer extends LitElement {
this._sessionId = event.session_id;
this._candidatesList.forEach((candidate) =>
addWebRtcCandidate(
this.hass,
this._api,
this.entityid!,
event.session_id,
// toJSON returns RTCIceCandidateInit
@@ -310,7 +320,7 @@ class HaWebRtcPlayer extends LitElement {
if (this._sessionId) {
addWebRtcCandidate(
this.hass,
this._api,
this.entityid,
this._sessionId,
// toJSON returns RTCIceCandidateInit
+5 -2
View File
@@ -80,7 +80,7 @@ class HaInputMulti extends LitElement {
<div class="items">
${repeat(
this._items,
(item, index) => `${item}-${index}`,
(_item, index) => index,
(item, index) => {
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
return html`
@@ -128,7 +128,7 @@ class HaInputMulti extends LitElement {
)}
</div>
</ha-sortable>
<div class="layout horizontal">
<div class="layout horizontal add-row">
<ha-button
size="s"
appearance="filled"
@@ -217,6 +217,9 @@ class HaInputMulti extends LitElement {
margin-bottom: 8px;
--ha-input-padding-bottom: 0;
}
.add-row:has(+ ha-input-helper-text) {
margin-bottom: var(--ha-space-1);
}
ha-icon-button {
display: block;
}
+8 -7
View File
@@ -1,16 +1,19 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../../types";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-state-icon";
@customElement("ha-entity-marker")
class HaEntityMarker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "entity-id", reflect: true }) public entityId?: string;
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
private _stateObj?: HassEntity;
@property({ attribute: "entity-name" }) public entityName?: string;
@property({ attribute: "entity-unit" }) public entityUnit?: string;
@@ -36,9 +39,7 @@ class HaEntityMarker extends LitElement {
})}
></div>`
: this.showIcon && this.entityId
? html`<ha-state-icon
.stateObj=${this.hass?.states[this.entityId]}
></ha-state-icon>`
? html`<ha-state-icon .stateObj=${this._stateObj}></ha-state-icon>`
: !this.entityUnit
? this.entityName
: html`
@@ -128,7 +128,6 @@ export class HaLocationsEditor extends LitElement {
protected render(): TemplateResult {
return html`
<ha-map
.hass=${this.hass}
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
+74 -33
View File
@@ -1,4 +1,6 @@
import { consume } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type {
Circle,
CircleMarker,
@@ -18,6 +20,7 @@ import {
formatTimeWeekday,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
@@ -26,7 +29,22 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import type { HomeAssistant, ThemeMode } from "../../types";
import {
configContext,
connectionContext,
formattersContext,
internationalizationContext,
statesContext,
uiContext,
} from "../../data/context";
import type {
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantFormatters,
HomeAssistantInternationalization,
HomeAssistantUI,
ThemeMode,
} from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";
@@ -76,7 +94,32 @@ export interface HaMapEntity {
@customElement("ha-map")
export class HaMap extends ReactiveElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HassEntities;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
@state()
@consume({ context: uiContext, subscribe: true })
private _ui!: HomeAssistantUI;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: HomeAssistantConnection;
@property({ attribute: false }) public entities?: string[] | HaMapEntity[];
@@ -175,17 +218,16 @@ export class HaMap extends ReactiveElement {
return;
}
let autoFitRequired = false;
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const oldStates = changedProps.get("_states") as HassEntities | undefined;
if (changedProps.has("_loaded") || changedProps.has("entities")) {
this._drawEntities();
autoFitRequired = !this._pauseAutoFit;
} else if (this._loaded && oldHass && this.entities) {
} else if (this._loaded && oldStates && this.entities) {
// Check if any state has changed
for (const entity of this.entities) {
if (
oldHass.states[getEntityId(entity)] !==
this.hass!.states[getEntityId(entity)]
oldStates[getEntityId(entity)] !== this._states[getEntityId(entity)]
) {
this._drawEntities();
autoFitRequired = !this._pauseAutoFit;
@@ -219,10 +261,11 @@ export class HaMap extends ReactiveElement {
}, PROGRAMMITIC_FIT_DELAY);
}
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("hass") ||
(oldHass && oldHass.themes?.darkMode === this.hass.themes?.darkMode))
(!changedProps.has("_ui") ||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
) {
return;
}
@@ -233,7 +276,7 @@ export class HaMap extends ReactiveElement {
private get _darkMode() {
return (
this.themeMode === "dark" ||
(this.themeMode === "auto" && Boolean(this.hass.themes.darkMode))
(this.themeMode === "auto" && Boolean(this._ui?.themes.darkMode))
);
}
@@ -258,8 +301,8 @@ export class HaMap extends ReactiveElement {
this._loading = true;
try {
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
latitude: this.hass?.config.latitude ?? 52.3731339,
longitude: this.hass?.config.longitude ?? 4.8903147,
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
zoom: this.zoom,
});
this._updateMapStyle();
@@ -300,7 +343,7 @@ export class HaMap extends ReactiveElement {
if (options?.unpause_autofit) {
this._pauseAutoFit = false;
}
if (!this.leafletMap || !this.Leaflet || !this.hass) {
if (!this.leafletMap || !this.Leaflet || !this._config) {
return;
}
@@ -311,10 +354,7 @@ export class HaMap extends ReactiveElement {
) {
this._isProgrammaticFit = true;
this.leafletMap.setView(
new this.Leaflet.LatLng(
this.hass.config.latitude,
this.hass.config.longitude
),
new this.Leaflet.LatLng(this._config.latitude, this._config.longitude),
options?.zoom || this.zoom
);
setTimeout(() => {
@@ -351,7 +391,7 @@ export class HaMap extends ReactiveElement {
boundingbox: LatLngExpression[],
options?: { zoom?: number; pad?: number }
) {
if (!this.leafletMap || !this.Leaflet || !this.hass) {
if (!this.leafletMap || !this.Leaflet) {
return;
}
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
@@ -382,32 +422,31 @@ export class HaMap extends ReactiveElement {
if (path.fullDatetime) {
formattedTime = formatDateTime(
point.timestamp,
this.hass.locale,
this.hass.config
this._i18n.locale,
this._config
);
} else if (isToday(point.timestamp)) {
formattedTime = formatTimeWithSeconds(
point.timestamp,
this.hass.locale,
this.hass.config
this._i18n.locale,
this._config
);
} else {
formattedTime = formatTimeWeekday(
point.timestamp,
this.hass.locale,
this.hass.config
this._i18n.locale,
this._config
);
}
return `${filterXSS(path.name ?? "")}<br>${formattedTime}`;
}
private _drawPaths(): void {
const hass = this.hass;
const map = this.leafletMap;
// eslint-disable-next-line @typescript-eslint/naming-convention
const Leaflet = this.Leaflet;
if (!hass || !map || !Leaflet) {
if (!this._i18n || !this._config || !map || !Leaflet) {
return;
}
if (this._mapPaths.length) {
@@ -535,12 +574,12 @@ export class HaMap extends ReactiveElement {
}
private _drawEntities(): void {
const hass = this.hass;
const states = this._states;
const map = this.leafletMap;
// eslint-disable-next-line @typescript-eslint/naming-convention
const Leaflet = this.Leaflet;
if (!hass || !map || !Leaflet) {
if (!states || !map || !Leaflet) {
return;
}
@@ -578,7 +617,7 @@ export class HaMap extends ReactiveElement {
const className = this._darkMode ? "dark" : "light";
for (const entity of this.entities) {
const stateObj = hass.states[getEntityId(entity)];
const stateObj = states[getEntityId(entity)];
if (!stateObj) {
continue;
}
@@ -591,7 +630,7 @@ export class HaMap extends ReactiveElement {
entity_picture: entityPicture,
} = stateObj.attributes;
const location = getEntityLocation(stateObj, hass.states);
const location = getEntityLocation(stateObj, states);
if (!location) {
continue;
}
@@ -648,11 +687,14 @@ export class HaMap extends ReactiveElement {
// create icon
const entityName =
typeof entity !== "string" && entity.label_mode === "state"
? this.hass.formatEntityState(stateObj)
? this._formatters.formatEntityState(stateObj)
: typeof entity !== "string" &&
entity.label_mode === "attribute" &&
entity.attribute !== undefined
? this.hass.formatEntityAttributeValue(stateObj, entity.attribute)
? this._formatters.formatEntityAttributeValue(
stateObj,
entity.attribute
)
: (customTitle ??
title
.split(" ")
@@ -661,7 +703,6 @@ export class HaMap extends ReactiveElement {
.substr(0, 3));
const entityMarker = document.createElement("ha-entity-marker");
entityMarker.hass = this.hass;
entityMarker.showIcon =
typeof entity !== "string" && entity.label_mode === "icon";
entityMarker.entityId = getEntityId(entity);
@@ -674,7 +715,7 @@ export class HaMap extends ReactiveElement {
: "";
entityMarker.entityPicture =
entityPicture && (typeof entity === "string" || !entity.label_mode)
? this.hass.hassUrl(entityPicture)
? this._connection.hassUrl(entityPicture)
: "";
if (typeof entity !== "string") {
entityMarker.entityColor = entity.color;
-1
View File
@@ -26,7 +26,6 @@ export class HaTraceLogbook extends LitElement {
return this.logbookEntries.length
? html`
<ha-logbook-renderer
relative-time
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
+27 -5
View File
@@ -17,9 +17,10 @@ import type {
ChooseActionTraceStep,
TraceExtended,
} from "../../data/trace";
import { getDataFromPath } from "../../data/trace";
import { getDataFromPath, isTriggerPath } from "../../data/trace";
import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-code-editor";
import "../ha-icon-button";
import "../ha-tab-group";
@@ -33,6 +34,12 @@ const TRACE_PATH_TABS = [
"logbook",
] as const;
// A repeat keeps only its last iterations, so the array index is not the real
// one. Use the recorded repeat.index when we have it.
const iterationNumber = (trace: ActionTraceStep, index: number): number =>
(trace.changed_variables?.repeat as { index?: number } | undefined)?.index ??
index + 1;
@customElement("ha-trace-path-details")
export class HaTracePathDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -63,7 +70,7 @@ export class HaTracePathDetails extends LitElement {
protected render(): TemplateResult {
return html`
<div class="padded-box trace-info">
${this._renderSelectedTraceInfo()}
${this._renderNotTriggeredNotice()} ${this._renderSelectedTraceInfo()}
</div>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
@@ -89,6 +96,22 @@ export class HaTracePathDetails extends LitElement {
`;
}
private _renderNotTriggeredNotice() {
if (
!this.trace.not_triggered ||
!this.selected?.path ||
!isTriggerPath(this.selected.path) ||
!(this.selected.path in this.trace.trace)
) {
return nothing;
}
return html`<ha-alert alert-type="info">
${this.hass!.localize(
"ui.panel.config.automation.trace.path.not_triggered"
)}
</ha-alert>`;
}
private _renderSelectedTraceInfo() {
const paths = this.trace.trace;
@@ -214,7 +237,7 @@ export class HaTracePathDetails extends LitElement {
: html`<h3>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: idx + 1 }
{ number: iterationNumber(trace, idx) }
)}
</h3>`}
${curPath
@@ -318,7 +341,7 @@ export class HaTracePathDetails extends LitElement {
? html`<p>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: idx + 1 }
{ number: iterationNumber(trace, idx) }
)}
</p>`
: ""}
@@ -388,7 +411,6 @@ export class HaTracePathDetails extends LitElement {
return entries.length
? html`
<ha-logbook-renderer
relative-time
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
+6
View File
@@ -20,6 +20,9 @@ export class HatGraphNode extends LitElement {
@property({ attribute: "not-enabled", reflect: true, type: Boolean })
notEnabled = false;
@property({ attribute: "not-triggered", reflect: true, type: Boolean })
notTriggered = false;
@property({ attribute: "graph-start", reflect: true, type: Boolean })
graphStart = false;
@@ -127,6 +130,9 @@ export class HatGraphNode extends LitElement {
--stroke-clr: var(--hover-clr);
--icon-clr: var(--default-icon-clr);
}
:host([not-triggered]) circle {
stroke-dasharray: 4 3;
}
:host([not-enabled]) circle {
--stroke-clr: var(--disabled-clr);
}
+9 -3
View File
@@ -90,21 +90,27 @@ export class HatScriptGraph extends LitElement {
private _renderTrigger(config: Trigger, i: number) {
const path = `trigger/${i}`;
const track = this.trace && path in this.trace.trace;
const tracked = this.trace && path in this.trace.trace;
// A not-triggered trace records the trigger that evaluated a change but
// decided not to fire. It is still selectable (to view the reason), but
// must not be shown as the path that ran.
const notTriggered = !!(tracked && this.trace.not_triggered);
const track = tracked && !notTriggered;
this.renderedNodes[path] = { config, path, type: "trigger" };
if (track) {
if (tracked) {
this.trackedNodes[path] = this.renderedNodes[path];
}
return html`
<hat-graph-node
graph-start
?track=${track}
?not-triggered=${notTriggered}
@focus=${this._selectNode(config, path, "trigger")}
?active=${this.selected === path}
.iconPath=${mdiAsterisk}
.notEnabled=${"enabled" in config && config.enabled === false}
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
tabindex=${track ? "0" : "-1"}
tabindex=${tracked ? "0" : "-1"}
></hat-graph-node>
`;
}
+30 -2
View File
@@ -2,6 +2,7 @@ import { consume } from "@lit/context";
import {
mdiAlertCircle,
mdiCircle,
mdiCircleOffOutline,
mdiCircleOutline,
mdiProgressClock,
mdiProgressWrench,
@@ -18,7 +19,7 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
import { localizeTriggerDescription } from "../../data/logbook";
import { localizeTriggerSource } from "../../data/logbook";
import type {
ChooseAction,
IfAction,
@@ -323,6 +324,23 @@ class ActionRenderer {
}
private _handleTrigger(index: number, triggerStep: TriggerTraceStep): number {
if (this.trace.not_triggered) {
this._renderEntry(
triggerStep.path,
this.hass.localize(
"ui.panel.config.automation.trace.messages.evaluated_not_triggered",
{
time: formatDateTimeWithSeconds(
new Date(triggerStep.timestamp),
this.hass.locale,
this.hass.config
),
}
),
mdiCircleOffOutline
);
return index + 1;
}
this._renderEntry(
triggerStep.path,
this.hass.localize(
@@ -333,7 +351,7 @@ class ActionRenderer {
: "other",
alias: triggerStep.changed_variables.trigger?.alias,
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
trigger: localizeTriggerDescription(
trigger: localizeTriggerSource(
this.hass.localize,
this.trace.trigger
),
@@ -725,6 +743,16 @@ export class HaAutomationTracer extends LitElement {
),
icon: mdiProgressWrench,
};
} else if (this.trace.not_triggered) {
entry = {
description: this.hass.localize(
"ui.panel.config.automation.trace.messages.not_triggered",
{
time: renderFinishedAt(),
}
),
icon: mdiCircleOffOutline,
};
} else if (this.trace.script_execution === "finished") {
entry = {
description: this.hass.localize(
+1 -1
View File
@@ -1,7 +1,7 @@
import type { HomeAssistant } from "../types";
import type { Selector } from "./selector";
export const enum AITaskEntityFeature {
export enum AITaskEntityFeature {
GENERATE_DATA = 1,
SUPPORT_ATTACHMENTS = 2,
GENERATE_IMAGE = 4,
+2 -2
View File
@@ -18,7 +18,7 @@ import { getExtendedEntityRegistryEntry } from "./entity/entity_registry";
export const FORMAT_TEXT = "text";
export const FORMAT_NUMBER = "number";
export const enum AlarmControlPanelEntityFeature {
export enum AlarmControlPanelEntityFeature {
ARM_HOME = 1,
ARM_AWAY = 2,
ARM_NIGHT = 4,
@@ -108,7 +108,7 @@ export const supportedAlarmModes = (stateObj: AlarmControlPanelEntity) =>
export const setProtectedAlarmControlPanelMode = async (
element: HTMLElement,
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callService" | "localize" | "callWS">,
stateObj: AlarmControlPanelEntity,
mode: AlarmMode
) => {
+5 -2
View File
@@ -338,7 +338,7 @@ export const runDebugAssistPipeline = (
};
export const runAssistPipeline = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "connection">,
callback: (event: PipelineRunEvent) => void,
options: PipelineRunOptions
) =>
@@ -379,7 +379,10 @@ export const listAssistPipelines = (hass: HomeAssistant) =>
type: "assist_pipeline/pipeline/list",
});
export const getAssistPipeline = (hass: HomeAssistant, pipeline_id?: string) =>
export const getAssistPipeline = (
hass: Pick<HomeAssistant, "callWS">,
pipeline_id?: string
) =>
hass.callWS<AssistPipeline>({
type: "assist_pipeline/pipeline/get",
pipeline_id,
+1 -1
View File
@@ -3,7 +3,7 @@ import { supportsFeature } from "../common/entity/supports-feature";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity/entity";
export const enum AssistSatelliteEntityFeature {
export enum AssistSatelliteEntityFeature {
ANNOUNCE = 1,
}
+1 -1
View File
@@ -41,7 +41,7 @@ export const autocompleteLoginFields = (schema: HaFormSchema[]) =>
});
export const getSignedPath = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
path: string
): Promise<SignedPath> => hass.callWS({ type: "auth/sign_path", path });
+2 -2
View File
@@ -180,7 +180,7 @@ export interface PersistentNotificationTrigger extends BaseTrigger {
export interface ZoneTrigger extends BaseTrigger {
trigger: "zone";
entity_id: string;
entity_id: string | string[];
zone: string;
event: "enter" | "leave";
}
@@ -377,7 +377,7 @@ export const expandConditionWithShorthand = (
};
export const triggerAutomationActions = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callService">,
entityId: string
) => {
hass.callService("automation", "trigger", {
+3
View File
@@ -1124,6 +1124,9 @@ const describeLegacyCondition = (
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
numberOfEntities: entities.length,
// With "any", entities are joined with "or", which takes a singular
// verb in English even for multiple entities ("A or B is ...").
matchAny: condition.match === "any" ? "true" : "false",
entities:
condition.match === "any"
? formatListWithOrs(hass.locale, entities)
+6
View File
@@ -486,6 +486,12 @@ export const getFormattedBackupTime = memoizeOne(
export const SUPPORTED_UPLOAD_FORMAT = "application/x-tar";
// Browsers report the MIME type of a .tar inconsistently (Firefox on Windows
// gives an empty or different type), so accept it by extension as well.
export const isSupportedBackupFile = (file: File): boolean =>
file.type === SUPPORTED_UPLOAD_FORMAT ||
file.name.toLowerCase().endsWith(".tar");
export interface BackupUploadFileFormData {
file?: File;
}
+1 -1
View File
@@ -54,7 +54,7 @@ export enum RecurrenceRange {
THISANDFUTURE = "THISANDFUTURE",
}
export const enum CalendarEntityFeature {
export enum CalendarEntityFeature {
CREATE_EVENT = 1,
DELETE_EVENT = 2,
UPDATE_EVENT = 4,
+12 -9
View File
@@ -7,14 +7,17 @@ import type { HomeAssistant } from "../types";
import { getSignedPath } from "./auth";
export const CAMERA_ORIENTATIONS = [1, 2, 3, 4, 6, 8];
export const CAMERA_SUPPORT_ON_OFF = 1;
export const CAMERA_SUPPORT_STREAM = 2;
export const STREAM_TYPE_HLS = "hls";
export const STREAM_TYPE_WEB_RTC = "web_rtc";
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
export enum CameraEntityFeature {
ON_OFF = 1,
STREAM = 2,
}
interface CameraEntityAttributes extends HassEntityAttributeBase {
model_name: string;
access_token?: string;
@@ -86,7 +89,7 @@ export const computeMJPEGStreamUrl = (
: undefined;
export const fetchThumbnailUrlWithCache = async (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
entityId: string,
width: number,
height: number
@@ -102,7 +105,7 @@ export const fetchThumbnailUrlWithCache = async (
};
export const fetchThumbnailUrl = async (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
entityId: string
) => {
const path = await getSignedPath(hass, `/api/camera_proxy/${entityId}`);
@@ -110,7 +113,7 @@ export const fetchThumbnailUrl = async (
};
export const fetchStreamUrl = async (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
entityId: string,
format?: "hls"
) => {
@@ -128,7 +131,7 @@ export const fetchStreamUrl = async (
};
export const webRtcOffer = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "connection">,
entity_id: string,
offer: string,
callback: (event: WebRtcOfferEvent) => void
@@ -140,7 +143,7 @@ export const webRtcOffer = (
});
export const addWebRtcCandidate = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
entity_id: string,
session_id: string,
candidate: RTCIceCandidateInit
@@ -186,7 +189,7 @@ export interface CameraCapabilities {
}
export const fetchCameraCapabilities = async (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
entity_id: string
) =>
hass.callWS<CameraCapabilities>({ type: "camera/capabilities", entity_id });
@@ -197,7 +200,7 @@ export interface WebRTCClientConfiguration {
}
export const fetchWebRtcClientConfiguration = async (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
entityId: string
) =>
hass.callWS<WebRTCClientConfiguration>({
+1 -1
View File
@@ -68,7 +68,7 @@ export type ClimateEntity = HassEntityBase & {
};
};
export const enum ClimateEntityFeature {
export enum ClimateEntityFeature {
TARGET_TEMPERATURE = 1,
TARGET_TEMPERATURE_RANGE = 2,
TARGET_HUMIDITY = 4,
+7
View File
@@ -10,6 +10,13 @@ export interface DirtyStateContext<
> {
/** Whether any contributor's current slice differs from its initial snapshot */
isDirty: boolean;
/**
* Like `isDirty`, but treats `false` and `undefined`/absent object keys as
* the same value, so a toggle that ends at its off-default (e.g.
* `show_entity_picture: false`) reads as clean and does not warn on a scrim
* close. `isDirty` still reports the raw change so save can stay enabled.
*/
isEffectiveDirty: boolean;
/**
* Push a state slice. The first push for a slice sets its baseline.
* Subsequent pushes are compared against that baseline using the provider's
+1 -1
View File
@@ -1,7 +1,7 @@
import { ensureArray } from "../common/array/ensure-array";
import type { HomeAssistant } from "../types";
export const enum ConversationEntityFeature {
export enum ConversationEntityFeature {
CONTROL = 1,
}
+4 -4
View File
@@ -4,10 +4,10 @@ import type {
} from "home-assistant-js-websocket";
import { stateActive } from "../common/entity/state_active";
import { supportsFeature } from "../common/entity/supports-feature";
import type { HomeAssistant } from "../types";
import type { HomeAssistantFormatters } from "../types";
import { UNAVAILABLE } from "./entity/entity";
export const enum CoverEntityFeature {
export enum CoverEntityFeature {
OPEN = 1,
CLOSE = 2,
SET_POSITION = 4,
@@ -122,7 +122,7 @@ export interface CoverEntity extends HassEntityBase {
export function computeCoverPositionStateDisplay(
stateObj: CoverEntity,
hass: HomeAssistant,
formatEntityAttributeValue: HomeAssistantFormatters["formatEntityAttributeValue"],
position?: number
) {
const statePosition = stateActive(stateObj)
@@ -133,7 +133,7 @@ export function computeCoverPositionStateDisplay(
const currentPosition = position ?? statePosition;
return currentPosition && currentPosition !== 100
? hass.formatEntityAttributeValue(
? formatEntityAttributeValue(
stateObj,
// Always use position as it's the same formatting as tilt position
"current_position",
+3 -3
View File
@@ -1,14 +1,14 @@
import type { HassEntityBase } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { HomeAssistantApi } from "../types";
export const stateToIsoDateString = (entityState: HassEntityBase) =>
`${entityState}T00:00:00`;
export const setDateValue = (
hass: HomeAssistant,
callService: HomeAssistantApi["callService"],
entityId: string,
date: string | undefined = undefined
) => {
const param = { entity_id: entityId, date };
hass.callService("date", "set_value", param);
callService("date", "set_value", param);
};
+3 -3
View File
@@ -1,11 +1,11 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistantApi } from "../types";
export const setDateTimeValue = (
hass: HomeAssistant,
callService: HomeAssistantApi["callService"],
entityId: string,
datetime: Date
) => {
hass.callService("datetime", "set_value", {
callService("datetime", "set_value", {
entity_id: entityId,
datetime: datetime.toISOString(),
});
+8 -9
View File
@@ -211,14 +211,14 @@ export interface EntityRegistryEntryUpdateParams {
const batteryPriorities = ["sensor", "binary_sensor"];
export const findBatteryEntity = <T extends { entity_id: string }>(
hass: HomeAssistant,
states: HomeAssistant["states"],
entities: T[]
): T | undefined => {
const batteryEntities = entities
.filter(
(entity) =>
hass.states[entity.entity_id] &&
hass.states[entity.entity_id].attributes.device_class === "battery" &&
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery" &&
batteryPriorities.includes(computeDomain(entity.entity_id))
)
.sort(
@@ -234,14 +234,13 @@ export const findBatteryEntity = <T extends { entity_id: string }>(
};
export const findBatteryChargingEntity = <T extends { entity_id: string }>(
hass: HomeAssistant,
states: HomeAssistant["states"],
entities: T[]
): T | undefined =>
entities.find(
(entity) =>
hass.states[entity.entity_id] &&
hass.states[entity.entity_id].attributes.device_class ===
"battery_charging"
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery_charging"
);
export const computeEntityRegistryName = (
@@ -259,7 +258,7 @@ export const computeEntityRegistryName = (
};
export const getExtendedEntityRegistryEntry = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
entityId: string
): Promise<ExtEntityRegistryEntry> =>
hass.callWS({
@@ -277,7 +276,7 @@ export const getExtendedEntityRegistryEntries = (
});
export const updateEntityRegistryEntry = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
entityId: string,
updates: Partial<EntityRegistryEntryUpdateParams>
): Promise<UpdateEntityRegistryEntryResult> =>
+3 -3
View File
@@ -12,7 +12,7 @@ import type {
import { stateActive } from "../common/entity/state_active";
import type { HomeAssistant } from "../types";
export const enum FanEntityFeature {
export enum FanEntityFeature {
SET_SPEED = 1,
OSCILLATE = 2,
DIRECTION = 4,
@@ -100,7 +100,7 @@ export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 4;
export function computeFanSpeedStateDisplay(
stateObj: FanEntity,
hass: HomeAssistant,
formatters: Pick<HomeAssistant, "formatEntityAttributeValue">,
speed?: number
) {
const percentage = stateActive(stateObj)
@@ -109,7 +109,7 @@ export function computeFanSpeedStateDisplay(
const currentSpeed = speed ?? percentage;
return currentSpeed
? hass.formatEntityAttributeValue(
? formatters.formatEntityAttributeValue(
stateObj,
"percentage",
Math.round(currentSpeed)
+6 -2
View File
@@ -8,9 +8,13 @@ export const uploadFile = async (hass: HomeAssistant, file: File) => {
body: fd,
});
if (resp.status === 413) {
throw new Error(`Uploaded file is too large (${file.name})`);
throw new Error(
hass.localize("ui.common.upload_file_too_large", {
name: file.name,
})
);
} else if (resp.status !== 200) {
throw new Error("Unknown error");
throw new Error(hass.localize("ui.common.unknown_error"));
}
const data = await resp.json();
return data.file_id;
+41
View File
@@ -0,0 +1,41 @@
import type { HomeAssistant } from "../types";
export interface HttpConfig {
server_host?: string[];
server_port?: number;
ssl_certificate?: string;
ssl_peer_certificate?: string;
ssl_key?: string;
cors_allowed_origins?: string[];
use_x_forwarded_for?: boolean;
trusted_proxies?: string[];
use_x_frame_options?: boolean;
ip_ban_enabled?: boolean;
login_attempts_threshold?: number;
ssl_profile?: "modern" | "intermediate";
}
export interface HttpConfigState {
stable: HttpConfig;
pending: HttpConfig | null;
revert_at: string | null;
}
export interface SaveHttpConfigResult {
restart: boolean;
}
export const fetchHttpConfig = (hass: HomeAssistant) =>
hass.callWS<HttpConfigState>({ type: "http/config" });
export const saveHttpConfig = (
hass: HomeAssistant,
config: HttpConfig | null
) =>
hass.callWS<SaveHttpConfigResult>({
type: "http/config/configure",
config,
});
export const promoteHttpConfig = (hass: HomeAssistant) =>
hass.callWS<undefined>({ type: "http/config/promote" });
+1 -1
View File
@@ -20,7 +20,7 @@ export type HumidifierEntity = HassEntityBase & {
};
};
export const enum HumidifierEntityFeature {
export enum HumidifierEntityFeature {
MODES = 1,
}
+10 -8
View File
@@ -39,6 +39,7 @@ import {
mdiMicrophoneMessage,
mdiMotionSensor,
mdiPalette,
mdiRadioTower,
mdiRayVertex,
mdiRemote,
mdiRobot,
@@ -52,7 +53,6 @@ import {
mdiThermostat,
mdiTimerOutline,
mdiToggleSwitch,
mdiVideoInputAntenna,
mdiWater,
mdiWaterPercent,
mdiWeatherPartlyCloudy,
@@ -129,7 +129,7 @@ export const FALLBACK_DOMAIN_ICONS = {
plant: mdiFlower,
power: mdiFlash,
proximity: mdiAppleSafari,
radio_frequency: mdiVideoInputAntenna,
radio_frequency: mdiRadioTower,
remote: mdiRemote,
scene: mdiPalette,
schedule: mdiCalendarClock,
@@ -548,7 +548,9 @@ const getEntityIcon = async (
};
export const attributeIcon = async (
hass: HomeAssistant,
hassConfig: HomeAssistant["config"],
hassConnection: HomeAssistant["connection"],
entities: HomeAssistant["entities"],
state: HassEntity,
attribute: string,
attributeValue?: string
@@ -556,7 +558,7 @@ export const attributeIcon = async (
let icon: string | undefined;
const domain = computeStateDomain(state);
const deviceClass = state.attributes.device_class;
const entity = hass.entities?.[state.entity_id] as
const entity = entities[state.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
const platform = entity?.platform;
@@ -567,8 +569,8 @@ export const attributeIcon = async (
if (translation_key && platform) {
const platformIcons = await getPlatformIcons(
hass.config,
hass.connection,
hassConfig,
hassConnection,
platform
);
if (platformIcons) {
@@ -580,8 +582,8 @@ export const attributeIcon = async (
}
if (!icon) {
const entityComponentIcons = await getComponentIcons(
hass.connection,
hass.config,
hassConnection,
hassConfig,
domain
);
if (entityComponentIcons) {
+6 -2
View File
@@ -57,9 +57,13 @@ export const createImage = async (
body: fd,
});
if (resp.status === 413) {
throw new Error(`Uploaded image is too large (${file.name})`);
throw new Error(
hass.localize("ui.common.upload_image_too_large", {
name: file.name,
})
);
} else if (resp.status !== 200) {
throw new Error("Unknown error");
throw new Error(hass.localize("ui.common.unknown_error"));
}
return resp.json();
};
+134
View File
@@ -0,0 +1,134 @@
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeEntityName } from "../common/entity/compute_entity_name";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE, UNKNOWN } from "./entity/entity";
// The infrared integration is an entity-type integration: its emitter and
// receiver entities live in the `infrared` domain (their registry platform is
// the providing integration, e.g. broadlink/esphome).
const INFRARED_DOMAIN = "infrared";
export type InfraredProxyType = "emitter" | "receiver";
export type InfraredDeviceType = InfraredProxyType | "both";
export interface InfraredDevice {
id: string;
device_id: string | null;
name: string;
type: InfraredDeviceType;
online: boolean;
// Most recent last-used timestamp (entity state) across the device's
// entities, as an ISO string. Undefined when never used.
last_used?: string;
entity_ids: string[];
}
interface InfraredProxyEntity {
entity_id: string;
device_id: string | null;
name: string;
type: InfraredProxyType;
online: boolean;
last_used?: string;
}
// Collect the infrared proxy entities from the entity registry. A proxy is an
// entity in the `infrared` domain, classified as emitter or receiver by its
// device class.
const computeInfraredProxies = (
entities: HomeAssistant["entities"],
states: HomeAssistant["states"],
devices: HomeAssistant["devices"]
): InfraredProxyEntity[] => {
const proxies: InfraredProxyEntity[] = [];
for (const entry of Object.values(entities)) {
if (computeDomain(entry.entity_id) !== INFRARED_DOMAIN) {
continue;
}
const stateObj = states[entry.entity_id];
const deviceClass = stateObj?.attributes.device_class;
if (deviceClass !== "emitter" && deviceClass !== "receiver") {
continue;
}
const online = stateObj.state !== UNAVAILABLE;
// The entity state holds the timestamp the proxy was last used (or
// unknown/unavailable when it never has been).
let last_used: string | undefined;
if (stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN) {
const time = new Date(stateObj.state).getTime();
if (!isNaN(time)) {
last_used = stateObj.state;
}
}
proxies.push({
entity_id: entry.entity_id,
device_id: entry.device_id ?? null,
name: computeEntityName(stateObj, entities, devices) || entry.entity_id,
type: deviceClass,
online,
last_used,
});
}
return proxies;
};
// Group the proxy entities by device. A device exposing both an emitter and a
// receiver entity is reported as type "both".
export const computeInfraredDevices = (
entities: HomeAssistant["entities"],
states: HomeAssistant["states"],
devices: HomeAssistant["devices"]
): InfraredDevice[] => {
const proxies = computeInfraredProxies(entities, states, devices);
const groups = new Map<string, InfraredProxyEntity[]>();
for (const proxy of proxies) {
const key = proxy.device_id ?? `entity:${proxy.entity_id}`;
const group = groups.get(key);
if (group) {
group.push(proxy);
} else {
groups.set(key, [proxy]);
}
}
return Array.from(groups.values(), (group) => {
const hasEmitter = group.some((p) => p.type === "emitter");
const hasReceiver = group.some((p) => p.type === "receiver");
const type: InfraredDeviceType =
hasEmitter && hasReceiver ? "both" : hasEmitter ? "emitter" : "receiver";
const online = group.some((p) => p.online);
// Across a device's entities, keep the most recent valid timestamp.
let last_used: string | undefined;
for (const p of group) {
if (
p.last_used &&
(!last_used ||
new Date(p.last_used).getTime() > new Date(last_used).getTime())
) {
last_used = p.last_used;
}
}
const { device_id } = group[0];
const device = device_id ? devices[device_id] : undefined;
const name = (device && computeDeviceName(device)) || group[0].name;
return {
id: device_id ?? group[0].entity_id,
device_id,
name,
type,
online,
last_used,
entity_ids: group.map((p) => p.entity_id),
};
});
};

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