Compare commits

..

57 Commits

Author SHA1 Message Date
Aidan Timson 3f9538890a Implement sections for gallery sidebar 2026-06-15 09:57:27 +01:00
Franck Nijhof c2adc2b84a Use numeric timestamps instead of Date objects in history line chart data (#52631) 2026-06-15 11:22:57 +03:00
Franck Nijhof 2e3cbf6aab Index tooltip points by series in history line chart (#52630) 2026-06-15 11:16:11 +03:00
Franck Nijhof 82b2a60f32 Index chart legend datasets by id and name for O(1) lookup (#52632) 2026-06-15 11:03:36 +03:00
Franck Nijhof 2eb1811524 Don't mutate shared registry objects in the area page (#52611)
The area page resolved device and entity display names by writing them
back onto the registry entries returned by the (memoized) memberships,
which are the shared objects from hass.devices and the entity registry.
That overwrote each entry's raw name during render and leaked app-wide: an
unnamed device would get name set to the localized "Unnamed device", which
then short-circuits the entity-derived fallback elsewhere, and a device or
entity with no user name would have its name field corrupted in the
settings dialog.

Compute the display names on shallow copies and sort/render those instead,
leaving the shared registry objects untouched. The area page renders the
same names and order as before.
2026-06-15 11:00:31 +03:00
TheJulianJES 04b284159a Fix serial port selector integration domain in options flows (#52626)
* Pass the integration domain to the flow form context

In options flows the flow handler is the config entry id, not the
integration domain. Expose the resolved domain (falling back to the
handler for config flows) on the flow form context so selectors can rely
on the actual domain regardless of the flow type.

* Use the flow domain for serial port recommendations

The serial port selector marked a port as recommended by comparing
matching integrations against `context.handler`. In options flows the
handler is the config entry id, so an integration's own ports were
classified as "not recommended" and labelled "Used by <integration>".
Prefer `context.domain` (with a fallback to `handler`) so the
integration's own ports stay recommended in options flows too.

* Drop the handler fallback for the serial port selector domain

`step-flow-form` is the only context producer that sets `handler`, and it
now always sets `domain` alongside it, so the fallback never resolves to a
different value. In options flows `handler` is the config entry id anyway,
which would be the wrong value to fall back to.
2026-06-15 10:58:38 +03:00
renovate[bot] ddce581fdb Update formatjs monorepo to v7.4.9 (#52625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 09:31:35 +02:00
Franck Nijhof 668a7df5cd Use American English spelling for badges behavior label (#52627) 2026-06-15 09:31:08 +02:00
Franck Nijhof d7cad1becd Add rel="noreferrer" to links opening in a new tab (#52628) 2026-06-15 09:30:22 +02:00
Franck Nijhof 1e412ad035 Replace O(n²) entity lookups with Map lookups in history and energy graph (#52629)
Replace O(n²) entity lookups with Map lookups in history merge and energy devices graph
2026-06-15 09:28:59 +02:00
Franck Nijhof 11611cd597 Hide redundant device name in group member list (#52593)
The member entity list in a group entity's more-info dialog always
prefixed each tile with the device name, even when every member belongs to
the same device (for example WLED segments under a single device), where it
adds no information.

Mirror the existing area_name handling: omit the device name when all
members share the same device, and fall back to the entity name so each
tile still has a label.
2026-06-15 09:23:44 +02:00
pcan08 0d545d744b Remove dead diagnostics code from integration card (#52606)
supportsDiagnostics and _diagnosticHandlers were no longer used in the
card template.
2026-06-14 22:03:19 +02:00
Petar Petrov f39dab2de5 Remove misleading "Total exported" line from energy usage tooltip (#52605) 2026-06-14 22:02:54 +02:00
Arsène Reymond 1527117015 fix: font-family for breadcrumb & select-anchor (#52612) 2026-06-14 21:55:32 +02:00
Franck Nijhof 26794560ac Remove unused emptyImageBase64 helper (#52614)
The emptyImageBase64 constant in src/common/empty_image_base64.ts has no
references anywhere in the codebase.
2026-06-14 21:54:45 +02:00
Franck Nijhof 976f9de8ad Remove unused timezone-datalist component (#52615)
The createTimezoneListEl helper in src/components/timezone-datalist.ts has
no references anywhere in the codebase. The google-timezones-json
dependency it used is still imported by other modules, so it is kept.
2026-06-14 21:54:23 +02:00
Franck Nijhof 6810bc5412 Remove unused scrollToTarget helper (#52616)
The default-exported scrollToTarget function in
src/common/dom/scroll-to-target.ts (a legacy copy from
paper-scroll-header-panel) has no references anywhere in the codebase.
2026-06-14 21:53:43 +02:00
Franck Nijhof a4ca54b80b Remove unused loadImg helper (#52617)
The loadImg export in load_resource.ts is not referenced anywhere; drop it
and narrow the internal _load tag type to the used values.
2026-06-14 21:52:59 +02:00
Franck Nijhof 07f0ef0ded Remove unused light helpers (#52619)
lightIsInColorMode and formatTempColor in data/light.ts are not referenced
anywhere in the codebase.
2026-06-14 21:52:25 +02:00
Franck Nijhof cf89bb32ab Remove unused replaceTileLayer helper (#52618)
Remove unused replaceTileLayer and LeafletDrawModuleType

Neither the replaceTileLayer helper nor the LeafletDrawModuleType type in
setup-leaflet-map.ts is referenced anywhere in the codebase.
2026-06-14 21:51:54 +02:00
Franck Nijhof ec5cbd16d8 Add accessible labels to entity ID copy/restore buttons (#52620)
The copy and restore icon buttons next to the entity ID field in the
entity settings dialog had no accessible name. Add descriptive labels
using two new translation keys.
2026-06-14 21:51:05 +02:00
Franck Nijhof 926abd7fc5 Replace Latin "e.g." with plain English in translations (#52621) 2026-06-14 21:50:27 +02:00
Franck Nijhof e227bbe9a2 Add tests for isTimestamp string utility (#52622) 2026-06-14 21:49:46 +02:00
Franck Nijhof f82b0b61e5 Add accessible labels to automation editor icon buttons (#52613)
Icon-only ha-icon-buttons have no accessible name, so screen readers
announce nothing. Add labels (using existing translation keys) to the
conversation trigger's add/remove sentence buttons and the integration
documentation buttons in the trigger and condition platform editors.
2026-06-14 15:21:24 +02:00
renovate[bot] a62c89ee00 Update dependency @rsdoctor/rspack-plugin to v1.5.13 (#52607)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-14 11:27:49 +02:00
Arjan 4b6d07134c Developer tools - State tab: allow to hide Device and Area columns (#52552)
* Allow to hide Device and Area columns

* Fix prettier error
2026-06-14 08:16:38 +02:00
dependabot[bot] 35829c301e Bump home-assistant/actions from 868e6cb4607727d764341a158d98872cd63fa658 to e91ad1948e57189485b9c1ad608af0c303946f89 (#52602)
Bump home-assistant/actions

Bumps [home-assistant/actions](https://github.com/home-assistant/actions) from 868e6cb4607727d764341a158d98872cd63fa658 to e91ad1948e57189485b9c1ad608af0c303946f89.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](https://github.com/home-assistant/actions/compare/868e6cb4607727d764341a158d98872cd63fa658...e91ad1948e57189485b9c1ad608af0c303946f89)

---
updated-dependencies:
- dependency-name: home-assistant/actions
  dependency-version: e91ad1948e57189485b9c1ad608af0c303946f89
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 08:13:53 +02:00
dependabot[bot] a73f587591 Bump actions/checkout from 6.0.2 to 6.0.3 (#52603)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 06:10:53 +00:00
dependabot[bot] 2e5f776af7 Bump home-assistant/wheels from 2025.12.0 to 2026.06.0 (#52604)
Bumps [home-assistant/wheels](https://github.com/home-assistant/wheels) from 2025.12.0 to 2026.06.0.
- [Release notes](https://github.com/home-assistant/wheels/releases)
- [Commits](https://github.com/home-assistant/wheels/compare/e5742a69d69f0e274e2689c998900c7d19652c21...34957438948e0b3dcde73c77750643dadae594f5)

---
updated-dependencies:
- dependency-name: home-assistant/wheels
  dependency-version: 2026.06.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 06:10:38 +00:00
dependabot[bot] e91cffe27c Bump github/codeql-action from 4.36.0 to 4.36.2 (#52601)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.36.0 to 4.36.2.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/7211b7c8077ea37d8641b6271f6a365a22a5fbfa...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 06:10:18 +00:00
Sören Beye adbca5145c Fix energy graph stacking order (#52541) 2026-06-14 08:07:54 +02:00
Franck Nijhof 59d5ded6a5 Use Maps for registry lookups in automations picker (#52591)
The automations picker data builder resolved each automation's entity
registry entry by scanning the whole entity registry with find, and each
label by scanning the label registry, making both lookups scale with the
registry size for every row.

Build entity_id and label_id lookup Maps once and use them, so each row's
lookups are O(1). Behavior is unchanged.
2026-06-14 09:06:44 +03:00
Franck Nijhof daba5dd8be Retain option value type in select form field (#52594)
A config flow field guarded by vol.In([1, 5, 6]) sends numeric option
values, but the underlying select returns the chosen value as a string, and
ha-form-select forwarded that string unchanged. On submit the backend
validated "1" against [1, 5, 6] and rejected it as an invalid selection.

Map the selected value back to its original option in ha-form-select so the
source type is retained. String options are unaffected. The mapping is
extracted into matchSelectOptionValue and covered by unit tests.
2026-06-14 09:05:06 +03:00
Franck Nijhof 1e3e43ba46 Reduce per-entity work when building entity picker items (#52588)
getEntities builds the item list over every entity each time an entity
picker, the quick bar, or a target picker opens. On a large installation
that list construction did avoidable per-entity work:

- the include/exclude entity and domain filters used Array.includes,
  a linear scan per entity (O(entities x filter));
- computeRTL was recomputed for every entity although it only depends on
  the language and translation metadata;
- domainToName (a localize lookup) was called for every entity even though
  domains repeat heavily across entities.

Use Sets for the filters, hoist computeRTL out of the map, and cache the
domain name per domain. This speeds up opening pickers on large installs.
Behavior is unchanged. Add tests for getEntities, which had none.
2026-06-14 08:04:29 +02:00
Franck Nijhof e4cc1eaad2 Avoid mutating the input array in computeCssVariable (#52584)
computeCssVariable built its nested var() fallback chain with
props.reverse(), which reverses the caller's array in place. Every current
caller passes a freshly built array so there is no visible corruption
today, but mutating an input parameter is a latent trap: a shared,
memoized, or frozen array would produce wrong output or throw.

Use reduceRight to build the same chain from last to first without
mutating or copying the array. Output is unchanged. Add tests for
computeCssVariable (including a no-mutation guarantee) and computeCssValue,
which previously had no coverage.
2026-06-14 08:03:11 +02:00
Franck Nijhof 9aa687577f Use a Map for device label lookup in devices dashboard (#52590)
The devices dashboard data builder looked up each device label by scanning
the label registry with find, making it O(devices x labels x labelReg).

Build a label_id to entry Map once and use it for the lookups. Behavior is
unchanged.
2026-06-14 08:02:15 +02:00
Franck Nijhof 2556707370 Use a Set for entity-source lookup in helpers panel (#52587)
While the helpers configuration table is open, willUpdate recomputes the
helper entity list on every state change by filtering all states. The
membership test used Array.includes against the entity-source keys, a
linear scan run for every state, making the filter O(states x sources)
per state update.

Build a Set of the entity-source ids once and use Set.has for O(1)
lookups, dropping the filter to O(states + sources). Behavior is
unchanged.
2026-06-14 08:01:36 +02:00
Franck Nijhof 854c57c0e0 Prevent error logger from crashing on unparseable stack traces (#52575)
When createLogMessage received an error whose stack stacktrace-js could
not parse (for example a DOMException such as "AbortError: Transition was
skipped"), fromError threw "Cannot parse given Error object". That throw
escaped createLogMessage, so the global unhandledrejection handler logged
"Failure writing unhandled promise rejection to system log" and the
original error was never recorded.

Wrap the stacktrace extraction in a try/catch and fall back to the raw
error stack (or the provided stack fallback) so the logger stays robust
and still records the original error.
2026-06-14 09:01:31 +03:00
Franck Nijhof 055076c45e Use singular verb in zone condition summary (#52598)
The zone condition summary joins multiple entities with "or", so "If A or B
are in zone X" is grammatically wrong in English; "or" takes singular
agreement: "If A or B is in zone X".

Make both plural branches render "is" while keeping the numberOfEntities
placeholder, so it stays visible in the source language and translators can
keep a real plural where their grammar needs it.
2026-06-14 07:39:30 +02:00
Franck Nijhof d4ec72006d Use singular verb in numeric state trigger summary (#52592)
The numeric state trigger summary joins multiple entities with "or" but
switched the English verb to plural, reading "If A or B are above X". With
"or" the trigger fires when any one entity crosses the threshold, so English
uses singular agreement: "If A or B is above X".

Make both plural branches render "is" in the three English numeric_state
trigger description strings. The numberOfEntities placeholder is kept (not
replaced by a static "is") so it stays visible in the source language and
translators can keep using plural agreement where their grammar needs it.
The numeric_state condition is unchanged: it joins entities with "and" and
requires all to match.
2026-06-13 21:12:16 +02:00
renovate[bot] 393d6a8a0a Update rspack monorepo to v2.0.8 (#52595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-13 20:42:51 +02:00
Franck Nijhof 4a030884f5 Skip entities table list rebuild on plain state changes (#52586)
While the entities configuration table is open, willUpdate rebuilt the
list of entities without a unique id on every hass update. Because each
state update produces a new states object, the oldHass.states !==
this.hass.states guard was always true, so on every state tick the panel
allocated a Set over all registry entities, iterated every state, and built
StateEntity objects, then discarded the result unless a non-registry entity
had actually been added.

Detect a newly added entity up front and only enter the rebuild when the
set of entity ids could have changed (or a registry, entity-sources, or
exposed-entities dependency changed). A plain state value change on an
existing entity can no longer trigger the rebuild. Behavior is unchanged:
the inner assignment already only ran when an entity was added.
2026-06-13 17:13:09 +02:00
pcan08 f65596cad8 Align apps page search bar style with integrations page (#52581)
Align apps installed search bar style with integrations dashboard

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:09:53 +02:00
Franck Nijhof 1449c17fd1 Hoist per-call allocations out of computeStateDisplay (#52585)
computeStateToPartsFromEntityAttributes runs for essentially every entity
state that is rendered, but it rebuilt several constants on every call: a
3-element domain array, a 15-element timestamp-domain array (both only used
for an includes() check), and the monetary part-type map.

Move these to module-level Set/object constants (matching the existing
STATE_COLORED_DOMAIN pattern in state_color.ts) and use Set.has(). Behavior
is unchanged; the domain lists are now named and documented.
2026-06-13 17:01:41 +02:00
Franck Nijhof ce0e6a7665 Cache Intl.NumberFormat instances in formatNumber (#52583)
formatNumberToParts, the engine behind formatNumber, built a new
Intl.NumberFormat on every call. It is invoked for essentially every
numeric state render, and constructing an Intl.NumberFormat is
comparatively expensive.

Cache the formatters in a Map keyed by (locale, options). Unlike the
single-entry memoizeOne used for the date formatters, the number format
options are derived per value, so a multi-entry cache is needed to avoid
thrashing. The number of distinct combinations is small and bounded in
practice. Output is unchanged.
2026-06-13 17:01:33 +02:00
Franck Nijhof 460dace974 Speed up bulk selection in data table (#52589)
select() looked up each id with a find over all filtered rows and checked
membership with includes on the growing checked-rows array, making a large
batch selection O(rows x ids) plus O(ids squared).

Build a row lookup Map once and track membership with a Set, dropping the
batch selection to O(rows + ids). Behavior is unchanged.
2026-06-13 16:58:37 +02:00
Franck Nijhof 7111d8a8a8 Auto-select first voice in required TTS voice picker (#52576)
When a voice was required and no value was set, the picker displayed the
first voice in the dropdown but kept its own value undefined and never
fired a value-changed event. As a result, the parent (for example the TTS
test card in the media browser) never learned the voice: the selected
voice id footer stayed hidden and no voice was sent on synthesis. This was
most noticeable for languages with a single available voice, where the
selection could not be changed to force an event.

Auto-select and emit the first voice when one is required and the current
value is missing or no longer valid for the loaded voices, so the value
matches what the dropdown shows. Non-required usages keep clearing the
value as before.
2026-06-13 16:51:57 +02:00
Franck Nijhof b96d1f2809 Center the to-do list reorder drag handle (#52582)
The reorder handle lives in the mwc list item meta slot, which is a fixed
24px tall box. The handle's vertical padding of 16px made the icon 56px
tall, overflowing that box and pushing the drag icon visually below the
row center.

Remove the vertical padding so the 24px icon fits the 24px meta slot and
stays centered. The horizontal padding is kept unchanged.
2026-06-13 16:51:18 +02:00
Franck Nijhof 26bdff9a16 Fix clipboard fallback throwing when active element is in the light DOM (#52578)
When the async Clipboard API is unavailable or rejects, copyToClipboard
falls back to a hidden textarea and execCommand. It appended that textarea
to deepActiveElement()?.getRootNode(). When the deepest active element
lives in the light DOM, getRootNode() returns the Document node, and
appending an element to a Document throws HierarchyRequestError (only the
single documentElement is allowed), so the fallback copy silently failed.

Append to document.body when the resolved root is a Document. Shadow roots
and elements keep holding the textarea directly, preserving execCommand
behavior inside dialogs that trap focus.
2026-06-13 11:14:57 +02:00
TheJulianJES 16ac66c1f8 Fix update entity progress text not showing when version is newer (#52579)
* Show installing status for update entities regardless of state

An install can be in progress while the update entity state is "off"
(e.g. downgrading firmware, where installed_version is newer than
latest_version). The installing check was nested inside the state ===
"on" branch, so these entities incorrectly showed "Up-to-date" instead
of "Installing (xx%)". Hoist the in-progress check above the state
branching so it always takes precedence.

* Show installing icon for update entities regardless of state

When an install is in progress (e.g. downgrading firmware), the entity
state can be "off" while installing. Show the installing icon
(mdi:package-down) whenever an install is in progress, before the
state-based icon selection.

Note: for entities with a brand entity_picture (e.g. Zigbee/ZHA
firmware updates), state-badge hides the icon in favour of the picture,
so this only affects update entities without a brand image.
2026-06-13 09:14:12 +00:00
Aidan Timson 8533dd586b Migrate home panel and profile dialogs to dirty state provider and dialog behaviour (#52559)
Migrate home panel and profile dialogs to dirty state provider and dialog behavior
2026-06-12 21:13:54 +02:00
Jan-Philipp Benecke 2cfb947c9b Migrate ha-config-info to use ha-list-base (#52574) 2026-06-12 19:13:08 +00:00
Aidan Timson 466cf2dfb2 Migrate automation dialogs to dirty state provider and dialog behaviour (#52558)
Migrate automation dialogs to dirty state provider and dialog behavior
2026-06-12 21:10:23 +02:00
Jan-Philipp Benecke 193bcad917 Adjust scrolling container of ha-top-app-bar-fixed pages (#52571)
* Adjust scrolling container of ha-top-app-bar-fixed pages

* Adjust scrolling behavior in ha-drawer

* Revert ha-drawer
2026-06-12 20:56:50 +02:00
Stefan Agner 52d32aec42 Add built-in Matter logo icon (#52568)
Add the Matter symbol as a bundled custom icon, available as
mdi:matter. Like the existing ESPHome and Music Assistant logos, the
SVG path is lazy-loaded and rendered with the foreground color.
2026-06-12 20:53:59 +02:00
renovate[bot] 9adb7215ce Update html-eslint monorepo to v0.62.0 (#52573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 20:52:00 +02:00
renovate[bot] 273967fe70 Update dependency prettier to v3.8.4 (#52569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 19:20:28 +03:00
88 changed files with 1622 additions and 982 deletions
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: dev
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: master
persist-credentials: false
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -65,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -85,7 +85,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: dev
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: master
persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+4 -4
View File
@@ -26,7 +26,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -97,7 +97,7 @@ jobs:
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: cp314
tag: musllinux_1_2
@@ -113,7 +113,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+18 -1
View File
@@ -103,12 +103,29 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (!toProcess) {
console.error("Unknown category", group.category);
if (!group.pages) {
if (!group.subsections && !group.pages) {
group.pages = [];
}
continue;
}
if (group.subsections) {
// Listed pages keep their per-subsection order.
for (const subsection of group.subsections) {
for (const page of subsection.pages) {
if (!toProcess.delete(page)) {
console.error("Found unreferenced demo", page);
}
}
}
// Any remaining pages land in a trailing "Other" subsection.
const leftover = Array.from(toProcess).sort();
if (leftover.length) {
group.subsections.push({ header: "Other", pages: leftover });
}
continue;
}
// Any pre-defined groups will not be sorted.
if (group.pages) {
for (const page of group.pages) {
+11
View File
@@ -62,6 +62,17 @@ Use `sidebar.js` when a page needs a visible section, section header, or determi
- New categories without a sidebar entry are appended by the generator with their category name as the header.
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
### Subsections
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
- Listed pages keep their per-subsection order.
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
- Use sentence case for subsection headers and follow the content standards below.
## Markdown Pages
Use markdown pages for explanations, design guidance, API notes, and copy standards.
+164 -9
View File
@@ -10,6 +10,10 @@ import {
mdiViewDashboard,
} from "@mdi/js";
// A group may list its pages flat in `pages`, or group them under named
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
// any pages found in the category but not listed are appended alphabetically
// (to a generated "Other" subsection when the group uses subsections).
export default [
{
// This section has no header and so all page links are shown directly in the sidebar
@@ -27,31 +31,162 @@ export default [
category: "components",
icon: mdiPuzzle,
header: "Components",
subsections: [
{
header: "Form and selectors",
pages: [
"ha-form",
"ha-selector",
"ha-select-box",
"ha-input",
"ha-textarea",
],
},
{
header: "Controls and sliders",
pages: [
"ha-button",
"ha-control-button",
"ha-progress-button",
"ha-switch",
"ha-control-switch",
"ha-slider",
"ha-control-slider",
"ha-control-circular-slider",
"ha-control-number-buttons",
"ha-control-select",
"ha-control-select-menu",
"ha-hs-color-picker",
],
},
{
header: "Overlays",
pages: [
"ha-dialog",
"ha-dialogs",
"ha-adaptive-dialog",
"ha-adaptive-popover",
"ha-dropdown",
"ha-tooltip",
],
},
{
header: "Lists and disclosure",
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
},
{
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
},
],
},
{
category: "lovelace",
icon: mdiViewDashboard,
// Label for in the sidebar
header: "Dashboards",
// Specify order of pages. Any pages in the category folder but not listed here will
// automatically be added after the pages listed here.
pages: ["introduction"],
subsections: [
{
header: "Introduction",
pages: ["introduction"],
},
{
header: "Entity cards",
pages: [
"entities-card",
"entity-button-card",
"entity-filter-card",
"glance-card",
"tile-card",
"area-card",
],
},
{
header: "Picture cards",
pages: [
"picture-card",
"picture-elements-card",
"picture-entity-card",
"picture-glance-card",
],
},
{
header: "Domain cards",
pages: [
"light-card",
"thermostat-card",
"alarm-panel-card",
"gauge-card",
"plant-card",
"map-card",
"media-control-card",
"media-player-row",
],
},
{
header: "Layout and utility",
pages: [
"grid-and-stack-card",
"conditional-card",
"iframe-card",
"markdown-card",
"todo-list-card",
],
},
],
},
{
category: "more-info",
icon: mdiInformationOutline,
header: "More Info dialogs",
subsections: [
{
header: "Climate and water",
pages: ["climate", "humidifier", "water-heater", "fan"],
},
{
header: "Covers and access",
pages: ["cover", "lock", "lawn-mower", "vacuum"],
},
{
header: "Lighting",
pages: ["light", "scene"],
},
{
header: "Media",
pages: ["media-player"],
},
{
header: "Inputs and values",
pages: ["input-number", "input-text", "number", "timer"],
},
{
header: "System",
pages: ["update"],
},
],
},
{
category: "automation",
icon: mdiRobot,
header: "Automation",
pages: [
"editor-trigger",
"editor-condition",
"editor-action",
"trace",
"trace-timeline",
subsections: [
{
header: "Editors",
pages: ["editor-trigger", "editor-condition", "editor-action"],
},
{
header: "Descriptions",
pages: ["describe-trigger", "describe-condition", "describe-action"],
},
{
header: "Traces",
pages: ["trace", "trace-timeline"],
},
],
},
{
@@ -64,6 +199,26 @@ export default [
category: "date-time",
icon: mdiCalendarClock,
header: "Date and Time",
subsections: [
{
header: "Date",
pages: ["date"],
},
{
header: "Time",
pages: ["time", "time-seconds", "time-weekday"],
},
{
header: "Combined",
pages: [
"date-time",
"date-time-numeric",
"date-time-seconds",
"date-time-short",
"date-time-short-year",
],
},
],
},
{
category: "misc",
+60 -20
View File
@@ -40,15 +40,26 @@ interface GalleryPage {
demo?: unknown;
}
interface GallerySidebarSubsection {
header: string;
pages: string[];
}
interface GallerySidebarGroup {
category: string;
header?: string;
icon?: string;
pages: string[];
pages?: string[];
subsections?: GallerySidebarSubsection[];
}
const groupPages = (group: GallerySidebarGroup): string[] =>
group.subsections
? group.subsections.flatMap((subsection) => subsection.pages)
: (group.pages ?? []);
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
const mql = matchMedia("(prefers-color-scheme: dark)");
@@ -284,26 +295,15 @@ class HaGallery extends LitElement {
const sidebar: unknown[] = [];
for (const group of GALLERY_SIDEBAR) {
const links: unknown[] = [];
const expanded = group.pages.some(
const expanded = groupPages(group).some(
(page) => this._page === `${group.category}/${page}`
);
for (const page of group.pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
const content = group.subsections
? group.subsections.map((subsection) =>
this._renderSidebarSubsection(group, subsection)
)
);
}
: this._renderPageLinks(group, group.pages ?? []);
sidebar.push(
group.header
@@ -321,16 +321,46 @@ class HaGallery extends LitElement {
.path=${group.icon}
></ha-svg-icon>`
: nothing}
${links}
${content}
</ha-expansion-panel>
`
: links
: content
);
}
return sidebar;
}
private _renderSidebarSubsection(
group: GallerySidebarGroup,
subsection: GallerySidebarSubsection
) {
return html`
<div class="gallery-sidebar-subheader">${subsection.header}</div>
${this._renderPageLinks(group, subsection.pages)}
`;
}
private _renderPageLinks(group: GallerySidebarGroup, pages: string[]) {
const links: unknown[] = [];
for (const page of pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
)
);
}
return links;
}
private _renderPageLink(
page: string,
title: string,
@@ -585,6 +615,16 @@ class HaGallery extends LitElement {
width: var(--ha-sidebar-expanded-section-item-width, 248px);
}
.gallery-sidebar-subheader {
margin: var(--ha-space-2) var(--ha-space-4) var(--ha-space-1);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.gallery-sidebar-icon,
.gallery-nav-item ha-svg-icon[slot="start"] {
color: var(--sidebar-icon-color);
+5 -5
View File
@@ -40,7 +40,7 @@
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.8",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-getcanonicallocales": "3.2.10",
@@ -131,13 +131,13 @@
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.61.0",
"@html-eslint/eslint-plugin": "0.62.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.12",
"@rspack/core": "2.0.6",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -186,7 +186,7 @@
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.3",
"prettier": "3.8.4",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
+1 -2
View File
@@ -1,7 +1,7 @@
// Load a resource and get a promise when loading done.
// From: https://davidwalsh.name/javascript-loader
const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
const _load = (tag: "link" | "script", url: string, type?: "module") =>
// This promise will be used by Promise.all to determine success or failure
new Promise((resolve, reject) => {
const element = document.createElement(tag);
@@ -33,5 +33,4 @@ const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
});
export const loadCSS = (url: string) => _load("link", url);
export const loadJS = (url: string) => _load("script", url);
export const loadImg = (url: string) => _load("img", url);
export const loadModule = (url: string) => _load("script", url, "module");
-41
View File
@@ -1,41 +0,0 @@
/**
* Scroll to a specific y coordinate.
*
* Copied from paper-scroll-header-panel.
*
* @method scroll
* @param {number} top The coordinate to scroll to, along the y-axis.
* @param {boolean} smooth true if the scroll position should be smoothly adjusted.
*/
export default function scrollToTarget(element, target) {
// the scroll event will trigger _updateScrollState directly,
// However, _updateScrollState relies on the previous `scrollTop` to update the states.
// Calling _updateScrollState will ensure that the states are synced correctly.
const top = 0;
const scroller = target;
const easingFn = function easeOutQuad(t, b, c, d) {
t /= d;
return -c * t * (t - 2) + b;
};
const animationId = Math.random();
const duration = 200;
const startTime = Date.now();
const currentScrollTop = scroller.scrollTop;
const deltaScrollTop = top - currentScrollTop;
element._currentAnimationId = animationId;
(function updateFrame() {
const now = Date.now();
const elapsedTime = now - startTime;
if (elapsedTime > duration) {
scroller.scrollTop = top;
} else if (element._currentAnimationId === animationId) {
scroller.scrollTop = easingFn(
elapsedTime,
currentScrollTop,
deltaScrollTop,
duration
);
requestAnimationFrame(updateFrame.bind(element));
}
}).call(element);
}
-13
View File
@@ -3,8 +3,6 @@ import type { Map, TileLayer } from "leaflet";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement,
@@ -45,17 +43,6 @@ export const setupLeafletMap = async (
return [map, Leaflet, tileLayer];
};
export const replaceTileLayer = (
leaflet: LeafletModuleType,
map: Map,
tileLayer: TileLayer
): TileLayer => {
map.removeLayer(tileLayer);
tileLayer = createTileLayer(leaflet);
tileLayer.addTo(map);
return tileLayer;
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
-3
View File
@@ -1,3 +0,0 @@
/** An empty image which can be set as src of an img element. */
export const emptyImageBase64 =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+37 -30
View File
@@ -19,6 +19,40 @@ import type { LocalizeFunc } from "../translations/localize";
import { computeDomain } from "./compute_domain";
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
// 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",
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
export const computeStateDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
@@ -138,21 +172,10 @@ const computeStateToPartsFromEntityAttributes = (
}
if (parts.length) {
const TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
const valueParts: ValuePart[] = [];
for (const part of parts) {
const type = TYPE_MAP[part.type];
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")
@@ -191,7 +214,7 @@ const computeStateToPartsFromEntityAttributes = (
return [{ type: "value", value: value }];
}
if (["date", "input_datetime", "time"].includes(domain)) {
if (DATE_TIME_DOMAINS.has(domain)) {
// If trying to display an explicit state, need to parse the explicit state to `Date` then format.
// Attributes aren't available, we have to use `state`.
@@ -250,23 +273,7 @@ const computeStateToPartsFromEntityAttributes = (
// state is a timestamp
if (
[
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
].includes(domain) ||
TIMESTAMP_DOMAINS.has(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
) {
+6 -5
View File
@@ -4,9 +4,10 @@ import { updateIsInstalling } from "../../data/update";
export const updateIcon = (stateObj: HassEntity, state?: string) => {
const compareState = state ?? stateObj.state;
return compareState === "on"
? updateIsInstalling(stateObj as UpdateEntity)
? "mdi:package-down"
: "mdi:package-up"
: "mdi:package";
// An install can be in progress even when the state is "off", e.g. when
// downgrading firmware. Show the installing icon regardless of state.
if (updateIsInstalling(stateObj as UpdateEntity)) {
return "mdi:package-down";
}
return compareState === "on" ? "mdi:package-up" : "mdi:package";
};
+21 -2
View File
@@ -40,6 +40,25 @@ export const numberFormatToLocale = (
}
};
// Constructing an Intl.NumberFormat is comparatively expensive, and these
// formatters are created on every numeric state render. The number of distinct
// (locale, options) combinations is small and bounded in practice, so cache the
// instances instead of rebuilding them on every call.
const numberFormatCache = new Map<string, Intl.NumberFormat>();
const getNumberFormatter = (
locale: string | string[] | undefined,
options: Intl.NumberFormatOptions
): Intl.NumberFormat => {
const key = JSON.stringify([locale, options]);
let formatter = numberFormatCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, options);
numberFormatCache.set(key, formatter);
}
return formatter;
};
/**
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
*
@@ -75,7 +94,7 @@ export const formatNumberToParts = (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num))
) {
return new Intl.NumberFormat(
return getNumberFormatter(
locale,
getDefaultFormatOptions(num, options)
).formatToParts(Number(num));
@@ -87,7 +106,7 @@ export const formatNumberToParts = (
localeOptions?.number_format === NumberFormat.none
) {
// If NumberFormat is none, use en-US format without grouping.
return new Intl.NumberFormat(
return getNumberFormatter(
"en-US",
getDefaultFormatOptions(num, {
...options,
+8 -2
View File
@@ -11,6 +11,12 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
}
const root = rootEl || deepActiveElement()?.getRootNode() || document.body;
// A document node cannot have a textarea appended directly (only the single
// documentElement is allowed), so fall back to its body. Shadow roots and
// elements can hold the textarea directly, which keeps execCommand working
// inside dialogs that trap focus.
const container: Node =
root.nodeType === Node.DOCUMENT_NODE ? document.body : root;
const el = document.createElement("textarea");
el.value = str;
@@ -19,8 +25,8 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
el.style.top = "0";
el.style.left = "0";
el.style.opacity = "0";
root.appendChild(el);
container.appendChild(el);
el.select();
document.execCommand("copy");
root.removeChild(el);
container.removeChild(el);
};
+14 -4
View File
@@ -394,6 +394,18 @@ export class HaChartBase extends LitElement {
return nothing;
}
const datasets = ensureArray(this.data!);
// Index datasets by id and name so each legend item is an O(1) lookup
// instead of scanning every dataset twice. Charts can have many series.
const datasetById = new Map<unknown, (typeof datasets)[number]>();
const datasetByName = new Map<unknown, (typeof datasets)[number]>();
for (const dataset of datasets) {
if (dataset.id !== undefined && !datasetById.has(dataset.id)) {
datasetById.set(dataset.id, dataset);
}
if (dataset.name !== undefined && !datasetByName.has(dataset.name)) {
datasetByName.set(dataset.name, dataset);
}
}
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -413,10 +425,10 @@ export class HaChartBase extends LitElement {
return nothing;
}
let itemStyle: Record<string, any> = {};
let id = "";
let value = "";
let noLabelClick = false;
const name = typeof item === "string" ? item : (item.name ?? "");
let id: string;
if (typeof item === "string") {
id = item;
} else {
@@ -426,9 +438,7 @@ export class HaChartBase extends LitElement {
noLabelClick = item.noLabelClick ?? false;
}
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
const dataset =
datasets.find((d) => d.id === id) ??
datasets.find((d) => d.name === id);
const dataset = datasetById.get(id) ?? datasetByName.get(id);
itemStyle = {
color: dataset?.color as string,
...(dataset?.itemStyle as { borderColor?: string }),
@@ -147,6 +147,14 @@ export class StateHistoryChartLine extends LitElement {
this.hass.config
);
const datapoints: Record<string, any>[] = [];
// Index the hovered points by series so the per-dataset lookup below is
// O(1) instead of scanning `params` for every dataset on each mouse move.
const paramsBySeriesIndex = new Map<number, Record<string, any>>();
for (const p of params) {
if (!paramsBySeriesIndex.has(p.seriesIndex)) {
paramsBySeriesIndex.set(p.seriesIndex, p);
}
}
this._chartData.forEach((dataset, index) => {
if (
dataset.tooltip?.show === false ||
@@ -154,9 +162,7 @@ export class StateHistoryChartLine extends LitElement {
) {
return;
}
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
const param = paramsBySeriesIndex.get(index);
if (param) {
datapoints.push(param);
return;
@@ -440,6 +446,10 @@ export class StateHistoryChartLine extends LitElement {
this._chartTime = new Date();
const endTime = this.endTime;
// Work with numeric epoch timestamps (ms) instead of Date objects below.
// Charts can hold a huge number of points, and allocating a Date per point
// is needless GC pressure; the "time" axis consumes numbers natively.
const endTimeMs = endTime.getTime();
const names = this.names || {};
const colors = this.colors || {};
entityStates.forEach((states, dataIdx) => {
@@ -451,9 +461,9 @@ export class StateHistoryChartLine extends LitElement {
const data: LineSeriesOption[] = [];
const pushData = (timestamp: Date, datavalues: any[] | null) => {
const pushData = (timestamp: number, datavalues: any[] | null) => {
if (!datavalues) return;
if (timestamp > endTime) {
if (timestamp > endTimeMs) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
@@ -624,11 +634,11 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.target_temp_low
);
series.push(targetHigh, targetLow);
pushData(new Date(entityState.last_changed), series);
pushData(entityState.last_changed, series);
} else {
const target = safeParseFloat(entityState.attributes.temperature);
series.push(target);
pushData(new Date(entityState.last_changed), series);
pushData(entityState.last_changed, series);
}
});
} else if (domain === "humidifier") {
@@ -746,31 +756,27 @@ export class StateHistoryChartLine extends LitElement {
} else {
series.push(entityState.state === "on" ? current : null);
}
pushData(new Date(entityState.last_changed), series);
pushData(entityState.last_changed, series);
});
} else {
addDataSet(states.entity_id, name, color);
let lastValue: number;
let lastDate: Date;
let lastNullDate: Date | null = null;
let lastDate: number;
let lastNullDate: number | null = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
const processData = (entityState: LineChartState) => {
const value = safeParseFloat(entityState.state);
const date = new Date(entityState.last_changed);
const date = entityState.last_changed;
if (value !== null && lastNullDate) {
const dateTime = date.getTime();
const lastNullDateTime = lastNullDate.getTime();
const lastDateTime = lastDate?.getTime();
const tmpValue =
(value - lastValue) *
((lastNullDateTime - lastDateTime) /
(dateTime - lastDateTime)) +
((lastNullDate - lastDate) / (date - lastDate)) +
lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(new Date(lastNullDateTime + 1), [null]);
pushData(lastNullDate + 1, [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
@@ -809,17 +815,17 @@ export class StateHistoryChartLine extends LitElement {
}
// Add an entry for final values
pushData(endTime, prevValues);
pushData(endTimeMs, prevValues);
// For sensors, append current state if viewing recent data
const now = new Date();
const nowMs = Date.now();
// allow 1s of leeway for "now"
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
const isUpToNow = nowMs - endTimeMs <= 1000;
if (domain === "sensor" && isUpToNow && data.length === 1) {
const stateObj = this.hass.states[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([now, currentValue]);
data[0].data!.push([nowMs, currentValue]);
trackY(currentValue);
}
}
+8 -2
View File
@@ -215,10 +215,16 @@ export class HaDataTable extends LitElement {
if (clear) {
this._checkedRows = [];
}
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
const rowLookup = new Map(
(this._filteredData || []).map((data) => [data[this.id], data])
);
const checkedRows = new Set(this._checkedRows);
ids.forEach((id) => {
const row = this._filteredData?.find((data) => data[this.id] === id);
if (row?.selectable !== false && !this._checkedRows.includes(id)) {
const row = rowLookup.get(id);
if (row?.selectable !== false && !checkedRows.has(id)) {
this._checkedRows.push(id);
checkedRows.add(id);
}
});
this._lastSelectedRowId = null;
+1
View File
@@ -183,6 +183,7 @@ export class HaControlSelectMenu extends LitElement {
gap: 10px;
width: 100%;
user-select: none;
font-family: var(--ha-font-family-body, inherit);
font-style: normal;
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.25px;
+21 -5
View File
@@ -12,6 +12,20 @@ import type {
HaFormSelectSchema,
} from "./types";
/**
* The underlying select returns option values as strings. Map a selected value
* back to its original option value so the source type is retained (for example
* a number coming from a backend `vol.In` schema), falling back to the value
* itself when no option matches.
*/
export const matchSelectOptionValue = (
options: HaFormSelectSchema["options"],
value: string
): HaFormSelectData => {
const option = options.find((opt) => String(opt[0]) === String(value));
return option ? option[0] : value;
};
@customElement("ha-form-select")
export class HaFormSelect extends LitElement implements HaFormElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -66,14 +80,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
let value: string | undefined = ev.detail.value;
if (value === this.data) {
return;
}
let value: HaFormSelectData | undefined = ev.detail.value;
if (value === "") {
value = undefined;
} else if (value != null) {
value = matchSelectOptionValue(this.schema.options, value);
}
if (value === this.data) {
return;
}
fireEvent(this, "value-changed", {
+2
View File
@@ -41,6 +41,8 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
),
esphome: () =>
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
matter: () =>
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
};
@customElement("ha-icon")
@@ -354,7 +354,9 @@ export class HaSerialPortSelector extends LitElement {
}
private get _selectorDomain(): string | undefined {
return this.context?.handler;
// `domain` is the integration domain even in options flows, where the flow
// handler is the config entry id instead.
return this.context?.domain;
}
private _memoRecommendedDomains = memoizeOne(
+22 -22
View File
@@ -3,6 +3,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../common/navigate";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-icon-button-arrow-prev";
import "./ha-menu-button";
@@ -11,6 +12,9 @@ const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
export const haTopAppBarFixedStyles = css`
:host {
display: block;
position: relative;
height: 100vh;
overflow: hidden;
--total-top-app-bar-height: calc(
var(--header-height, 0px) + var(--sub-row-height, 0px)
);
@@ -21,10 +25,11 @@ export const haTopAppBarFixedStyles = css`
box-sizing: border-box;
color: var(--app-header-text-color, #fff);
background-color: var(--app-header-background-color, var(--primary-color));
position: fixed;
position: absolute;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
width: var(--ha-top-app-bar-width, 100%);
width: 100%;
z-index: 4;
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
@@ -116,17 +121,17 @@ export const haTopAppBarFixedStyles = css`
}
.top-app-bar-fixed-adjust {
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
padding-top: calc(
box-sizing: border-box;
position: absolute;
top: calc(
var(--total-top-app-bar-height, 0px) + var(--safe-area-inset-top, 0px)
);
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
overflow: auto;
}
:host([narrow]) .top-app-bar-fixed-adjust {
@@ -146,6 +151,8 @@ export class HaTopAppBarFixed extends LitElement {
@query(".sub-row") protected _subRowElement?: HTMLElement;
@query(".top-app-bar-fixed-adjust") protected _scrollElement?: HTMLElement;
@state() private _hasSubRow = false;
private _scrollTarget?: HTMLElement | Window;
@@ -154,14 +161,13 @@ export class HaTopAppBarFixed extends LitElement {
@property({ attribute: false })
public get scrollTarget(): HTMLElement | Window {
return this._scrollTarget || window;
return this._scrollTarget || this._scrollElement || window;
}
public set scrollTarget(value: HTMLElement | Window) {
const old = this.scrollTarget;
this._unregisterListeners();
this._scrollTarget = value;
this._updateBarPosition();
this.requestUpdate("scrollTarget", old);
if (this.isConnected) {
this._registerListeners();
@@ -183,7 +189,6 @@ export class HaTopAppBarFixed extends LitElement {
if (this.hasUpdated) {
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -243,7 +248,7 @@ export class HaTopAppBarFixed extends LitElement {
}
protected _renderContent() {
return html`<div class="top-app-bar-fixed-adjust">
return html`<div class="top-app-bar-fixed-adjust ha-scrollbar">
<slot></slot>
</div>`;
}
@@ -252,7 +257,6 @@ export class HaTopAppBarFixed extends LitElement {
super.firstUpdated(changedProperties);
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -270,13 +274,6 @@ export class HaTopAppBarFixed extends LitElement {
this._unregisterListeners();
}
protected _updateBarPosition() {
if (this._barElement) {
this._barElement.style.position =
this.scrollTarget === window ? "" : "absolute";
}
}
protected _syncScrollState = () => {
const scrollTop =
this.scrollTarget instanceof Window
@@ -336,7 +333,10 @@ export class HaTopAppBarFixed extends LitElement {
this.style.setProperty("--sub-row-height", `${subRowHeight}px`);
};
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
static override styles: CSSResultGroup = [
haStyleScrollbar,
haTopAppBarFixedStyles,
];
}
declare global {
+16 -6
View File
@@ -85,15 +85,25 @@ export class HaTTSVoicePicker extends LitElement {
await listTTSVoices(this.hass, this.engineId, this.language)
).voices;
if (!this.value) {
const valueIsValid =
this.value &&
this._voices?.some((voice) => voice.voice_id === this.value);
if (valueIsValid) {
return;
}
if (
!this._voices ||
!this._voices.find((voice) => voice.voice_id === this.value)
) {
this.value = undefined;
// The current value is missing or no longer valid for the loaded voices.
// When a voice is required, auto-select the first one (the <ha-select>
// already displays it) so the value is propagated to the parent;
// otherwise clear it.
const newValue =
this.required && this._voices?.length
? this._voices[0].voice_id
: undefined;
if (newValue !== this.value) {
this.value = newValue;
fireEvent(this, "value-changed", { value: this.value });
}
}
@@ -29,6 +29,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
<div
class=${classMap({
"top-app-bar-fixed-adjust": true,
"ha-scrollbar": true,
"top-app-bar-fixed-adjust--pane": this.pane,
})}
>
@@ -130,12 +131,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
.top-app-bar-fixed-adjust--pane {
display: flex;
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
overflow: hidden;
}
.pane {
@@ -167,6 +163,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
position: relative;
flex: 1;
height: 100%;
min-width: 0;
}
.top-app-bar-fixed-adjust--pane .content {
-13
View File
@@ -1,13 +0,0 @@
import timezones from "google-timezones-json";
export const createTimezoneListEl = () => {
const list = document.createElement("datalist");
list.id = "timezones";
Object.keys(timezones).forEach((key) => {
const option = document.createElement("option");
option.value = key;
option.innerText = timezones[key];
list.appendChild(option);
});
return list;
};
+24 -10
View File
@@ -73,30 +73,44 @@ export const getEntities = (
let entityIds = Object.keys(hass.states);
// These run over every entity, so use Sets for O(1) membership instead of
// repeated Array.includes scans.
if (includeEntities) {
const includeEntitiesSet = new Set(includeEntities);
entityIds = entityIds.filter((entityId) =>
includeEntities.includes(entityId)
includeEntitiesSet.has(entityId)
);
}
if (excludeEntities) {
const excludeEntitiesSet = new Set(excludeEntities);
entityIds = entityIds.filter(
(entityId) => !excludeEntities.includes(entityId)
(entityId) => !excludeEntitiesSet.has(entityId)
);
}
if (includeDomains) {
const includeDomainsSet = new Set(includeDomains);
entityIds = entityIds.filter((eid) =>
includeDomains.includes(computeDomain(eid))
includeDomainsSet.has(computeDomain(eid))
);
}
if (excludeDomains) {
const excludeDomainsSet = new Set(excludeDomains);
entityIds = entityIds.filter(
(eid) => !excludeDomains.includes(computeDomain(eid))
(eid) => !excludeDomainsSet.has(computeDomain(eid))
);
}
// These values are the same for every entity, so compute them once instead
// of inside the map over (potentially thousands of) entities.
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domainNames = new Map<string, string>();
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
@@ -110,12 +124,12 @@ export const getEntities = (
hass.floors
);
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
domainName = domainToName(hass.localize, domain);
domainNames.set(domain, domainName);
}
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
-13
View File
@@ -44,15 +44,6 @@ interface AddonTranslations {
configuration?: Record<string, AddonFieldTranslation>;
}
export interface AddonNetworkIsolationParams {
interface: string;
ipv4: string;
}
export interface AddonNetworkIsolation extends AddonNetworkIsolationParams {
driver: "macvlan";
}
export interface HassioAddonInfo {
advanced: boolean;
available: boolean;
@@ -109,9 +100,6 @@ export interface HassioAddonDetails extends HassioAddonInfo {
long_description: null | string;
machine: any;
network_description: null | Record<string, string>;
network_isolation: AddonNetworkIsolation | null;
network_isolation_available: boolean;
network_isolation_mac: string | null;
network: null | Record<string, number>;
options: Record<string, unknown>;
privileged: any;
@@ -155,7 +143,6 @@ export interface HassioAddonSetOptionParams {
auto_update?: boolean;
ingress_panel?: boolean;
network?: Record<string, unknown> | null;
network_isolation?: AddonNetworkIsolationParams | null;
watchdog?: boolean;
}
-1
View File
@@ -19,7 +19,6 @@ export interface NetworkInterface {
ipv6?: Partial<IpConfiguration>;
type: "ethernet" | "wireless" | "vlan";
wifi?: Partial<WifiConfiguration> | null;
network_isolation_capable?: boolean;
}
export interface DockerNetwork {
+8 -6
View File
@@ -725,16 +725,18 @@ export const mergeHistoryResults = (
}
const newLineItem: LineChartUnit = { ...historyItem, data: [] };
const historyDataByEntity = new Map(
historyItem.data.map((d) => [d.entity_id, d])
);
const ltsDataByEntity = new Map(ltsItem.data.map((d) => [d.entity_id, d]));
const entities = new Set([
...historyItem.data.map((d) => d.entity_id),
...ltsItem.data.map((d) => d.entity_id),
...historyDataByEntity.keys(),
...ltsDataByEntity.keys(),
]);
for (const entity of entities) {
const historyDataItem = historyItem.data.find(
(d) => d.entity_id === entity
);
const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity);
const historyDataItem = historyDataByEntity.get(entity);
const ltsDataItem = ltsDataByEntity.get(entity);
if (!historyDataItem || !ltsDataItem) {
newLineItem.data.push(historyDataItem || ltsDataItem!);
-7
View File
@@ -43,11 +43,6 @@ export const lightSupportsColorMode = (
mode: LightColorMode
) => entity.attributes.supported_color_modes?.includes(mode) || false;
export const lightIsInColorMode = (entity: LightEntity) =>
(entity.attributes.color_mode &&
modesSupportingColor.includes(entity.attributes.color_mode)) ||
false;
export const lightSupportsColor = (entity: LightEntity) =>
entity.attributes.supported_color_modes?.some((mode) =>
modesSupportingColor.includes(mode)
@@ -159,5 +154,3 @@ export const computeDefaultFavoriteColors = (
return colors;
};
export const formatTempColor = (value: number) => `${value} K`;
+18 -17
View File
@@ -231,6 +231,24 @@ export const computeUpdateStateDisplay = (
const state = stateObj.state;
const attributes = stateObj.attributes;
// An install can be in progress even when the state is "off", e.g. when
// downgrading firmware (installed_version is newer than latest_version).
// Show the installing status regardless of state in that case.
if (updateIsInstalling(stateObj)) {
const supportsProgress =
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
if (supportsProgress) {
return hass.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, hass.locale, {
maximumFractionDigits: attributes.display_precision,
minimumFractionDigits: attributes.display_precision,
}),
});
}
return hass.localize("ui.card.update.installing");
}
if (state === "off") {
const isSkipped =
attributes.latest_version &&
@@ -241,23 +259,6 @@ export const computeUpdateStateDisplay = (
return hass.formatEntityState(stateObj);
}
if (state === "on") {
if (updateIsInstalling(stateObj)) {
const supportsProgress =
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
if (supportsProgress) {
return hass.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, hass.locale, {
maximumFractionDigits: attributes.display_precision,
minimumFractionDigits: attributes.display_precision,
}),
});
}
return hass.localize("ui.card.update.installing");
}
}
return hass.formatEntityState(stateObj);
};
@@ -403,6 +403,7 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.domain=${this._params.domain ?? this._step.handler}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
></step-flow-form>
+5 -1
View File
@@ -35,6 +35,10 @@ class StepFlowForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// The integration domain this flow belongs to. Unlike `step.handler`, this is
// the domain even for options flows (where the handler is the config entry id).
@property({ attribute: false }) public domain?: string;
@state() private _loading = false;
@state() private _stepData?: Record<string, any>;
@@ -108,7 +112,7 @@ class StepFlowForm extends LitElement {
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
.context=${{ handler: step.handler, domain: this.domain }}
></ha-form>`
: nothing}
</div>
@@ -1103,6 +1103,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
.title .breadcrumb {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
font-family: var(--ha-font-family-heading, inherit);
line-height: 16px;
--mdc-icon-size: 16px;
padding: var(--ha-space-1);
+17 -5
View File
@@ -22,6 +22,7 @@ interface EntityInfo {
entityId: string;
entityName: string | undefined;
areaId: string | undefined;
deviceId: string | undefined;
}
@customElement("more-info-content")
@@ -120,7 +121,7 @@ class MoreInfoContent extends LitElement {
hass.entities,
hass.devices
);
const { area } = getEntityContext(
const { area, device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
@@ -128,7 +129,8 @@ class MoreInfoContent extends LitElement {
hass.floors
);
const areaId = area?.area_id;
return { entityId, entityName, areaId };
const deviceId = device?.id;
return { entityId, entityName, areaId, deviceId };
})
.filter(Boolean) as EntityInfo[];
@@ -140,10 +142,20 @@ class MoreInfoContent extends LitElement {
const areaIds = new Set(entityInfos.map((info) => info.areaId));
const allSameArea = areaIds.size === 1;
// Build name and state content config based on conditions
const name: EntityNameItem[] = [{ type: "device" }];
// Check if all entities belong to the same device
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
const allSameDevice = deviceIds.size === 1;
if (!allSameEntityName) {
// Build name and state content config based on conditions. The device name
// is redundant when every member belongs to the same device, so omit it
// (and fall back to the entity name so the tile still has a label).
const name: EntityNameItem[] = [];
if (!allSameDevice) {
name.push({ type: "device" });
}
if (!allSameEntityName || allSameDevice) {
name.push({ type: "entity" });
}
@@ -29,13 +29,11 @@ class SupervisorAppConfigDashboard extends LitElement {
const hasConfiguration =
(this.addon.options && Object.keys(this.addon.options).length) ||
(this.addon.schema && Object.keys(this.addon.schema).length);
const hasNetwork =
this.addon.network || this.addon.network_isolation_available;
return html`
<div class="content">
${this.addon.system_managed &&
(hasConfiguration || hasNetwork || this.addon.audio)
(hasConfiguration || this.addon.network || this.addon.audio)
? html`
<supervisor-app-system-managed
.hass=${this.hass}
@@ -44,7 +42,7 @@ class SupervisorAppConfigDashboard extends LitElement {
></supervisor-app-system-managed>
`
: nothing}
${hasConfiguration || hasNetwork || this.addon.audio
${hasConfiguration || this.addon.network || this.addon.audio
? html`
${hasConfiguration
? html`
@@ -56,7 +54,7 @@ class SupervisorAppConfigDashboard extends LitElement {
></supervisor-app-config>
`
: nothing}
${hasNetwork
${this.addon.network
? html`
<supervisor-app-network
.hass=${this.hass}
@@ -9,44 +9,22 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-switch";
import type { HaSwitch } from "../../../../../components/ha-switch";
import "../../../../../components/input/ha-input";
import type { HaInput } from "../../../../../components/input/ha-input";
import type {
AddonNetworkIsolationParams,
HassioAddonDetails,
HassioAddonSetOptionParams,
} from "../../../../../data/hassio/addon";
import { setHassioAddonOption } from "../../../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../../../data/hassio/common";
import type { NetworkInterface } from "../../../../../data/hassio/network";
import { fetchNetworkInfo } from "../../../../../data/hassio/network";
import { DirtyStateProviderMixin } from "../../../../../mixins/dirty-state-provider-mixin";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { supervisorAppsStyle } from "../../resources/supervisor-apps-style";
import { suggestSupervisorAppRestart } from "../dialogs/suggestSupervisorAppRestart";
interface NetworkConfig {
ports: Record<string, number | null>;
isolation: AddonNetworkIsolationParams | null;
}
const isValidIpv4 = (address: string): boolean => {
const parts = address.split(".");
return (
parts.length === 4 &&
parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
);
};
@customElement("supervisor-app-network")
class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
LitElement
) {
class SupervisorAppNetwork extends DirtyStateProviderMixin<
Record<string, number | null>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public addon!: HassioAddonDetails;
@@ -57,19 +35,17 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
@state() private _error?: string;
@state() private _config?: NetworkConfig;
@state() private _isolationInterfaces?: NetworkInterface[];
@state() private _config?: Record<string, number | null>;
protected render() {
if (!this._config) {
return nothing;
}
const ports = this._config.ports;
const config = this._config;
const hasHiddenOptions = Object.keys(ports).find(
(entry) => ports[entry] === null
const hasHiddenOptions = Object.keys(config).find(
(entry) => config[entry] === null
);
return html`
@@ -80,29 +56,23 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.introduction"
)}
</p>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
${this.addon.network
? html`
<p>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.introduction"
)}
</p>
<ha-form
.disabled=${this.disabled}
.data=${ports}
@value-changed=${this._configChanged}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.schema=${this._createSchema(ports, this._showOptional)}
></ha-form>
`
: nothing}
${this.addon.network_isolation_available
? this._renderIsolation()
: nothing}
<ha-form
.disabled=${this.disabled}
.data=${this._config}
@value-changed=${this._configChanged}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.schema=${this._createSchema(this._config, this._showOptional)}
></ha-form>
</div>
${hasHiddenOptions
? html`<ha-formfield
@@ -140,129 +110,21 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
`;
}
private _renderIsolation() {
const isolation = this._config!.isolation;
return html`
<div class="isolation">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.title"
)}
>
<ha-switch
.checked=${isolation !== null}
.disabled=${this.disabled}
@change=${this._isolationToggled}
></ha-switch>
</ha-formfield>
<p class="secondary">
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.description"
)}
</p>
${isolation
? html`
<ha-select
.label=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.interface"
)}
.value=${isolation.interface}
.disabled=${this.disabled}
.options=${(this._isolationInterfaces || []).map((iface) => ({
value: iface.interface,
label: iface.ipv4?.address?.length
? `${iface.interface} (${iface.ipv4.address.join(", ")})`
: iface.interface,
}))}
@selected=${this._isolationInterfaceChanged}
></ha-select>
<ha-input
.label=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.ip_address"
)}
.hint=${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.ip_address_helper"
)}
.value=${isolation.ipv4}
.disabled=${this.disabled}
@change=${this._isolationAddressChanged}
></ha-input>
${this.addon.network_isolation_mac
? html`
<p class="mac">
<span class="secondary">
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.mac_address"
)}
</span>
<code>${this.addon.network_isolation_mac}</code>
</p>
`
: nothing}
<ha-alert alert-type="info">
<ul>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.separate_device"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.ipv6"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.host_reachability"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.info.host_interfaces"
)}
</li>
</ul>
</ha-alert>
`
: nothing}
</div>
`;
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("addon")) {
this._setNetworkConfig();
if (
this.addon.network_isolation_available &&
this._isolationInterfaces === undefined
) {
this._loadIsolationInterfaces();
}
}
}
private async _loadIsolationInterfaces(): Promise<void> {
this._isolationInterfaces = [];
try {
const { interfaces } = await fetchNetworkInfo(this.hass);
this._isolationInterfaces = interfaces.filter(
(iface) => iface.network_isolation_capable
);
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
private _createSchema = memoizeOne(
(
ports: Record<string, number | null>,
config: Record<string, number | null>,
showOptional: boolean
): HaFormSchema[] =>
(showOptional
? Object.keys(ports)
: Object.keys(ports).filter((entry) => ports[entry] !== null)
? Object.keys(config)
: Object.keys(config).filter((entry) => config[entry] !== null)
).map((entry) => ({
name: entry,
selector: {
@@ -285,58 +147,14 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
item.name;
private _setNetworkConfig(): void {
const config: NetworkConfig = {
ports: this.addon.network || {},
isolation: this.addon.network_isolation
? {
interface: this.addon.network_isolation.interface,
ipv4: this.addon.network_isolation.ipv4,
}
: null,
};
const config = this.addon.network || {};
this._config = config;
this._initDirtyTracking({ type: "deep" }, config);
this._initDirtyTracking({ type: "shallow" }, config);
}
private _configChanged(ev: CustomEvent): void {
this._config = { ...this._config!, ports: ev.detail.value };
this._updateDirtyState(this._config);
}
private _isolationToggled(ev: Event): void {
const enabled = (ev.target as HaSwitch).checked;
this._config = {
...this._config!,
isolation: enabled
? {
interface:
this.addon.network_isolation?.interface ||
this._isolationInterfaces?.[0]?.interface ||
"",
ipv4: this.addon.network_isolation?.ipv4 || "",
}
: null,
};
this._updateDirtyState(this._config);
}
private _isolationInterfaceChanged(ev: HaSelectSelectEvent): void {
this._config = {
...this._config!,
isolation: { ...this._config!.isolation!, interface: ev.detail.value },
};
this._updateDirtyState(this._config);
}
private _isolationAddressChanged(ev: Event): void {
this._config = {
...this._config!,
isolation: {
...this._config!.isolation!,
ipv4: (ev.target as HaInput).value || "",
},
};
this._updateDirtyState(this._config);
this._config = ev.detail.value;
this._updateDirtyState(ev.detail.value);
}
private async _resetTapped(ev: CustomEvent): Promise<void> {
@@ -345,13 +163,9 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
}
const button = ev.currentTarget as any;
const data: HassioAddonSetOptionParams = {};
if (this.addon.network) {
data.network = null;
}
if (this.addon.network_isolation_available) {
data.network_isolation = null;
}
const data: HassioAddonSetOptionParams = {
network: null,
};
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
@@ -389,36 +203,14 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
const button = ev.currentTarget as any;
this._error = undefined;
const { ports, isolation } = this._config!;
const networkconfiguration: Record<string, number | null> = {};
Object.entries(this._config!).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
if (this.addon.network_isolation_available && isolation) {
if (!isolation.interface) {
this._error = this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.no_interface"
);
button.actionError();
return;
}
if (!isValidIpv4(isolation.ipv4)) {
this._error = this.hass.localize(
"ui.panel.config.apps.configuration.network.isolation.invalid_ip"
);
button.actionError();
return;
}
}
const data: HassioAddonSetOptionParams = {};
if (this.addon.network) {
const networkconfiguration: Record<string, number | null> = {};
Object.entries(ports).forEach(([key, value]) => {
networkconfiguration[key] = value ?? null;
});
data.network = networkconfiguration;
}
if (this.addon.network_isolation_available) {
data.network_isolation = isolation;
}
const data: HassioAddonSetOptionParams = {
network: networkconfiguration,
};
try {
await setHassioAddonOption(this.hass.callWS, this.addon.slug, data);
@@ -462,36 +254,6 @@ class SupervisorAppNetwork extends DirtyStateProviderMixin<NetworkConfig>()(
.show-optional {
padding: 16px;
}
ha-form + .isolation {
margin-top: var(--ha-space-6);
}
.isolation .secondary {
margin-top: var(--ha-space-1);
color: var(--secondary-text-color);
}
.isolation ha-select,
.isolation ha-input {
display: block;
width: 100%;
}
.isolation ha-input {
margin-top: var(--ha-space-4);
}
.isolation .mac {
margin-bottom: 0;
}
.isolation .mac code {
display: block;
margin-top: var(--ha-space-1);
}
.isolation ha-alert {
display: block;
margin-top: var(--ha-space-4);
}
.isolation ha-alert ul {
margin: 0;
padding-inline-start: var(--ha-space-4);
}
`,
];
}
@@ -832,7 +832,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</span>
<code slot="headline"> ${this._currentAddon.hostname} </code>
</ha-row-item>
${this._renderNetworkIsolationRows()}
${metrics.map(
(metric) => html`
<supervisor-app-metric
@@ -843,46 +842,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
`
)}`
: nothing}
${this._currentAddon.version &&
this._currentAddon.state !== "started" &&
this._currentAddon.network_isolation
? html`<wa-divider></wa-divider>
${this._renderNetworkIsolationRows()}`
: nothing}
</div>
</ha-card>
`;
}
private _renderNetworkIsolationRows() {
const addon = this._currentAddon;
if (!addon.version || !addon.network_isolation) {
return nothing;
}
return html`
<ha-row-item>
<span slot="supporting-text">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.network_isolation_ip"
)}
</span>
<code slot="headline"> ${addon.network_isolation.ipv4} </code>
</ha-row-item>
${addon.network_isolation_mac
? html`
<ha-row-item>
<span slot="supporting-text">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.network_isolation_mac"
)}
</span>
<code slot="headline"> ${addon.network_isolation_mac} </code>
</ha-row-item>
`
: nothing}
`;
}
protected render(): TemplateResult {
return html`
${"protected" in this._currentAddon && !this._currentAddon.protected
@@ -261,15 +261,22 @@ export class HaConfigAppsInstalled extends LitElement {
}
.search {
display: flex;
align-items: center;
width: 100%;
height: 56px;
position: sticky;
top: 0;
z-index: 2;
background-color: var(--primary-background-color);
padding: 0 var(--ha-space-4);
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
ha-input-search {
padding: var(--ha-space-3) var(--ha-space-2);
background: var(--sidebar-background-color);
border-bottom: 1px solid var(--divider-color);
flex: 1;
min-width: 0;
}
.content {
+17 -18
View File
@@ -288,29 +288,28 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
Object.values(this.hass.devices),
this._entityReg
);
const { devices, entities } = memberships;
const quickLinkCounts = this._getQuickLinkCounts(
memberships,
this._related
);
// Pre-compute the entity and device names, so we can sort by them
if (devices) {
devices.forEach((entry) => {
entry.name = computeDeviceNameDisplay(
entry,
this.hass.localize,
this.hass.states
);
});
sortDeviceRegistryByName(devices, this.hass.locale.language);
}
if (entities) {
entities.forEach((entry) => {
entry.name = computeEntityRegistryName(this.hass, entry);
});
sortEntityRegistryByName(entities, this.hass.locale.language);
}
// Compute the display names on shallow copies so we can sort and render by
// them without mutating the shared registry objects.
const devices = memberships.devices.map((entry) => ({
...entry,
name: computeDeviceNameDisplay(
entry,
this.hass.localize,
this.hass.states
),
}));
sortDeviceRegistryByName(devices, this.hass.locale.language);
const entities = memberships.entities.map((entry) => ({
...entry,
name: computeEntityRegistryName(this.hass, entry),
}));
sortEntityRegistryByName(entities, this.hass.locale.language);
// Group entities by domain
const groupedEntities = groupBy(entities, (entity) =>
@@ -18,13 +18,22 @@ import {
} from "../../../../data/automation";
import { MODES, isMaxMode } from "../../../../data/script";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import type { AutomationModeDialog } from "./show-dialog-automation-mode";
interface AutomationModeState {
mode: (typeof MODES)[number];
max?: number;
}
@customElement("ha-dialog-automation-mode")
class DialogAutomationMode extends LitElement implements HassDialog {
class DialogAutomationMode
extends DirtyStateProviderMixin<AutomationModeState>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -42,6 +51,10 @@ class DialogAutomationMode extends LitElement implements HassDialog {
this._newMax = isMaxMode(this._newMode)
? params.config.max || AUTOMATION_DEFAULT_MAX
: undefined;
this._initDirtyTracking(
{ type: "shallow" },
{ mode: this._newMode, max: this._newMax }
);
}
public closeDialog(): boolean {
@@ -70,6 +83,7 @@ class DialogAutomationMode extends LitElement implements HassDialog {
<ha-dialog
.open=${this._open}
header-title=${title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-icon-button
@@ -123,7 +137,11 @@ class DialogAutomationMode extends LitElement implements HassDialog {
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._save}>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!this.isDirtyState}
>
${this.hass.localize(
"ui.panel.config.automation.editor.change_mode"
)}
@@ -141,6 +159,7 @@ class DialogAutomationMode extends LitElement implements HassDialog {
} else if (!this._newMax) {
this._newMax = AUTOMATION_DEFAULT_MAX;
}
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
}
private _valueChanged(ev: InputEvent) {
@@ -148,6 +167,7 @@ class DialogAutomationMode extends LitElement implements HassDialog {
const target = ev.target as HaInput;
if (target.name === "max") {
this._newMax = Number(target.value);
this._updateDirtyState({ mode: this._newMode, max: this._newMax });
}
}
@@ -25,6 +25,7 @@ import type { GenDataTaskResult } from "../../../../data/ai_task";
import type { AutomationConfig } from "../../../../data/automation";
import type { ScriptConfig } from "../../../../data/script";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
@@ -38,8 +39,18 @@ import type {
SaveDialogParams,
} from "./show-dialog-automation-save";
interface AutomationSaveState {
name?: string;
description?: string;
icon?: string;
entryUpdates: EntityRegistryUpdate;
}
@customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog {
class DialogAutomationSave
extends DirtyStateProviderMixin<AutomationSaveState>()(LitElement)
implements HassDialog
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -81,6 +92,16 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this._entryUpdates.labels.length > 0 ? "labels" : "",
this._entryUpdates.area ? "area" : "",
].filter(Boolean);
this._initDirtyTracking(
{ type: "deep" },
{
name: this._newName,
description: this._newDescription,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
}
);
}
public closeDialog(): boolean {
@@ -252,6 +273,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
.open=${this._open}
@closed=${this._dialogClosed}
header-title=${this._params.title || title}
.preventScrimClose=${this.isDirtyState}
>
${this._params.hideInputs
? nothing
@@ -281,7 +303,11 @@ class DialogAutomationSave extends LitElement implements HassDialog {
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._save}>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!!this._params.config.alias && !this.isDirtyState}
>
${this.hass.localize(
this._params.config.alias && !this._params.onDiscard
? "ui.panel.config.automation.editor.rename"
@@ -299,17 +325,28 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this._visibleOptionals = [...this._visibleOptionals, option];
}
private _trackDirtyState() {
this._updateDirtyState({
name: this._newName,
description: this._newDescription,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
});
}
private _registryEntryChanged(ev) {
ev.stopPropagation();
const id: string = ev.target.id;
const value = ev.detail.value;
this._entryUpdates = { ...this._entryUpdates, [id]: value };
this._trackDirtyState();
}
private _iconChanged(ev: CustomEvent) {
ev.stopPropagation();
this._newIcon = ev.detail.value || undefined;
this._trackDirtyState();
}
private _valueChanged(ev: CustomEvent) {
@@ -320,6 +357,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
} else {
this._newName = target.value;
}
this._trackDirtyState();
}
private _handleDiscard() {
@@ -387,6 +425,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
this._visibleOptionals = [...this._visibleOptionals, "labels"];
}
}
this._trackDirtyState();
}
private async _save(): Promise<void> {
@@ -164,6 +164,9 @@ export class HaPlatformCondition extends LitElement {
<ha-icon-button
.path=${mdiHelpCircleOutline}
class="help-icon"
.label=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
></ha-icon-button>
</a>`
: nothing}
@@ -251,6 +251,13 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
if (filteredAutomations === null) {
return [];
}
// Build lookups once instead of scanning the registries for every row.
const entityRegLookup = new Map(
entityReg.map((reg) => [reg.entity_id, reg])
);
const labelLookup = labelReg
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
return (
filteredAutomations
? automations.filter((automation) =>
@@ -258,13 +265,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
)
: automations
).map((automation) => {
const entityRegEntry = entityReg.find(
(reg) => reg.entity_id === automation.entity_id
);
const entityRegEntry = entityRegLookup.get(automation.entity_id);
const category = entityRegEntry?.categories.automation;
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || [])
.map((lbl) => labelReg!.find((label) => label.label_id === lbl)!)
.map((lbl) => labelLookup!.get(lbl)!)
.filter(Boolean);
const assistants = getEntityVoiceAssistantsIds(
entityReg,
@@ -55,6 +55,9 @@ export class HaConversationTrigger
@click=${this._removeOption}
slot="end"
.path=${mdiClose}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.conversation.delete"
)}
></ha-icon-button>
</ha-input>
`
@@ -78,6 +81,9 @@ export class HaConversationTrigger
@click=${this._addOption}
slot="end"
.path=${mdiPlus}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.conversation.add_sentence"
)}
></ha-icon-button>
</ha-input>`;
}
@@ -201,6 +201,9 @@ export class HaPlatformTrigger extends LitElement {
<ha-icon-button
.path=${mdiHelpCircleOutline}
class="help-icon"
.label=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
></ha-icon-button>
</a>`
: nothing}
@@ -84,7 +84,10 @@ export class CloudRegister extends LitElement {
${this.hass.localize(
"ui.panel.config.cloud.register.information3"
)}
<a href="https://www.nabucasa.com" target="_blank"
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>
${this.hass.localize(
@@ -37,6 +37,12 @@ class HaPanelDevStateRenderer extends LitElement {
@property({ attribute: false })
public showAttributes = true;
@property({ attribute: false })
public showDevice = true;
@property({ attribute: false })
public showArea = true;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -57,11 +63,15 @@ class HaPanelDevStateRenderer extends LitElement {
protected render() {
const showAttributes = !this.narrow && this.showAttributes;
const showDevice = !this.narrow && this.showDevice;
const showArea = !this.narrow && this.showArea;
return html`
<div
class=${classMap({
entities: true,
"hide-attributes": !showAttributes,
"hide-device": !showDevice,
"hide-area": !showArea,
"hide-extra": this.narrow,
})}
role="table"
@@ -81,14 +91,14 @@ class HaPanelDevStateRenderer extends LitElement {
)}
</span>
</div>
<div class="header" role="columnheader">
<div class="header" role="columnheader" ?hidden=${!showDevice}>
<span class="padded">
${this._i18n.localize(
"ui.panel.config.entities.picker.headers.device"
)}
</span>
</div>
<div class="header" role="columnheader">
<div class="header" role="columnheader" ?hidden=${!showArea}>
<span class="padded">
${this._i18n.localize("ui.panel.config.generic.headers.area")}
</span>
@@ -355,6 +365,24 @@ class HaPanelDevStateRenderer extends LitElement {
white-space: pre-wrap;
}
.hide-device .filter-devices {
display: none;
}
.hide-device .row .header:nth-child(3),
.hide-device .row .cell:nth-child(3) {
display: none;
}
.hide-area .filter-areas {
display: none;
}
.hide-area .row .header:nth-child(4),
.hide-area .row .cell:nth-child(4) {
display: none;
}
.hide-attributes .filter-attributes {
display: none;
}
@@ -87,6 +87,18 @@ class HaPanelDevState extends LitElement {
})
private _showAttributes = true;
@storage({
key: "devToolsShowDevice",
state: true,
})
private _showDevice = true;
@storage({
key: "devToolsShowArea",
state: true,
})
private _showArea = true;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state()
@@ -157,14 +169,32 @@ class HaPanelDevState extends LitElement {
)}
</h1>
${!this.narrow
? html`<ha-checkbox
.checked=${this._showAttributes}
@change=${this._saveAttributeCheckboxState}
>
${this._i18n.localize(
"ui.panel.config.developer-tools.tabs.states.attributes"
)}
</ha-checkbox>`
? html`
<div class="filters-toggles">
<ha-checkbox
.checked=${this._showDevice}
@change=${this._saveDeviceCheckboxState}
>
${this._i18n.localize(
"ui.panel.config.entities.picker.headers.device"
)}
</ha-checkbox>
<ha-checkbox
.checked=${this._showArea}
@change=${this._saveAreaCheckboxState}
>
${this._i18n.localize("ui.panel.config.generic.headers.area")}
</ha-checkbox>
</div>
<ha-checkbox
.checked=${this._showAttributes}
@change=${this._saveAttributeCheckboxState}
>
${this._i18n.localize(
"ui.panel.config.developer-tools.tabs.states.attributes"
)}
</ha-checkbox>
`
: nothing}
</div>
<ha-expansion-panel
@@ -280,6 +310,8 @@ class HaPanelDevState extends LitElement {
.entities=${entities}
.virtualize=${entities.length > VIRTUALIZE_THRESHOLD}
.showAttributes=${this._showAttributes}
.showDevice=${this._showDevice}
.showArea=${this._showArea}
@states-tool-entity-selected=${this._entitySelected}
>
<ha-input-search
@@ -593,6 +625,14 @@ class HaPanelDevState extends LitElement {
this._showAttributes = ev.target.checked;
}
private _saveDeviceCheckboxState(ev) {
this._showDevice = ev.target.checked;
}
private _saveAreaCheckboxState(ev) {
this._showArea = ev.target.checked;
}
private _yamlChanged(ev) {
this._stateAttributes = ev.detail.value;
this._validJSON = ev.detail.isValid;
@@ -617,12 +657,25 @@ class HaPanelDevState extends LitElement {
.heading {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
gap: var(--ha-space-4);
}
.heading ha-checkbox {
margin-right: var(--ha-space-2);
justify-content: center;
.heading h1 {
margin-right: auto;
}
.filters-toggles {
display: flex;
align-items: center;
gap: var(--ha-space-4);
}
.heading .filters-toggles ha-checkbox {
margin-right: 0;
width: max-content;
display: inline-flex;
}
.entity-id {
@@ -452,6 +452,12 @@ export class HaConfigDeviceDashboard extends LitElement {
outputDevices = outputDevices.filter((device) => !device.disabled_by);
}
// Build a label lookup once instead of scanning labelReg for every
// label of every device.
const labelLookup = labelReg
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
const formattedOutputDevices = outputDevices.map((device) => {
const deviceEntries = sortConfigEntries(
device.config_entries
@@ -462,7 +468,7 @@ export class HaConfigDeviceDashboard extends LitElement {
const labels = labelReg && device?.labels;
const labelsEntries = (labels || [])
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
.map((lbl) => labelLookup!.get(lbl))
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
const { areaName } = computeDeviceAreaLabel(
@@ -894,11 +894,17 @@ export class EntityRegistrySettingsEditor extends LitElement {
slot="end"
@click=${this._restoreEntityId}
.path=${mdiRestore}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.restore_entity_id"
)}
></ha-icon-button>
<ha-icon-button
slot="end"
@click=${this._copyEntityId}
.path=${mdiContentCopy}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.copy_entity_id"
)}
></ha-icon-button>
</ha-input>
${!this.entry.device_id
@@ -1178,9 +1178,17 @@ export class HaConfigEntities extends LitElement {
return;
}
// Only the *set* of entity ids matters for the list below. A plain state
// value change on an existing entity cannot add an "entity without unique
// id", so detecting a newly added entity lets us skip the (potentially
// large) rebuild on every state update, which fires constantly.
const stateEntityAdded =
changedProps.has("hass") &&
(!oldHass ||
Object.keys(this.hass.states).some((id) => !(id in oldHass.states)));
if (
(changedProps.has("hass") &&
(!oldHass || oldHass.states !== this.hass.states)) ||
stateEntityAdded ||
changedProps.has("_entities") ||
changedProps.has("_entitySources") ||
changedProps.has("_exposedEntities")
@@ -1259,11 +1259,14 @@ ${rejected
return;
}
const entityIds = Object.keys(this._entitySource);
// Use a Set for O(1) lookups: this runs on every state change, and the
// filter scans every state, so an array `includes` here is O(states ×
// sources).
const entityIds = new Set(Object.keys(this._entitySource));
const newHelpers = Object.values(this.hass!.states).filter(
(entity) =>
entityIds.includes(entity.entity_id) ||
entityIds.has(entity.entity_id) ||
isHelperDomain(computeStateDomain(entity))
);
+20 -19
View File
@@ -13,12 +13,10 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-logo-svg";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import type { HassioHassOSInfo } from "../../../data/hassio/host";
import { fetchHassioHassOsInfo } from "../../../data/hassio/host";
import type { HassioInfo } from "../../../data/hassio/supervisor";
@@ -200,8 +198,8 @@ class HaConfigInfo extends LitElement {
</ha-card>
<ha-card outlined class="pages">
<ha-md-list>
<ha-md-list-item type="button" @click=${this._showShortcuts}>
<ha-list-base>
<ha-list-item-button @click=${this._showShortcuts}>
<div
slot="start"
class="icon-background"
@@ -209,15 +207,14 @@ class HaConfigInfo extends LitElement {
>
<ha-svg-icon .path=${mdiKeyboard}></ha-svg-icon>
</div>
<span
<span slot="headline"
>${this.hass.localize("ui.panel.config.info.shortcuts")}</span
>
</ha-md-list-item>
</ha-list-item-button>
${PAGES.map(
(page) => html`
<ha-md-list-item
type="link"
<ha-list-item-button
.href=${documentationUrl(this.hass, page.path)}
target="_blank"
rel="noopener noreferrer"
@@ -225,20 +222,20 @@ class HaConfigInfo extends LitElement {
<div
slot="start"
class="icon-background"
.style="background-color: ${page.iconColor}"
style=${`background-color: ${page.iconColor};`}
>
<ha-svg-icon .path=${page.iconPath}></ha-svg-icon>
</div>
<span>
<span slot="headline">
${this.hass.localize(
`ui.panel.config.info.items.${page.name}`
)}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-md-list-item>
</ha-list-item-button>
`
)}
</ha-md-list>
</ha-list-base>
${customUiList.length
? html`
<div class="custom-ui">
@@ -246,8 +243,9 @@ class HaConfigInfo extends LitElement {
${customUiList.map(
(item) => html`
<div>
<a href=${item.url} target="_blank"> ${item.name}</a>:
${item.version}
<a href=${item.url} target="_blank" rel="noreferrer">
${item.name}</a
>: ${item.version}
</div>
`
)}
@@ -391,12 +389,15 @@ class HaConfigInfo extends LitElement {
.icon-background ha-svg-icon {
height: 24px;
width: 24px;
display: block;
padding: 8px;
color: #fff;
}
.icon-background {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-circle);
}
@@ -354,6 +354,7 @@ export class HaConfigEntryRow extends LitElement {
<a
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
target="_blank"
rel="noreferrer"
@click=${this._signUrl}
>
<ha-dropdown-item value="diagnostics">
@@ -27,7 +27,6 @@ import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import type { ConfigEntry } from "../../../data/config_entries";
import { getConfigEntries } from "../../../data/config_entries";
import { fetchDiagnosticHandlers } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
@@ -163,8 +162,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
@state() private _filter: string = history.state?.filter || "";
@state() private _diagnosticHandlers?: Record<string, boolean>;
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
@query("ha-input-search") private _searchInput!: HaInputSearch;
@@ -386,16 +383,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
this._handleRouteChanged();
this._scanUSBDevices();
this._scanImprovDevices();
if (isComponentLoaded(this.hass.config, "diagnostics")) {
fetchDiagnosticHandlers(this.hass).then((infos) => {
const handlers = {};
for (const info of infos) {
handlers[info.domain] = info.handlers.config_entry;
}
this._diagnosticHandlers = handlers;
});
}
}
protected updated(changed: PropertyValues<this>) {
@@ -650,9 +637,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
.manifest=${this._manifests[domain]}
.entityRegistryEntries=${this._entityRegistryEntries}
.domainEntities=${this._domainEntities[domain] || []}
.supportsDiagnostics=${this._diagnosticHandlers
? this._diagnosticHandlers[domain]
: false}
.logInfo=${this._logInfos
? this._logInfos[domain]
: nothing}
@@ -38,9 +38,6 @@ export class HaIntegrationCard extends LitElement {
@property({ attribute: false })
public entityRegistryEntries!: EntityRegistryEntry[];
@property({ attribute: "supports-diagnostics", type: Boolean })
public supportsDiagnostics = false;
@property({ attribute: false }) public logInfo?: IntegrationLogInfo;
@property({ attribute: false }) public domainEntities: string[] = [];
@@ -92,6 +92,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
this._configEntryId || ""
)}
target="_blank"
rel="noreferrer"
@click=${this._signUrl}
>
<ha-dropdown-item>
@@ -22,6 +22,7 @@ import "../../category/ha-category-picker";
import type { GenDataTaskResult } from "../../../../data/ai_task";
import type { SceneConfig } from "../../../../data/scene";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
@@ -42,8 +43,16 @@ const SUGGESTION_INCLUDE: MetadataSuggestionInclude = {
labels: true,
};
interface SceneSaveState {
name?: string;
icon?: string;
entryUpdates: EntityRegistryUpdate;
}
@customElement("ha-dialog-scene-save")
class DialogSceneSave extends LitElement {
class DialogSceneSave extends DirtyStateProviderMixin<SceneSaveState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@@ -80,6 +89,15 @@ class DialogSceneSave extends LitElement {
this._entryUpdates.category ? "category" : "",
this._entryUpdates.labels.length > 0 ? "labels" : "",
].filter(Boolean);
this._initDirtyTracking(
{ type: "deep" },
{
name: this._newName,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
}
);
}
public closeDialog() {
@@ -188,6 +206,7 @@ class DialogSceneSave extends LitElement {
<ha-dialog
.open=${this._open}
header-title=${this._params.title || title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._params.hideInputs
@@ -230,7 +249,11 @@ class DialogSceneSave extends LitElement {
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._save}>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!!this._params.config.id && !this.isDirtyState}
>
${this.hass.localize(
this._params.config.id && !this._params.onDiscard
? "ui.panel.config.scene.editor.rename"
@@ -248,17 +271,27 @@ class DialogSceneSave extends LitElement {
this._visibleOptionals = [...this._visibleOptionals, option];
}
private _trackDirtyState() {
this._updateDirtyState({
name: this._newName,
icon: this._newIcon,
entryUpdates: this._entryUpdates,
});
}
private _registryEntryChanged(ev) {
ev.stopPropagation();
const id: string = ev.target.id;
const value = ev.detail.value;
this._entryUpdates = { ...this._entryUpdates, [id]: value };
this._trackDirtyState();
}
private _iconChanged(ev: CustomEvent) {
ev.stopPropagation();
this._newIcon = ev.detail.value || undefined;
this._trackDirtyState();
}
private _valueChanged(ev: CustomEvent) {
@@ -267,6 +300,7 @@ class DialogSceneSave extends LitElement {
if (this._error && this._newName.trim()) {
this._error = false;
}
this._trackDirtyState();
}
private _handleDiscard() {
@@ -325,6 +359,7 @@ class DialogSceneSave extends LitElement {
this._visibleOptionals = [...this._visibleOptionals, "labels"];
}
}
this._trackDirtyState();
}
private async _save(): Promise<void> {
+10 -3
View File
@@ -13,6 +13,7 @@ import type { HaFormSchema } from "../../../components/ha-form/types";
import type { HomeFrontendSystemData } from "../../../data/frontend";
import type { ShortcutItem } from "../../../data/home_shortcuts";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "../components/home-favorites-editor";
@@ -37,7 +38,7 @@ const WELCOME_SCHEMA: HaFormSchema[] = [
@customElement("dialog-edit-home")
export class DialogEditHome
extends LitElement
extends DirtyStateProviderMixin<EditorState>()(LitElement)
implements HassDialog<EditHomeDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -60,6 +61,7 @@ export class DialogEditHome
show_welcome_message: !params.config.hide_welcome_message,
shortcuts: params.config.shortcuts ? [...params.config.shortcuts] : [],
};
this._initDirtyTracking({ type: "shallow" }, this._state);
this._open = true;
}
@@ -87,7 +89,7 @@ export class DialogEditHome
.headerSubtitle=${this.hass.localize(
"ui.panel.home.editor.description"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-alert alert-type="info">
@@ -178,7 +180,7 @@ export class DialogEditHome
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -222,6 +224,7 @@ export class DialogEditHome
...this._state!,
favorite_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _welcomeChanged(
@@ -231,6 +234,7 @@ export class DialogEditHome
...this._state!,
show_welcome_message: ev.detail.value.show_welcome_message,
};
this._updateDirtyState(this._state);
}
private _suggestedChanged(
@@ -240,6 +244,7 @@ export class DialogEditHome
...this._state!,
show_suggested_entities: ev.detail.value.show_suggested_entities,
};
this._updateDirtyState(this._state);
}
private _shortcutsChanged(ev: ValueChangedEvent<ShortcutItem[]>): void {
@@ -247,6 +252,7 @@ export class DialogEditHome
...this._state!,
shortcuts: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private async _save(): Promise<void> {
@@ -270,6 +276,7 @@ export class DialogEditHome
try {
await this._params.saveConfig(config);
this._markDirtyStateClean();
this.closeDialog();
} finally {
this._submitting = false;
@@ -10,13 +10,14 @@ import type { HaFormSchema } from "../../../components/ha-form/types";
import type { CustomShortcutItem } from "../../../data/home_shortcuts";
import { NavigationPathInfoController } from "../../../data/navigation-path-controller";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { EditShortcutDialogParams } from "./show-dialog-edit-shortcut";
@customElement("dialog-edit-shortcut")
export class DialogEditShortcut
extends LitElement
extends DirtyStateProviderMixin<CustomShortcutItem>()(LitElement)
implements HassDialog<EditShortcutDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -32,6 +33,7 @@ export class DialogEditShortcut
public showDialog(params: EditShortcutDialogParams): void {
this._params = params;
this._data = { ...params.item };
this._initDirtyTracking({ type: "shallow" }, this._data);
this._open = true;
}
@@ -89,6 +91,7 @@ export class DialogEditShortcut
.open=${this._open}
.headerTitle=${this.hass.localize("ui.panel.home.editor.edit_shortcut")}
width="small"
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-form
@@ -107,7 +110,11 @@ export class DialogEditShortcut
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._save}>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${!this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
@@ -124,6 +131,7 @@ export class DialogEditShortcut
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
this._data = ev.detail.value as CustomShortcutItem;
this._updateDirtyState(this._data);
}
private _save() {
@@ -136,6 +144,7 @@ export class DialogEditShortcut
icon: icon || undefined,
color: color || undefined,
});
this._markDirtyStateClean();
this.closeDialog();
}
@@ -267,8 +267,6 @@ function formatTooltip(
let sumPositive = 0;
let countPositive = 0;
let sumNegative = 0;
let countNegative = 0;
const rows: TemplateResult[] = [];
for (const param of params) {
const y = param.value?.[1] as number;
@@ -280,14 +278,12 @@ function formatTooltip(
if (value === "0") {
continue;
}
if (param.componentSubType === "bar") {
if (y > 0) {
sumPositive += y;
countPositive++;
} else {
sumNegative += y;
countNegative++;
}
// Only the positive bars (consumption) are summed into a total. Negative
// bars mix unrelated categories (grid export and battery charge), so they
// are not totaled.
if (param.componentSubType === "bar" && y > 0) {
sumPositive += y;
countPositive++;
}
rows.push(
html`<ha-chart-tooltip-marker
@@ -305,8 +301,6 @@ function formatTooltip(
(row, i) => html`${i > 0 ? html`<br />` : nothing}${row}`
)}${sumPositive !== 0 && countPositive > 1 && formatTotal
? html`<br /><b>${formatTotal(sumPositive)}</b>`
: nothing}${sumNegative !== 0 && countNegative > 1 && formatTotal
? html`<br /><b>${formatTotal(sumNegative)}</b>`
: nothing}`;
}
@@ -568,8 +568,11 @@ export class HuiEnergyDevicesGraphCard
}
if (compareData) {
const compareById = new Map(
chartDataCompare.map((d2) => [(d2 as any).id as string, d2] as const)
);
datasets[1].data = chartData.map((d) =>
chartDataCompare.find((d2) => (d2 as any).id === d.id)
compareById.get(d.id)
) as typeof chartDataCompare;
}
@@ -51,6 +51,13 @@ const colorPropertyMap = {
used_battery: "--energy-battery-out-color",
};
const stackOrder = {
to_battery: 1,
to_grid: 2,
used_solar: 3,
used_battery: 4,
};
@customElement("hui-energy-usage-graph-card")
export class HuiEnergyUsageGraphCard
extends SubscribeMixin(LitElement)
@@ -174,15 +181,10 @@ export class HuiEnergyUsageGraphCard
}
private _formatTotal = (total: number) =>
total > 0
? this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale) }
)
: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_returned",
{ num: formatNumber(-total, this.hass.locale) }
);
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_consumed",
{ num: formatNumber(total, this.hass.locale) }
);
private _createOptions = memoizeOne(
(
@@ -559,7 +561,7 @@ export class HuiEnergyUsageGraphCard
this._compareStart!
);
Object.entries(combinedData).forEach(([type, sources], idx) => {
Object.entries(combinedData).forEach(([type, sources]) => {
Object.entries(sources).forEach(([statId, source]) => {
const points: BarSeriesOption["data"] = [];
// Process chart data.
@@ -592,12 +594,7 @@ export class HuiEnergyUsageGraphCard
statisticsMetaData[statId]
),
// @ts-expect-error
order:
type === "used_solar"
? 1
: type === "to_battery"
? Object.keys(combinedData).length
: idx + 2,
order: stackOrder[type] ?? Object.keys(combinedData).length,
barMaxWidth: 50,
itemStyle: {
borderColor: getEnergyColor(
@@ -236,7 +236,17 @@ export class HuiPowerSourcesGraphCard
};
const now = Date.now();
Object.keys(statIds).forEach((key, keyIndex) => {
const seriesData: Record<
string,
{
colorHex: string;
rgb: [number, number, number];
positive: [number, number][];
negative: [number, number][];
}
> = {};
Object.keys(statIds).forEach((key) => {
if (statIds[key].stats.length) {
const colorHex = computedStyles.getPropertyValue(statIds[key].color);
const rgb = hex2rgb(colorHex);
@@ -261,14 +271,32 @@ export class HuiPowerSourcesGraphCard
}),
trackY
);
datasets.push({
...commonSeriesOptions,
id: key,
name: statIds[key].name,
color: colorHex,
stack: "positive",
areaStyle: {
color: new LinearGradient(0, 0, 0, 1, [
seriesData[key] = { colorHex, rgb, positive, negative };
}
});
const pushSeries = (
key: string,
data: [number, number][],
stack: "positive" | "negative",
z: number
) => {
const { colorHex, rgb } = seriesData[key];
datasets.push({
...commonSeriesOptions,
id: stack === "positive" ? key : `${key}-negative`,
name: statIds[key].name,
color: colorHex,
stack,
areaStyle: {
color: new LinearGradient(
0,
stack === "positive" ? 0 : 1,
0,
stack === "positive" ? 1 : 0,
[
{
offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
@@ -277,34 +305,32 @@ export class HuiPowerSourcesGraphCard
offset: 1,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
},
]),
},
data: positive,
z: 3 - keyIndex, // draw in reverse order so 0 value lines are overwritten
});
if (key !== "solar") {
datasets.push({
...commonSeriesOptions,
id: `${key}-negative`,
name: statIds[key].name,
color: colorHex,
stack: "negative",
areaStyle: {
color: new LinearGradient(0, 1, 0, 0, [
{
offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
},
{
offset: 1,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
},
]),
},
data: negative,
z: 4 - keyIndex, // draw in reverse order but above positive series
});
}
]
),
},
data,
z,
});
};
// Draw in reverse order so 0 value lines are overwritten
["solar", "battery", "grid"].forEach((key, i) => {
if (seriesData[key]) {
pushSeries(key, seriesData[key].positive, "positive", 3 - i);
}
});
// Draw in reverse order but above positive series
["battery", "grid"].forEach((key, i) => {
if (seriesData[key]) {
pushSeries(key, seriesData[key].negative, "negative", 4 - i);
}
});
Object.keys(statIds).forEach((key) => {
if (seriesData[key]) {
const { colorHex, rgb } = seriesData[key];
this._legendData!.push({
id: key,
secondaryIds: key !== "solar" ? [`${key}-negative`] : [],
@@ -1047,7 +1047,7 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
height: 24px;
padding: 16px 4px;
padding: 0 4px;
}
.deleteItemButton {
@@ -14,13 +14,16 @@ import type {
DataEntryFlowStep,
DataEntryFlowStepForm,
} from "../../data/data_entry_flow";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
let instance = 0;
@customElement("ha-mfa-module-setup-flow")
class HaMfaModuleSetupFlow extends LitElement {
class HaMfaModuleSetupFlow extends DirtyStateProviderMixin<
Record<string, unknown>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _dialogClosedCallback?: (params: {
@@ -83,7 +86,7 @@ class HaMfaModuleSetupFlow extends LitElement {
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
header-title=${this._computeStepTitle()}
@closed=${this._dialogClosed}
>
@@ -220,6 +223,7 @@ class HaMfaModuleSetupFlow extends LitElement {
private _stepDataChanged(ev: CustomEvent) {
this._stepData = ev.detail.value;
this._updateDirtyState(this._stepData);
}
private _submitStep() {
@@ -317,6 +321,7 @@ class HaMfaModuleSetupFlow extends LitElement {
// We got a new form if there are no errors.
if (Object.keys(step.errors).length === 0) {
this._stepData = {};
this._initDirtyTracking({ type: "shallow" }, {});
}
}
@@ -11,6 +11,7 @@ import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-svg-icon";
import "../../components/input/ha-input";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import type { LongLivedAccessTokenDialogParams } from "./show-long-lived-access-token-dialog";
@@ -18,7 +19,9 @@ import type { LongLivedAccessTokenDialogParams } from "./show-long-lived-access-
const QR_LOGO_URL = "/static/icons/favicon-192x192.png";
@customElement("ha-long-lived-access-token-dialog")
export class HaLongLivedAccessTokenDialog extends LitElement {
export class HaLongLivedAccessTokenDialog extends DirtyStateProviderMixin<string>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _qrCode?: TemplateResult;
@@ -46,6 +49,7 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
);
this._renderDialog = true;
this._open = true;
this._initDirtyTracking({ type: "shallow" }, "");
}
public closeDialog() {
@@ -80,7 +84,7 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
: this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.create"
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div class="content">
@@ -177,10 +181,16 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
private _nameChanged(ev: Event) {
this._name = (ev.currentTarget as HTMLInputElement).value;
this._errorMessage = undefined;
this._updateDirtyState(this._name);
}
private _isCreateDisabled() {
return this._loading || !this._name.trim() || this._hasDuplicateName();
return (
this._loading ||
!this._name.trim() ||
this._hasDuplicateName() ||
!this.isDirtyState
);
}
private async _createToken(): Promise<void> {
@@ -200,6 +210,7 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
client_name: name,
});
this._name = name;
this._markDirtyStateClean();
this._createdCallback();
} catch (err: unknown) {
this._errorMessage = err instanceof Error ? err.message : String(err);
+6 -5
View File
@@ -2,11 +2,12 @@ export function computeCssVariable(
props: string | string[]
): string | undefined {
if (Array.isArray(props)) {
return props
.reverse()
.reduce<
string | undefined
>((str, variable) => `var(${variable}${str ? `, ${str}` : ""})`, undefined);
// reduceRight builds the nested var() fallback chain from last to first
// without mutating the caller's array (unlike reverse()).
return props.reduceRight<string | undefined>(
(str, variable) => `var(${variable}${str ? `, ${str}` : ""})`,
undefined
);
}
return `var(${props})`;
}
+18 -10
View File
@@ -60,16 +60,24 @@ export const createLogMessage = async (
// - a possible list of aggregated errors
if (error instanceof Error) {
lines.push(error.toString() || messageFallback);
const stackLines = (await fromError(error))
.slice(0, MAX_STACK_FRAMES)
.map((frame) => {
frame.fileName ??= "";
if (URL.canParse(frame.fileName)) {
frame.fileName = new URL(frame.fileName).pathname;
}
frame.fileName = frame.fileName.replace(REMOVAL_PATHS, "");
return frame.toString();
});
let stackLines: (string | undefined)[];
try {
stackLines = (await fromError(error))
.slice(0, MAX_STACK_FRAMES)
.map((frame) => {
frame.fileName ??= "";
if (URL.canParse(frame.fileName)) {
frame.fileName = new URL(frame.fileName).pathname;
}
frame.fileName = frame.fileName.replace(REMOVAL_PATHS, "");
return frame.toString();
});
} catch {
// stacktrace-js cannot always parse a stack (for example a DOMException
// with no, or an unrecognized, stack), so fall back to the raw stack
// instead of letting the error logger itself throw.
stackLines = error.stack ? [error.stack] : [];
}
lines.push(...(stackLines.length > 0 ? stackLines : [stackFallback]));
// @ts-expect-error Requires library bump to ES2022
if (error.cause) {
+2
View File
@@ -0,0 +1,2 @@
export const mdiMatterLogo =
"M21.1688 13.7795V10.8875C18.4835 11.6576 16.239 13.3844 14.8539 15.777C13.4486 18.1276 13.0706 20.9372 13.7842 23.6005L16.3017 22.1646C16.1338 20.8952 16.2186 19.6055 16.6578 18.3781L22.5742 21.7493L24 20.9792V19.397L18.0412 16.0057C18.8803 14.9867 19.972 14.2571 21.1672 13.7795M2.8108 10.8859V13.7779C4.0483 14.2571 5.0977 14.9851 5.9368 16.0041L0 19.397V20.9792L1.4477 21.7493L7.3438 18.3781C7.7626 19.6055 7.8473 20.8952 7.6795 22.1646L10.197 23.6005C10.8886 20.9372 10.5122 18.1276 9.1695 15.777C7.7438 13.3843 5.4976 11.6575 2.8124 10.8875M16.8885 6.4119C15.8391 7.182 14.7067 7.7436 13.3844 7.9722V1.2315L12.0418 0.3992L10.6144 1.2315V7.9738C9.3345 7.7451 8.1393 7.1835 7.1323 6.4135L4.6148 7.8494C6.6712 9.7644 9.2718 10.8456 12.0418 10.8456C14.8118 10.8456 17.4124 9.7629 19.4059 7.8494L16.8885 6.4135V6.4119Z";
+15 -32
View File
@@ -600,7 +600,7 @@
}
},
"template": {
"yaml_warning": "It appears you may be writing YAML into this template field (saw ''{string}''), which is likely incorrect. This field is intended for templates only (e.g. '{{ states(sensor.test) > 0 }}' ).",
"yaml_warning": "It appears you may be writing YAML into this template field (saw ''{string}''), which is likely incorrect. This field is intended for templates only (for example, '{{ states(sensor.test) > 0 }}').",
"learn_more": "Learn more about templating"
},
"text": {
@@ -1892,12 +1892,14 @@
"editor": {
"name": "Name",
"icon": "Icon",
"icon_error": "Icons should be in the format 'prefix:iconname', e.g. 'mdi:home'",
"icon_error": "Icons should be in the format 'prefix:iconname', like 'mdi:home'",
"default_code": "Default code",
"default_code_error": "Code does not match code format",
"calendar_color": "Calendar color",
"associated_zone": "Associated zone",
"entity_id": "Entity ID",
"copy_entity_id": "Copy entity ID",
"restore_entity_id": "Restore entity ID",
"unit_of_measurement": "Unit of measurement",
"precipitation_unit": "Precipitation unit",
"precision": "Display precision",
@@ -2904,8 +2906,6 @@
"current_version": "Current version: {version}",
"changelog": "Changelog",
"hostname": "Hostname",
"network_isolation_ip": "IP address on your network",
"network_isolation_mac": "MAC address on your network",
"visit_app_page": "Visit {name} page for more details.",
"start": "Start",
"stop": "Stop",
@@ -3080,23 +3080,7 @@
"header": "Network",
"introduction": "Configure the network ports that this app uses.",
"show_disabled": "Show disabled ports",
"reset_defaults": "Reset to defaults",
"isolation": {
"title": "Isolated network access",
"description": "Run this app with its own IP address on a selected network instead of sharing the host network. Ingress and the web UI link keep working as before.",
"interface": "Network interface",
"ip_address": "IP address",
"ip_address_helper": "Choose an address outside the DHCP range of your router, or reserve it in your router.",
"mac_address": "MAC address of this app on your network",
"no_interface": "Select a network interface to use isolated network access.",
"invalid_ip": "Enter a valid IPv4 address, like 192.168.1.50.",
"info": {
"separate_device": "The app joins the selected network as a separate device with its own IP and MAC address.",
"ipv6": "IPv6 needs no setup: the app automatically gets its IPv6 addresses from your network.",
"host_reachability": "Your Home Assistant system and the app cannot reach each other through their addresses on the selected network. This is part of the isolation — ingress, the web UI link, and other Home Assistant features are not affected.",
"host_interfaces": "Apps that need access to all network interfaces of your system may not work as expected with isolated network access."
}
}
"reset_defaults": "Reset to defaults"
},
"audio": {
"header": "Audio",
@@ -4298,7 +4282,7 @@
"cost_stat_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_stat_input%]",
"cost_entity": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_entity%]",
"cost_entity_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_entity_input%]",
"cost_entity_helper": "Any entity with a unit of `{currency}/(valid {class} unit)` (e.g. `{currency}/{unit1}` or `{currency}/{unit2}`) may be used and will be automatically converted.",
"cost_entity_helper": "Any entity with a unit of `{currency}/(valid {class} unit)` (like `{currency}/{unit1}` or `{currency}/{unit2}`) may be used and will be automatically converted.",
"cost_entity_helper_energy": "energy",
"cost_entity_helper_volume": "volume",
"cost_number": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_number%]",
@@ -4327,7 +4311,7 @@
"cost_stat_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_stat_input%]",
"cost_entity": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_entity%]",
"cost_entity_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_entity_input%]",
"cost_entity_helper": "Any entity with a unit of `{currency}/(valid water unit)` (e.g. `{currency}/gal` or `{currency}/m³`) may be used and will be automatically converted.",
"cost_entity_helper": "Any entity with a unit of `{currency}/(valid water unit)` (like `{currency}/gal` or `{currency}/m³`) may be used and will be automatically converted.",
"cost_number": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_number%]",
"cost_number_input": "[%key:ui::panel::config::energy::grid::flow_dialog::from::cost_number%]",
"water_usage": "Water consumption",
@@ -4457,7 +4441,7 @@
},
"url": {
"caption": "Home Assistant URL",
"description": "Configure what website addresses Home Assistant should share with other devices when they need to fetch data from Home Assistant (e.g. to play text-to-speech or other hosted media).",
"description": "Configure what website addresses Home Assistant should share with other devices when they need to fetch data from Home Assistant (for example, to play text-to-speech or other hosted media).",
"internal_url_label": "Local network",
"external_url_label": "Internet",
"external_use_ha_cloud": "Use Home Assistant Cloud",
@@ -5345,9 +5329,9 @@
"type_input": "Numeric value of another entity",
"description": {
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
}
},
"persistent_notification": {
@@ -5659,7 +5643,7 @@
"zone": "[%key:ui::panel::config::automation::editor::triggers::type::zone::label%]",
"description": {
"picker": "Tests if someone (or something) is in a zone.",
"full": "If {entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} in {zone} {numberOfZones, plural,\n one {zone} \n other {zones}\n}"
"full": "If {entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} in {zone} {numberOfZones, plural,\n one {zone} \n other {zones}\n}"
}
}
}
@@ -8751,7 +8735,6 @@
"no_data_period": "There is no data for this period.",
"energy_usage_graph": {
"total_consumed": "Total consumed {num} kWh",
"total_returned": "Total exported {num} kWh",
"total_usage": "+{num} kWh",
"combined_from_grid": "Combined from grid",
"consumed_solar": "Consumed solar",
@@ -9069,7 +9052,7 @@
"top": "Top",
"bottom": "Bottom"
},
"badges_wrap": "Badges behaviour",
"badges_wrap": "Badges behavior",
"badges_wrap_options": {
"wrap": "Wrap",
"scroll": "Scroll",
@@ -9214,7 +9197,7 @@
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
"settings": {
"column_span": "Width",
"column_span_helper": "Larger sections will be made smaller to fit the display. (e.g. on mobile devices)",
"column_span_helper": "Larger sections will be made smaller to fit the display. (for example, on mobile devices)",
"background": "Background options",
"background_enabled": "Background",
"background_enabled_helper": "Display a colored background behind the section",
@@ -10027,7 +10010,7 @@
"name": "Tile",
"description": "This card gives you a quick overview of an entity. It allows you to toggle the entity, show the More info dialog or trigger custom actions.",
"color": "Color",
"color_helper": "Inactive state (e.g. off, closed) will not be colored.",
"color_helper": "Inactive state (for example, off or closed) will not be colored.",
"icon_tap_action": "Icon tap behavior",
"icon_hold_action": "Icon hold behavior",
"icon_double_tap_action": "Icon double tap behavior",
+20
View File
@@ -64,6 +64,26 @@ describe("formatNumber", () => {
assert.strictEqual(formatNumber("", defaultLocale), "0");
});
it("Returns consistent results for interleaved calls with different options (formatter cache)", () => {
// Exercises the cached Intl.NumberFormat instances: alternating option
// shapes must each keep producing their own correct output.
for (let i = 0; i < 3; i++) {
assert.strictEqual(formatNumber(1234.5, defaultLocale), "1,234.5");
assert.strictEqual(
formatNumber(1234.5, defaultLocale, { minimumFractionDigits: 2 }),
"1,234.50"
);
assert.strictEqual(formatNumber("1234.50", defaultLocale), "1,234.50");
assert.strictEqual(
formatNumber(1234.5, {
...defaultLocale,
number_format: NumberFormat.none,
}),
"1234.5"
);
}
});
it("Formats number with options", () => {
assert.strictEqual(
formatNumber(1234.5, defaultLocale, {
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from "vitest";
import { isTimestamp } from "../../../src/common/string/is_timestamp";
test("isTimestamp accepts valid timestamps", () => {
expect(isTimestamp("2021-06-15T08:30:00Z")).toBe(true);
expect(isTimestamp("2021-06-15 08:30:00")).toBe(true);
expect(isTimestamp("2021-06-15T08:30")).toBe(true);
expect(isTimestamp("2021-12-31T23:59:59")).toBe(true);
expect(isTimestamp("2021-06-15T08:30:00.123+02:00")).toBe(true);
expect(isTimestamp("2021-06-15T24:00")).toBe(true);
});
test("isTimestamp rejects non-timestamps", () => {
expect(isTimestamp("not a date")).toBe(false);
expect(isTimestamp("2021/06/15T08:30")).toBe(false);
expect(isTimestamp("2021-13-01T00:00")).toBe(false);
expect(isTimestamp("2021-00-01T00:00")).toBe(false);
expect(isTimestamp("2021-06-32T00:00")).toBe(false);
expect(isTimestamp("2021-06-15T25:00")).toBe(false);
});
test("isTimestamp does not allow a leading plus or minus", () => {
expect(isTimestamp("+2021-06-15T08:30")).toBe(false);
expect(isTimestamp("-2021-06-15T08:30")).toBe(false);
});
test("isTimestamp requires a time component after the date", () => {
expect(isTimestamp("2021-06-15")).toBe(false);
});
test("isTimestamp rejects week-number dates", () => {
expect(isTimestamp("2021-W24-2T08:30")).toBe(false);
});
test("isTimestamp rejects a year on its own", () => {
expect(isTimestamp("2021")).toBe(false);
});
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { copyToClipboard } from "../../../src/common/util/copy-clipboard";
const deepActiveElement = vi.hoisted(() => vi.fn());
vi.mock("../../../src/common/dom/deep-active-element", () => ({
deepActiveElement,
}));
describe("copyToClipboard", () => {
const originalClipboard = navigator.clipboard;
const hadExecCommand = "execCommand" in document;
const setClipboard = (value: unknown) => {
Object.defineProperty(navigator, "clipboard", {
value,
configurable: true,
});
};
// jsdom does not implement execCommand, so provide a stub for the fallback.
const stubExecCommand = () => {
const execCommand = vi.fn().mockReturnValue(true);
(document as any).execCommand = execCommand;
return execCommand;
};
beforeEach(() => {
deepActiveElement.mockReset();
});
afterEach(() => {
setClipboard(originalClipboard);
if (!hadExecCommand) {
delete (document as any).execCommand;
}
vi.restoreAllMocks();
});
it("uses the async clipboard API when available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
setClipboard({ writeText });
await copyToClipboard("hello");
expect(writeText).toHaveBeenCalledWith("hello");
// The fallback should not run when the async API succeeds.
expect(deepActiveElement).not.toHaveBeenCalled();
});
it("falls back without throwing when the active element is in the light DOM", async () => {
setClipboard(undefined);
const execCommand = stubExecCommand();
// An element in the main document: getRootNode() returns the document,
// which cannot have a textarea appended to it directly.
const lightEl = document.createElement("div");
document.body.appendChild(lightEl);
deepActiveElement.mockReturnValue(lightEl);
const appendSpy = vi.spyOn(document.body, "appendChild");
await expect(copyToClipboard("hello")).resolves.toBeUndefined();
expect(appendSpy).toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
// The temporary textarea is cleaned up.
expect(document.body.querySelector("textarea")).toBeNull();
document.body.removeChild(lightEl);
});
it("appends into the shadow root when the active element lives in one", async () => {
setClipboard(undefined);
stubExecCommand();
const host = document.createElement("div");
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
const shadowEl = document.createElement("span");
shadow.appendChild(shadowEl);
deepActiveElement.mockReturnValue(shadowEl);
const shadowAppend = vi.spyOn(shadow, "appendChild");
await copyToClipboard("hello");
expect(shadowAppend).toHaveBeenCalled();
// The temporary textarea is cleaned up.
expect(shadow.querySelector("textarea")).toBeNull();
document.body.removeChild(host);
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { matchSelectOptionValue } from "../../../src/components/ha-form/ha-form-select";
import type { HaFormSelectSchema } from "../../../src/components/ha-form/types";
// The backend can send non-string option values (e.g. integers from a vol.In
// schema) even though the type declares strings, so cast in the test.
const asOptions = (options: (readonly [unknown, string])[]) =>
options as unknown as HaFormSelectSchema["options"];
describe("matchSelectOptionValue", () => {
it("retains the numeric type of a matched option", () => {
const options = asOptions([
[1, "One"],
[5, "Five"],
[6, "Six"],
]);
expect(matchSelectOptionValue(options, "5")).toBe(5);
});
it("matches a zero value correctly", () => {
const options = asOptions([
[0, "Zero"],
[1, "One"],
]);
expect(matchSelectOptionValue(options, "0")).toBe(0);
});
it("returns string option values unchanged", () => {
const options = asOptions([
["a", "A"],
["b", "B"],
]);
expect(matchSelectOptionValue(options, "b")).toBe("b");
});
it("returns the value unchanged when no option matches", () => {
const options = asOptions([["a", "A"]]);
expect(matchSelectOptionValue(options, "missing")).toBe("missing");
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { getEntities } from "../../../src/data/entity/entity_picker";
import type { HomeAssistant } from "../../../src/types";
const makeHass = (entityIds: string[]): HomeAssistant => {
const states: Record<string, any> = {};
for (const id of entityIds) {
states[id] = {
entity_id: id,
state: "on",
attributes: { friendly_name: id },
last_changed: "",
last_updated: "",
context: { id: "", parent_id: null, user_id: null },
};
}
return {
states,
entities: {},
devices: {},
areas: {},
floors: {},
language: "en",
localize: ((key: string) => key) as any,
translationMetadata: { translations: {} },
} as unknown as HomeAssistant;
};
const ids = (items: { id: string }[]) => items.map((item) => item.id).sort();
describe("getEntities", () => {
const hass = makeHass([
"light.kitchen",
"light.living",
"switch.fan",
"sensor.temp",
]);
it("returns all entities when no filters are given", () => {
expect(ids(getEntities(hass))).toEqual([
"light.kitchen",
"light.living",
"sensor.temp",
"switch.fan",
]);
});
it("filters by includeDomains", () => {
expect(ids(getEntities(hass, { includeDomains: ["light"] }))).toEqual([
"light.kitchen",
"light.living",
]);
});
it("filters by excludeDomains", () => {
expect(
ids(getEntities(hass, { excludeDomains: ["light", "switch"] }))
).toEqual(["sensor.temp"]);
});
it("filters by includeEntities", () => {
expect(
ids(
getEntities(hass, {
includeEntities: ["light.kitchen", "sensor.temp"],
})
)
).toEqual(["light.kitchen", "sensor.temp"]);
});
it("filters by excludeEntities", () => {
expect(
ids(
getEntities(hass, {
excludeEntities: ["light.kitchen", "light.living"],
})
)
).toEqual(["sensor.temp", "switch.fan"]);
});
it("combines include and exclude filters", () => {
expect(
ids(
getEntities(hass, {
includeDomains: ["light"],
excludeEntities: ["light.living"],
})
)
).toEqual(["light.kitchen"]);
});
it("applies idPrefix to the item id", () => {
const items = getEntities(hass, {
includeEntities: ["sensor.temp"],
idPrefix: "entity-",
});
expect(items).toHaveLength(1);
expect(items[0].id).toBe("entity-sensor.temp");
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
computeCssVariable,
computeCssValue,
} from "../../src/resources/css-variables";
describe("computeCssVariable", () => {
it("wraps a single property in var()", () => {
expect(computeCssVariable("--primary-color")).toBe("var(--primary-color)");
});
it("builds a nested fallback chain in order for an array", () => {
expect(computeCssVariable(["--a", "--b", "--c"])).toBe(
"var(--a, var(--b, var(--c)))"
);
});
it("handles a single-element array", () => {
expect(computeCssVariable(["--only"])).toBe("var(--only)");
});
it("returns undefined for an empty array", () => {
expect(computeCssVariable([])).toBeUndefined();
});
it("does not mutate the input array", () => {
const props = ["--a", "--b", "--c"];
computeCssVariable(props);
expect(props).toEqual(["--a", "--b", "--c"]);
});
it("returns the same result when called repeatedly with the same array", () => {
const props = ["--a", "--b", "--c"];
const first = computeCssVariable(props);
const second = computeCssVariable(props);
expect(second).toBe(first);
});
});
describe("computeCssValue", () => {
const computedStyles = {
getPropertyValue: (prop: string) =>
({
"--a-color": " red ",
"--b-color": "blue",
})[prop] ?? "",
} as CSSStyleDeclaration;
it("returns the trimmed value of a color property", () => {
expect(computeCssValue("--a-color", computedStyles)).toBe("red");
});
it("ignores properties that do not end with -color", () => {
expect(computeCssValue("--a-size", computedStyles)).toBeUndefined();
});
it("returns the first resolved value from an array", () => {
expect(
computeCssValue(["--missing-color", "--b-color"], computedStyles)
).toBe("blue");
});
it("returns undefined when no property resolves", () => {
expect(
computeCssValue(
["--missing-color", "--also-missing-color"],
computedStyles
)
).toBeUndefined();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createLogMessage } from "../../src/resources/log-message";
const fromError = vi.hoisted(() => vi.fn());
vi.mock("stacktrace-js", () => ({ fromError }));
describe("createLogMessage", () => {
beforeEach(() => {
fromError.mockReset();
});
it("includes the error message and parsed stack frames", async () => {
fromError.mockResolvedValue([
{ fileName: "https://example.com/foo.js", toString: () => "at foo.js" },
]);
const error = new Error("boom");
const message = await createLogMessage(error);
expect(message).toContain("Error: boom");
expect(message).toContain("at foo.js");
});
it("does not throw when stacktrace-js cannot parse the stack", async () => {
fromError.mockRejectedValue(new Error("Cannot parse given Error object"));
const error = new Error("boom");
error.stack = "Error: boom\n at <anonymous>";
const message = await createLogMessage(error);
expect(message).toContain("Error: boom");
// Falls back to the raw stack instead of crashing the logger.
expect(message).toContain("at <anonymous>");
});
it("falls back to the provided stack fallback when no stack is available", async () => {
fromError.mockRejectedValue(new Error("Cannot parse given Error object"));
const error = new Error("boom");
error.stack = undefined;
const message = await createLogMessage(
error,
undefined,
undefined,
"@unknown:0:0"
);
expect(message).toContain("@unknown:0:0");
});
});
+162 -160
View File
@@ -1676,13 +1676,13 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl-datetimeformat@npm:7.4.8":
version: 7.4.8
resolution: "@formatjs/intl-datetimeformat@npm:7.4.8"
"@formatjs/intl-datetimeformat@npm:7.4.9":
version: 7.4.9
resolution: "@formatjs/intl-datetimeformat@npm:7.4.9"
dependencies:
"@formatjs/bigdecimal": "npm:0.2.6"
"@formatjs/intl-localematcher": "npm:0.8.10"
checksum: 10/9dde6796f1e260fbb486f27b1a5774a70aef2b4259b102b745b495d93ea5881f0df80d133bf92138cb003c77b7a016f125562f20360a92125680cc7f54621971
checksum: 10/e8739e71f472f1b4beb871c5920b612481c04b440daca01aaadf562b5fdb262e1d40505d767c447595490129ca31b975a1cacb8eef04b50fb307535155f58cae
languageName: node
linkType: hard
@@ -1885,73 +1885,73 @@ __metadata:
languageName: node
linkType: hard
"@html-eslint/core@npm:^0.61.0":
version: 0.61.0
resolution: "@html-eslint/core@npm:0.61.0"
"@html-eslint/core@npm:^0.62.0":
version: 0.62.0
resolution: "@html-eslint/core@npm:0.62.0"
dependencies:
"@html-eslint/types": "npm:^0.61.0"
"@html-eslint/types": "npm:^0.62.0"
html-standard: "npm:^0.0.13"
checksum: 10/a464a9d06c808dd13bfb6a5b050d7bd0d51e6d43b239337036296ab01a4e45fac2a98bc8bb0b47894333b57ec35f970d07a5ecafc1be7d116a88801899cac301
checksum: 10/8899d20b7b5e0723e0f030b6007855f442633f6eea29835b260aa5f678076d88fb5b2c697b4238b868a5c4c5f929330767022c53a98c3ef21d06b84fa1466431
languageName: node
linkType: hard
"@html-eslint/eslint-plugin@npm:0.61.0":
version: 0.61.0
resolution: "@html-eslint/eslint-plugin@npm:0.61.0"
"@html-eslint/eslint-plugin@npm:0.62.0":
version: 0.62.0
resolution: "@html-eslint/eslint-plugin@npm:0.62.0"
dependencies:
"@eslint/plugin-kit": "npm:^0.4.1"
"@html-eslint/core": "npm:^0.61.0"
"@html-eslint/parser": "npm:^0.61.0"
"@html-eslint/template-parser": "npm:^0.61.0"
"@html-eslint/template-syntax-parser": "npm:^0.61.0"
"@html-eslint/types": "npm:^0.61.0"
"@html-eslint/core": "npm:^0.62.0"
"@html-eslint/parser": "npm:^0.62.0"
"@html-eslint/template-parser": "npm:^0.62.0"
"@html-eslint/template-syntax-parser": "npm:^0.62.0"
"@html-eslint/types": "npm:^0.62.0"
"@rviscomi/capo.js": "npm:^2.1.0"
html-standard: "npm:^0.0.13"
peerDependencies:
eslint: ">=8.0.0 || ^10.0.0-0"
checksum: 10/ef2a4e550ecea2d8dea786a09a68ffbd3cd7fae0d9bed05e012e37dcffaa358aaeba5e61ecae2029e7522e14d709971511d065ada3fd1dd1c0caed8496f4bc3d
checksum: 10/3a103e6ea40632a562d9123a3645685c0d986f0ae1794df4d6a074fa40f2ce934059ad49df92d58c00c34d1d59ee7491fa68a74dd3c821a9f9d6105e8f4a608c
languageName: node
linkType: hard
"@html-eslint/parser@npm:^0.61.0":
version: 0.61.0
resolution: "@html-eslint/parser@npm:0.61.0"
"@html-eslint/parser@npm:^0.62.0":
version: 0.62.0
resolution: "@html-eslint/parser@npm:0.62.0"
dependencies:
"@html-eslint/template-syntax-parser": "npm:^0.61.0"
"@html-eslint/types": "npm:^0.61.0"
"@html-eslint/template-syntax-parser": "npm:^0.62.0"
"@html-eslint/types": "npm:^0.62.0"
css-tree: "npm:^3.1.0"
es-html-parser: "npm:0.3.1"
checksum: 10/d0b864b159e2b69ed602a00e1cdfd842b1a67bbc85d5105376090a146da313f47107fb6a265219a9be1e60bcf97f5500637c91aac9abd4b25a873c0d69a5e237
checksum: 10/dd248ab52caa4d00c20d57ec9d62c7b677479a0a04809e33324f50bfe0b5cfcafa8d5a84a70eb3d90af2b8c72adc694578033c1d2b3365942eec4dd1c2e432ea
languageName: node
linkType: hard
"@html-eslint/template-parser@npm:^0.61.0":
version: 0.61.0
resolution: "@html-eslint/template-parser@npm:0.61.0"
"@html-eslint/template-parser@npm:^0.62.0":
version: 0.62.0
resolution: "@html-eslint/template-parser@npm:0.62.0"
dependencies:
"@html-eslint/types": "npm:^0.61.0"
"@html-eslint/types": "npm:^0.62.0"
es-html-parser: "npm:0.3.1"
checksum: 10/f44c7e9903366fc0f02c89fdc096e66f26f61d3be5fb88f582d39a5f3d6d831c33a60162005e5fbd643c70368929c6b568c97c5bc2fece5e789c9c2ab7aa9c27
checksum: 10/6da6d9ce0fd894a6eeda4d9f3a439aa65281cd84124b724709ea1d09c7bac5c391fef99115e35d2e1e1d3bf2821e315c83a50400c3dd537ef5c266a598ae43d9
languageName: node
linkType: hard
"@html-eslint/template-syntax-parser@npm:^0.61.0":
version: 0.61.0
resolution: "@html-eslint/template-syntax-parser@npm:0.61.0"
"@html-eslint/template-syntax-parser@npm:^0.62.0":
version: 0.62.0
resolution: "@html-eslint/template-syntax-parser@npm:0.62.0"
dependencies:
"@html-eslint/types": "npm:^0.61.0"
checksum: 10/d0b5c3fea0906e23c5cf98efcaee36933cfb2a436a6f3a09aa07854cced4616166827a83853b7f7f6dede0b398cb73dc290086ed0d030781fb68324f0ebcc7d4
"@html-eslint/types": "npm:^0.62.0"
checksum: 10/a6cd8729be8cca7e923278ffa5ba2c3b9898a9e0d55dc6565319fb411a00079ff64ef702f15dca9be4891d33d720a5f5893fc2b55040d3ad29d91c4754d9af97
languageName: node
linkType: hard
"@html-eslint/types@npm:^0.61.0":
version: 0.61.0
resolution: "@html-eslint/types@npm:0.61.0"
"@html-eslint/types@npm:^0.62.0":
version: 0.62.0
resolution: "@html-eslint/types@npm:0.62.0"
dependencies:
"@types/css-tree": "npm:^2.3.11"
"@types/estree": "npm:^1.0.6"
es-html-parser: "npm:0.3.1"
checksum: 10/6c57f3363dc938ecd0cdfce4bd0f38c29aa05a4f80449af9450f2514f8692739af3c4e82a5764707aa2fb92b8442efdc80b2cebfec96060e8957ef5ac72d1aee
checksum: 10/4021626f1d075adf3785f6df5496495ffe25ded68dd09eab99dddd6a175e3346aea4370c69cf8f176fc8325510ee8c85733c086c215a6d9f455b55e1cc0fcfdb
languageName: node
linkType: hard
@@ -3456,7 +3456,7 @@ __metadata:
languageName: node
linkType: hard
"@rsbuild/plugin-check-syntax@npm:1.6.1":
"@rsbuild/plugin-check-syntax@npm:^1.6.1":
version: 1.6.1
resolution: "@rsbuild/plugin-check-syntax@npm:1.6.1"
dependencies:
@@ -3474,83 +3474,83 @@ __metadata:
languageName: node
linkType: hard
"@rsdoctor/client@npm:1.5.12":
version: 1.5.12
resolution: "@rsdoctor/client@npm:1.5.12"
checksum: 10/df3dbabf3629ec8bd98a6eb46ffac1a3b7019c99b24be12bc67e5d9bc2289d0b3a925ee658609257df47187e614fa53ad3123f07e4b90d3fc5b79b009863a19c
"@rsdoctor/client@npm:1.5.13":
version: 1.5.13
resolution: "@rsdoctor/client@npm:1.5.13"
checksum: 10/3c2917c79a4fe371a83cbead6263705a0761814710a807e61d5694090dae677ca7c73427e4470f587cc313ea2326f80018fb18475fcb2963e2ac39dc3f818fa9
languageName: node
linkType: hard
"@rsdoctor/core@npm:1.5.12":
version: 1.5.12
resolution: "@rsdoctor/core@npm:1.5.12"
"@rsdoctor/core@npm:1.5.13":
version: 1.5.13
resolution: "@rsdoctor/core@npm:1.5.13"
dependencies:
"@rsbuild/plugin-check-syntax": "npm:1.6.1"
"@rsdoctor/graph": "npm:1.5.12"
"@rsdoctor/sdk": "npm:1.5.12"
"@rsdoctor/types": "npm:1.5.12"
"@rsdoctor/utils": "npm:1.5.12"
"@rspack/resolver": "npm:0.2.8"
browserslist-load-config: "npm:^1.0.1"
es-toolkit: "npm:^1.45.1"
"@rsbuild/plugin-check-syntax": "npm:^1.6.1"
"@rsdoctor/graph": "npm:1.5.13"
"@rsdoctor/sdk": "npm:1.5.13"
"@rsdoctor/types": "npm:1.5.13"
"@rsdoctor/utils": "npm:1.5.13"
"@rspack/resolver": "npm:^0.2.8"
browserslist-load-config: "npm:^1.0.2"
es-toolkit: "npm:^1.47.0"
filesize: "npm:^11.0.17"
fs-extra: "npm:^11.1.1"
semver: "npm:^7.7.4"
source-map: "npm:^0.7.6"
checksum: 10/1e767fc250e30d34ca7821498ba66ecf9695db46d14dc050e02fcaf04c3cc74e783ec6298ce98e8eca96ff30ef32f7c166ad0d3ad33bf4d55a6bdcf65ca99dfb
checksum: 10/5a482394a7c9374cff14d4422277d2d8fbcd0397325e936a6785871800e0891633a79bc850c331a2190b4b362efedeac4d9240f2f0255a6482e36cfbadee0874
languageName: node
linkType: hard
"@rsdoctor/graph@npm:1.5.12":
version: 1.5.12
resolution: "@rsdoctor/graph@npm:1.5.12"
"@rsdoctor/graph@npm:1.5.13":
version: 1.5.13
resolution: "@rsdoctor/graph@npm:1.5.13"
dependencies:
"@rsdoctor/types": "npm:1.5.12"
"@rsdoctor/utils": "npm:1.5.12"
es-toolkit: "npm:^1.45.1"
"@rsdoctor/types": "npm:1.5.13"
"@rsdoctor/utils": "npm:1.5.13"
es-toolkit: "npm:^1.47.0"
path-browserify: "npm:1.0.1"
source-map: "npm:^0.7.6"
checksum: 10/8eddfdb217a36f746e1a5e4dd8e9834b8a5a7b5dd55f8db5e6016ced2dc8bd87928848677315ad5246bd76754cde9703b327f47318d8d6b94c8a21b4d1d5b623
checksum: 10/0e4bb1c053a580f37fb2df5c054c4869da2c72f8a51ed86dc097f0309ca9f1621523026747e7d05dd5244cd6d6b929e2ba585b1d16004e81723e3c8d4c43adb4
languageName: node
linkType: hard
"@rsdoctor/rspack-plugin@npm:1.5.12":
version: 1.5.12
resolution: "@rsdoctor/rspack-plugin@npm:1.5.12"
"@rsdoctor/rspack-plugin@npm:1.5.13":
version: 1.5.13
resolution: "@rsdoctor/rspack-plugin@npm:1.5.13"
dependencies:
"@rsdoctor/core": "npm:1.5.12"
"@rsdoctor/graph": "npm:1.5.12"
"@rsdoctor/sdk": "npm:1.5.12"
"@rsdoctor/types": "npm:1.5.12"
"@rsdoctor/utils": "npm:1.5.12"
"@rsdoctor/core": "npm:1.5.13"
"@rsdoctor/graph": "npm:1.5.13"
"@rsdoctor/sdk": "npm:1.5.13"
"@rsdoctor/types": "npm:1.5.13"
"@rsdoctor/utils": "npm:1.5.13"
peerDependencies:
"@rspack/core": "*"
peerDependenciesMeta:
"@rspack/core":
optional: true
checksum: 10/808d1b06e5016d02f5bbeb0c1883ab88b802ee15c9d6cad5787446c689af8d44ce1950d472b645bbc0e8e45a507f226678f750708c2a65c49fcc6dca21436aa4
checksum: 10/539e98a69babf928f3c74f8f395c1e7375fc0021ba6f3bcbd95398c005ebc32187ee814cbefcabc3db7c8f01c10658be59be86327432fb282577493535b9722e
languageName: node
linkType: hard
"@rsdoctor/sdk@npm:1.5.12":
version: 1.5.12
resolution: "@rsdoctor/sdk@npm:1.5.12"
"@rsdoctor/sdk@npm:1.5.13":
version: 1.5.13
resolution: "@rsdoctor/sdk@npm:1.5.13"
dependencies:
"@rsdoctor/client": "npm:1.5.12"
"@rsdoctor/graph": "npm:1.5.12"
"@rsdoctor/types": "npm:1.5.12"
"@rsdoctor/utils": "npm:1.5.12"
"@rsdoctor/client": "npm:1.5.13"
"@rsdoctor/graph": "npm:1.5.13"
"@rsdoctor/types": "npm:1.5.13"
"@rsdoctor/utils": "npm:1.5.13"
launch-editor: "npm:^2.13.2"
safer-buffer: "npm:2.1.2"
socket.io: "npm:4.8.1"
tapable: "npm:2.3.3"
checksum: 10/c37158ab3c524d095e8844d96273632c5e10516ac93c79f4e5ae09cd9e52a60e34393b40fe5f83c058d08d82757c42eb9ff7080269a40bdef6191e8f19236704
checksum: 10/02245b485add981c3cfc2fab613fea9f58659c1cc48aedb7dfe472c173c0f8d0455bab4fc638fa096e461142e0e21465705ec29eba9372546c6699792e6fff25
languageName: node
linkType: hard
"@rsdoctor/types@npm:1.5.12":
version: 1.5.12
resolution: "@rsdoctor/types@npm:1.5.12"
"@rsdoctor/types@npm:1.5.13":
version: 1.5.13
resolution: "@rsdoctor/types@npm:1.5.13"
dependencies:
"@types/connect": "npm:3.4.38"
"@types/estree": "npm:1.0.5"
@@ -3564,16 +3564,16 @@ __metadata:
optional: true
webpack:
optional: true
checksum: 10/f9a7c5680b349b9341547980c5e70280ea3344299a017b3f03e622003f057ef1c004b34400fad295d7457f866007c83dab9c8f0f03b9f7d95e1800d63c8097f1
checksum: 10/c3e2f35d8157ba833926b55265faee27fad877e196dba2f7155c246d7440208d0fa42e5d7809bb1ab698ddfe054c41d8813f10435a4f6f110294a1bd4cfd24c4
languageName: node
linkType: hard
"@rsdoctor/utils@npm:1.5.12":
version: 1.5.12
resolution: "@rsdoctor/utils@npm:1.5.12"
"@rsdoctor/utils@npm:1.5.13":
version: 1.5.13
resolution: "@rsdoctor/utils@npm:1.5.13"
dependencies:
"@babel/code-frame": "npm:7.26.2"
"@rsdoctor/types": "npm:1.5.12"
"@rsdoctor/types": "npm:1.5.13"
"@types/estree": "npm:1.0.5"
acorn: "npm:^8.10.0"
acorn-import-attributes: "npm:^1.9.5"
@@ -3585,57 +3585,57 @@ __metadata:
json-stream-stringify: "npm:3.0.1"
lines-and-columns: "npm:2.0.4"
picocolors: "npm:^1.1.1"
rslog: "npm:^2.1.1"
rslog: "npm:^2.1.2"
strip-ansi: "npm:^6.0.1"
checksum: 10/11dc306eb6e2c325b644f45d57e5afb4e7c7d38924d52a8ebdef4276406074ff700c218b3bf7eb875cd9ceab3e546cb8c664df245076dca065dabceacd378feb
checksum: 10/59289ae18a781dd44510c3171e69eff63645cc76cad093d0b078a3fb8d2d0f12b5e08d333606b25f9b1755a980453ca6edd0629a13725712a113eaa8f3de2e2e
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-darwin-arm64@npm:2.0.6"
"@rspack/binding-darwin-arm64@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-darwin-arm64@npm:2.0.8"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-darwin-x64@npm:2.0.6"
"@rspack/binding-darwin-x64@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-darwin-x64@npm:2.0.8"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.0.6"
"@rspack/binding-linux-arm64-gnu@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.0.8"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-linux-arm64-musl@npm:2.0.6"
"@rspack/binding-linux-arm64-musl@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-linux-arm64-musl@npm:2.0.8"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-linux-x64-gnu@npm:2.0.6"
"@rspack/binding-linux-x64-gnu@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-linux-x64-gnu@npm:2.0.8"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-linux-x64-musl@npm:2.0.6"
"@rspack/binding-linux-x64-musl@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-linux-x64-musl@npm:2.0.8"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-wasm32-wasi@npm:2.0.6"
"@rspack/binding-wasm32-wasi@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-wasm32-wasi@npm:2.0.8"
dependencies:
"@emnapi/core": "npm:1.10.0"
"@emnapi/runtime": "npm:1.10.0"
@@ -3644,41 +3644,41 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.0.6"
"@rspack/binding-win32-arm64-msvc@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.0.8"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.0.6"
"@rspack/binding-win32-ia32-msvc@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.0.8"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding-win32-x64-msvc@npm:2.0.6"
"@rspack/binding-win32-x64-msvc@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding-win32-x64-msvc@npm:2.0.8"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/binding@npm:2.0.6"
"@rspack/binding@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/binding@npm:2.0.8"
dependencies:
"@rspack/binding-darwin-arm64": "npm:2.0.6"
"@rspack/binding-darwin-x64": "npm:2.0.6"
"@rspack/binding-linux-arm64-gnu": "npm:2.0.6"
"@rspack/binding-linux-arm64-musl": "npm:2.0.6"
"@rspack/binding-linux-x64-gnu": "npm:2.0.6"
"@rspack/binding-linux-x64-musl": "npm:2.0.6"
"@rspack/binding-wasm32-wasi": "npm:2.0.6"
"@rspack/binding-win32-arm64-msvc": "npm:2.0.6"
"@rspack/binding-win32-ia32-msvc": "npm:2.0.6"
"@rspack/binding-win32-x64-msvc": "npm:2.0.6"
"@rspack/binding-darwin-arm64": "npm:2.0.8"
"@rspack/binding-darwin-x64": "npm:2.0.8"
"@rspack/binding-linux-arm64-gnu": "npm:2.0.8"
"@rspack/binding-linux-arm64-musl": "npm:2.0.8"
"@rspack/binding-linux-x64-gnu": "npm:2.0.8"
"@rspack/binding-linux-x64-musl": "npm:2.0.8"
"@rspack/binding-wasm32-wasi": "npm:2.0.8"
"@rspack/binding-win32-arm64-msvc": "npm:2.0.8"
"@rspack/binding-win32-ia32-msvc": "npm:2.0.8"
"@rspack/binding-win32-x64-msvc": "npm:2.0.8"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
@@ -3700,15 +3700,15 @@ __metadata:
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/c2e5245abab3257d02f5d98947fad26c8de1b18bb17362734035cfbdd725d9c6c78432372bdff985b32fa4062059d7210e9f5ea7314ae3080805b64f616fe348
checksum: 10/aface75866ff0bcd4934fda26e856e8de63e710a1489e654f1c6e5108d6ca46d2183b01aad2a76db1511e99843522272882ece53c2a4cf9fbfe0ac5ab5bcd5c2
languageName: node
linkType: hard
"@rspack/core@npm:2.0.6":
version: 2.0.6
resolution: "@rspack/core@npm:2.0.6"
"@rspack/core@npm:2.0.8":
version: 2.0.8
resolution: "@rspack/core@npm:2.0.8"
dependencies:
"@rspack/binding": "npm:2.0.6"
"@rspack/binding": "npm:2.0.8"
peerDependencies:
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
"@swc/helpers": ^0.5.23
@@ -3717,7 +3717,7 @@ __metadata:
optional: true
"@swc/helpers":
optional: true
checksum: 10/d2417690e8135342179bc9e5035e16fe827522b4c0babef029a21ff5903cd56c09b86f08924527bd7d3e66f178f1f678ce099199cac8c1a137b18c5d8892e613
checksum: 10/93e34b878dbc69c12f9b06909354246597a5c387c5df77f61e56f3a20e2b45434b9fa8734866f4e662ab0e3456064bf8133ae6f58a3afffee5053a27b8395195
languageName: node
linkType: hard
@@ -3827,7 +3827,7 @@ __metadata:
languageName: node
linkType: hard
"@rspack/resolver@npm:0.2.8":
"@rspack/resolver@npm:^0.2.8":
version: 0.2.8
resolution: "@rspack/resolver@npm:0.2.8"
dependencies:
@@ -5810,10 +5810,10 @@ __metadata:
languageName: node
linkType: hard
"browserslist-load-config@npm:^1.0.1":
version: 1.0.1
resolution: "browserslist-load-config@npm:1.0.1"
checksum: 10/872d2978d2546eb02920b7124d8269e10b3a8d26c1426f1ca844c0d4db53929789d1df5acd0b322b464af18264b58d0f3038a54656fe160c6dc1ab18b2d9491f
"browserslist-load-config@npm:^1.0.2":
version: 1.0.3
resolution: "browserslist-load-config@npm:1.0.3"
checksum: 10/3e981e30c09e802ff881f04a992cbbe40004b3086d7f5ae1d0c04ae3d00dc49d2068f3f79ce6bec4f73f77998451cd03d83b2f677e4ef116ef0dcfafd976b8c1
languageName: node
linkType: hard
@@ -7197,15 +7197,17 @@ __metadata:
languageName: node
linkType: hard
"es-toolkit@npm:^1.45.1":
version: 1.46.1
resolution: "es-toolkit@npm:1.46.1"
"es-toolkit@npm:^1.47.0":
version: 1.47.0
resolution: "es-toolkit@npm:1.47.0"
dependenciesMeta:
"@trivago/prettier-plugin-sort-imports@4.3.0":
unplugged: true
prettier-plugin-sort-re-exports@0.0.1:
unplugged: true
checksum: 10/15fa8e58848c3cf3f56b3fca6505362a7e19a6487613cd928197d11a12066010655ee47f74e5f412d949173f998df7ce7babcba9ff838bd40ce4ca79fca8f3c4
vitepress-plugin-sandpack@1.1.4:
unplugged: true
checksum: 10/3dcb898b69cb84fd5bd8a18a5a63b01d0b9fc4a74539d01c58869f5460e1402a6eb7b1260729a564043a6776c24c04b96d72883918c1fbed9ab5c91a5c064f80
languageName: node
linkType: hard
@@ -8449,7 +8451,7 @@ __metadata:
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
"@eslint/js": "npm:10.0.1"
"@formatjs/intl-datetimeformat": "npm:7.4.8"
"@formatjs/intl-datetimeformat": "npm:7.4.9"
"@formatjs/intl-displaynames": "npm:7.3.10"
"@formatjs/intl-durationformat": "npm:0.10.14"
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
@@ -8465,7 +8467,7 @@ __metadata:
"@fullcalendar/luxon3": "npm:6.1.20"
"@fullcalendar/timegrid": "npm:6.1.20"
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
"@html-eslint/eslint-plugin": "npm:0.61.0"
"@html-eslint/eslint-plugin": "npm:0.62.0"
"@lezer/highlight": "npm:1.2.3"
"@lit-labs/motion": "npm:1.1.0"
"@lit-labs/observers": "npm:2.1.0"
@@ -8482,8 +8484,8 @@ __metadata:
"@octokit/plugin-retry": "npm:8.1.0"
"@octokit/rest": "npm:22.0.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.5.12"
"@rspack/core": "npm:2.0.6"
"@rsdoctor/rspack-plugin": "npm:1.5.13"
"@rspack/core": "npm:2.0.8"
"@rspack/dev-server": "npm:2.0.3"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
@@ -8570,7 +8572,7 @@ __metadata:
node-vibrant: "npm:4.0.4"
object-hash: "npm:3.0.0"
pinst: "npm:3.0.0"
prettier: "npm:3.8.3"
prettier: "npm:3.8.4"
punycode: "npm:2.3.1"
qr-scanner: "npm:1.4.2"
qrcode: "npm:1.5.4"
@@ -11350,12 +11352,12 @@ __metadata:
languageName: node
linkType: hard
"prettier@npm:3.8.3":
version: 3.8.3
resolution: "prettier@npm:3.8.3"
"prettier@npm:3.8.4":
version: 3.8.4
resolution: "prettier@npm:3.8.4"
bin:
prettier: bin/prettier.cjs
checksum: 10/4b3b12cbb29e4c96bed936e5d070167552500c18d37676fb3e0caae6199c42860662608e4dc116230698f6e2bb0267ef2548158224c92d40f188d309d72fdd6f
checksum: 10/54684a3cc6689238692b29fab541c01934af7677be94c02293ba49981a1ac121c8bebe2a865f0c3b963e99d208f847c53aed354cc0ce8750e2d45791d64506c5
languageName: node
linkType: hard
@@ -12012,10 +12014,10 @@ __metadata:
languageName: node
linkType: hard
"rslog@npm:^2.1.1":
version: 2.1.1
resolution: "rslog@npm:2.1.1"
checksum: 10/acdbcca91710a468eff2fc4441ed73991e1c2e234708de2aeda2b6c59469d3c4bce7b10bbad14dc97a52f613c0516e7bfd1049594f84766c9d65cf6ac6aa37e1
"rslog@npm:^2.1.2":
version: 2.1.3
resolution: "rslog@npm:2.1.3"
checksum: 10/2d64dc30e425665854619be879c58bf37bf6d76f7556d5cf41f97f221dd0ec0f4c43e0b733e14c536b8967878f8c9e1f717ddcb0de23239aa83cc9c3e96051f2
languageName: node
linkType: hard