Compare commits

..
Author SHA1 Message Date
Paul Bottein 9ca5f1fa70 Add a checkbox to use the device name for entities 2026-09-07 11:32:03 +02:00
renovate[bot]andGitHub b29b0bd083 Update dependency @codemirror/view to v6.43.11 (#54010) 2026-09-07 09:24:46 +01:00
ae8aa3813b Add Radio frequency to the demo (#53976)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-09-07 08:27:43 +01:00
Petar PetrovandGitHub 770caf62f7 Prompt for unsaved changes on history back navigation (#53919) 2026-09-07 09:17:19 +02:00
ivenandGitHub 8fed3ac0c8 Use a readable text color for calendar events (#54009) 2026-09-07 06:54:52 +00:00
98f5bdd703 Add Tags to the demo (#53977)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-09-07 08:39:04 +02:00
karwostsandGitHub 8e13ba872d Fix 'select all with issues' (#54007) 2026-09-07 08:35:46 +02:00
DominikandGitHub 37433c787f Adjust clock and notification drawer backdrop (#54012) 2026-09-07 06:22:14 +00:00
dc7ee8dc5a Serial: send consumers to their own config panel (#54000)
* Serial: send consumers to their own config panel

A port used by Zigbee or Z-Wave led to that integration's entry on the
integration page, which is a device list — not where the radio on that port is
managed. Point those consumers at the integration's own panel instead.

Which panel that is was already decided in `ha-config-entry-row`, from the
panels an integration registers at runtime falling back to
`integrationsWithPanel`. That resolution moves to `getConfigPanelPath` so both
callers stay in step, and so a port picks up any panel the integration page
would already link to.

A consumer that is not running keeps its integration-page link: its panel is
not loaded to receive it.

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

* Serial: send the Thread radio to the Thread panel

A Thread radio is held by the Open Thread Border Router app rather than by a
config entry, so it never reached the panel resolution. Map the app to the
integration behind it and look the panel up from there.

The panel is only offered when its integration is loaded, which an app being
started does not imply.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-06 08:27:25 +02:00
karwostsandGitHub ddc99501af Handle sequence: null in script. (#53999) 2026-09-06 08:25:35 +02:00
karwostsandGitHub 5ec87269dc Show errors in blueprint editor (#53996) 2026-09-06 08:24:35 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
53cf986491 Update dependency @lokalise/node-api to v16.4.1 (#53997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-06 08:24:19 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0388cb8cd0 Update dependency hls.js to v1.7.2 (#53998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-06 08:24:16 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cfefa39b84 Bump the codeql-action group across 1 directory with 2 updates (#54002)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.8 to 4.37.9
- [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/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `github/codeql-action/analyze` from 4.37.8 to 4.37.9
- [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/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-06 08:23:44 +02:00
5a907614af Add Thread to the demo (#53970)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Thread to the demo

Load the thread and otbr integrations and mock router discovery, dataset
listing and border router info, so the Thread panel renders in the demo.
The preferred network holds Home Assistant's own border router alongside a
HomePod mini and a Nest Hub; an Amazon Echo forms the one other network.

Widens ThreadRouter's brand union with "amazon", which the backend
already returns. The union is still not exhaustive.

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

* Mock the Thread actions and match the dataset casing

The extended PAN ID was lowercase while the TLV string carries it uppercase.
The dataset dialog looks for one inside the other with a case-sensitive
`includes`, so the preferred network's info showed neither the border
router's URL nor its active dataset.

The panel also offers adding a dataset, choosing the preferred network and
border router, resetting the border router and changing its channel, none of
which were mocked, so each rejected as unimplemented. They now change the
mocked data, and `otbr/set_channel` answers with the delay the panel reads
back.

`ThreadRouter["brand"]` is no longer a union. It only reaches `brandsUrl`,
and the backend resolves it from the border agent's vendor name, so every
new vendor meant another frontend change for no typing benefit.

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

* Push Thread network changes to the discovery stream

The panel groups the routers it draws from `thread/discover_routers`, not
from `otbr/info`, so changing the network only in the info left the border
router drawn under its old network for good. The mock now keeps its
subscribers and announces the router again when it moves.

A reset also has to put the border router on a network of its own instead of
reusing the existing extended PAN ID, which the panel groups by, and it adds
that network rather than taking over the preferred one, so the way back onto
the original network stays available.

`ThreadRouter["brand"]` is null for a vendor the backend does not know, and
the panel now leaves out the icon in that case rather than requesting one
that cannot exist.

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

* Build the Thread datasets from real TLVs

The pasted dataset TLV was malformed: an odd number of hex digits, a network
name field declaring ten bytes for nine, and a network key that ran into the
next type, so a decoder derailed partway through. Datasets are now assembled
from type/length/value triplets, which keeps the declared lengths honest and
each dataset's own extended PAN ID and network name inside its TLV.

Each dataset carries its own, so `thread/get_dataset_tlv` answers for the
dataset it was asked about and rejects an unknown one, and moving the border
router carries the active dataset with it rather than leaving the previous
network's behind, which would have hidden the URL again on the new network.

An imported dataset takes its extended PAN ID from the type 0x02 field
instead of the first sixteen characters, which were the timestamp, and a
reset generates eight hexadecimal bytes rather than a value starting
"RESET".

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

* Import Thread datasets from the credentials given

The import invented the channel, network name and PAN ID and stored a
rebuilt TLV, so importing real credentials showed a different network than
the one submitted and read back different credentials. It now takes all four
fields from the TLV and keeps that TLV verbatim.

Anything that does not decode is refused rather than falling back to a
generated ID, so the panel shows its error path for malformed credentials.

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

* Treat a Thread import as a revision of the network it names

Datasets sharing an extended PAN ID are revisions of one network, so
importing credentials for a network that is already known updates it rather
than adding a second card for it, and only a newer active timestamp wins.
Re-importing the credentials the demo itself shows used to duplicate the
network.

The active timestamp is now required, as the backend requires it, and a
reset leaves the default router unset, since nominating one is its own
action in the panel.

A router without a brand no longer reserves the avatar column, so its name
is not left indented against an empty graphic.

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

* Keep the Thread dataset, credentials and channel in step

Deleting a dataset left its credentials behind, so reading them back returned
a network the panel had already dropped. They go with it now.

Changing the channel only moved the border router's reported channel, leaving
the dataset and its active TLV on the old one, so the channel prompt and the
dataset dialog disagreed on the next read. A reset had the mirror problem,
installing a channel 15 network without moving the reported channel. Both
carry the dataset, its TLV and the channel together.

Network names are decoded as UTF-8 rather than one character per byte, so a
non-ASCII name reads correctly and a sequence the backend would refuse is
refused here.

Adds a demo test for the panel: the two networks it starts with, the reset
and rejoin that only show up if the discovery subscription is told, and
credentials being per dataset and going away with one. Checked that it bites
by dropping the announcement, which fails it on the card count after a reset.

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

* Refuse repeated tags and a zero channel in a Thread dataset

A repeated type is malformed and the backend's parser refuses it, rather
than letting the later one win, and a channel field carrying zero is not a
channel it accepts. An absent channel is still fine.

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

* Change the Thread channel without rewriting the credentials

Rebuilding the dataset TLV from its summary replaced the network key, PSKc,
mesh-local prefix, security policy and anything else this mock does not
model with fixture constants, so changing the channel on an imported network
silently swapped its credentials for someone else's. Only the channel
triplet is rewritten now, leaving the rest of the TLV as it was.

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

* Drop the remaining comments from the Thread mock

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-06 08:23:07 +02:00
c325392e79 Add Zigbee to the demo (#53971)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Zigbee to the demo

Load the zha integration and mock its devices, groups, configuration and
network settings, so the Zigbee panel, its options, groups, network info
and network map render in the demo. Neighbor tables give the map a mesh of
routers and end devices.

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

* Give the Zigbee temperature sensor a unique entity ID

sensor.bedroom_temperature already exists in the home demo config, whose
entities are added last and overwrote the display registry entry,
detaching it from its Zigbee device and undercounting the panel's
entities on that demo.

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

* Label the Zigbee options and take the offline plug unavailable

The options page localizes every field through the backend translations and
falls back to the raw identifier, so the pages showed names like
alarm_master_code. Seed a key per schema field, for the global options as
well as the alarm ones.

The panel reports the TV plug as offline, so its entity has to agree.

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

* Mock the Zigbee panel's write actions

Saving either options page, downloading a backup, changing the channel and
adding or removing a group or its members all rejected as unimplemented, so
every one of those visible controls failed. They now change the mocked state
that the panel refetches.

Saving merges into the stored configuration rather than replacing it, the
way the backend does, so saving one section leaves the rest alone.

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

* Seed the ZHA unavailability timeouts and copy the configuration

The options page falls back to two hours when these keys are absent, so the
battery timeout showed 2 hours where ZHA's default is 6. Both are seeded
now.

The configuration response handed out the backing object, and both editors
mutate the fetched data as controls change, so an edit the user never saved
survived leaving the page. It is copied on the way out; only the update
command writes to the stored configuration.

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

* List the groupable Zigbee devices

The create group page and the add members dialog build their pickers from
this command, so returning nothing left both permanently empty and made the
group commands unreachable from the panel. The lights and plugs are listed
now.

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

* Keep the Zigbee pairing page, backups and group rows honest

The add device page subscribes to the permit command as soon as it opens, so
with nothing registered it sat on its spinner for the full permit duration
behind a rejected subscription. A subscription that simply stays open is
enough, since nothing pairs in the demo.

A backup shared its network and node objects with the live settings, so
changing the channel afterwards rewrote the stored backup, which is the one
thing a backup must not do. They are copied now.

Group endpoints carried no entities, so both pickers labelled every light
and plug "No entities". Each now carries the entity the fixtures give it.

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

* Wire up the ZHA device pages in the demo

The ZHA info card and device actions look up the device's `zigbee`
connection and render nothing without it, so the demo devices only ever
showed the generic device page. Add the connection, and mock the two
commands that become reachable through it: the manage page's cluster
list (with each cluster's attributes and commands, so the attribute and
command tabs are usable), and the reconfiguration subscription, which
now walks the device's clusters and terminates instead of rejecting.

Written attributes are remembered, so reading one back agrees with what
the write button reported.

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

* Carry the registry area on the ZHA device payloads

The group pickers read the area straight off the device payload rather
than resolving the device registry, so the plugs listed no area. The
backend fills it in from the registry; derive it from the registry
fixtures here for the same reason, so the two cannot drift apart.

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

* Make the Zigbee bindings tab work

Both bind buttons ended in their error state: `zha/groups/bind` and
`zha/groups/unbind` were never registered. Registering them is not
enough on its own — group binding lists only a device's client side, so
with every mocked cluster on the server side the table read "No data"
and the button could never be enabled. The remotes now carry the `out`
clusters they would have on real hardware, and `zha/devices/bindable`
returns the routers, so the device half of the tab is no longer hidden.

Also drops the `any` from the configuration update payload, which
already has an exact type.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-05 09:54:59 +02:00
241b8c5771 Add Z-Wave to the demo (#53972)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Z-Wave to the demo

Load the zwave_js integration and mock its network status, provisioning
entries and node and controller statistics, so the Z-Wave panel, its
options, statistics, network info, provisioned devices and network map
render in the demo. Last working routes give the map a mesh with
repeaters, asleep and dead nodes.

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

* Give the Z-Wave motion sensor a unique entity ID

binary_sensor.kitchen_motion already exists in the home demo config, whose
entities are added last and overwrote the display registry entry,
detaching it from its Z-Wave device and undercounting the panel's entities
on that demo.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-04 16:20:53 +00:00
06e55141f5 Add MQTT to the demo (#53973)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add MQTT to the demo

Load the mqtt integration and mock topic subscriptions and device debug
info, so the MQTT panel renders in the demo and its listen card streams
messages on the topic you subscribe to.

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

* Give the MQTT sensor entities unique entity IDs

sensor.kitchen_temperature already exists in the home demo config, whose
entities are added last and overwrote the display registry entry,
detaching it from its MQTT device and undercounting the panel's entities
on that demo.

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

* Key the MQTT debug info by device and send real payloads

The debug info ignored the requested device, so the garage door's MQTT info
showed the fridge sensor's entity. Return the entities of the device that
was asked for, and nothing for one that is not MQTT.

The discovery payload is the config object, not a JSON string:
`mqtt-discovery-payload` dumps it as YAML, so a string rendered as one
quoted scalar. The frontend type said string while the component already
required an object; it now matches what the backend sends.

A subscription takes a topic filter, so echoing it back as the message topic
invented topics no broker could publish. Wildcard levels now resolve to a
concrete matching topic.

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

* Allow an empty MQTT discovery payload

An entity with debug traffic but nothing discovered has no config, and the
backend sends an empty string, so narrowing the field to an object alone was
wrong in the other direction.

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-09-04 15:55:21 +00:00
f6751a8c44 Add Infrared to the demo (#53975)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Infrared to the demo

Load the infrared integration and add the emitter and receiver proxy
entities it reads, so the Infrared panel and its device list render in the
demo. The panel is entity driven, so it needs no WebSocket mock.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-04 17:48:40 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
1fb138337a Update dependency @rspack/core to v2.2.2 (#53982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-04 17:36:36 +02:00
50df620a01 Add Matter to the demo (#53968)
* Add Matter to the demo

Load the matter integration and mock its network topology, so the Matter
panel and its network map render in the demo, with a Thread and Wi-Fi
topology that includes a border router and an offline node.

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

* Key Matter node diagnostics by device

The diagnostics and ping mocks ignored the requested device, so every
Matter device reported node 1 on Thread and available. The device page
gates its actions on that: the offline garden sensor offered actions that
need a live node, and the Wi-Fi plug offered a Thread network link.

Both are now derived from the topology the map already renders, so a
device's node ID, transport, node type and availability match it, and an
unknown device is rejected as the backend would.

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

* Give Matter fixtures unique entity IDs and mock its device actions

lock.front_door and sensor.garden_temperature already exist in the home
demo config, whose entities are added last and overwrote the display
registry entries, detaching them from their Matter devices. The panel
counted 3 entities instead of 5 on that demo. Renamed to a side door lock
and a patio sensor, which no demo config uses.

The device page offers commissioning, fabric and credential actions for an
available node, none of which were mocked, so each failed with
command_not_mocked.

Adds a demo E2E test for the panel and its map. Emptying the topology
subscription fails it.

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

* Mock the Matter lock services and fix the commissioning codes

The lock device exposes "Manage lock", whose dialog reads back the response
of matter.get_lock_info and matter.get_lock_users; neither was mocked, so
it always showed its load-failed alert.

setup_pin_code carried the manual pairing code. The three commissioning
codes now agree on the Matter test payload for passcode 20202021.

Pinging an unknown device rejects like the diagnostics command, matching
the backend, which resolves the device before acting either way.

The E2E test now reads the diagnostics of a Thread, a Wi-Fi and an offline
device, so a regression back to one shared response fails it.

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

* Return a credential result and gate the Matter Thread action

`setMatterLockCredential` reads `user_index` off the per-entity response, so
the demo's empty object made saving a code throw. Return the indices the
dialog reads back, and drop the mock for a `clear_lock_credential` service
the frontend never calls.

The device action linking to the Thread panel did not check that the
integration is loaded, unlike the same link on the Matter dashboard, so it
could navigate to a panel that is not there.

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

* Keep the mocked Matter lock users across changes

The manage dialog reloads the list after every add, edit and delete, so the
static response made each change look like it was reverted. Keep the users
per lock entity and mutate them, and hand back a copy so the dialog's
reactive state sees a new list.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-04 17:34:09 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e581172474 Update dependency globals to v17.12.0 (#53981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-04 13:25:55 +02:00
59f16eeca2 Add Serial to the demo (#53974)
* Add Bluetooth to the demo

Load the bluetooth integration and mock its scanner, advertisement and
connection subscriptions, so the Bluetooth panel, its adapter info,
monitors and network map render in the demo.

Also adds the scaffolding the other Settings > Connectivity panels build
on: a per-integration fixtures contract, registry builders, and the two
registries that collect components, WS command prefixes and registry data
from each integration. `addEntities` now carries `device_id` and
`platform` into the mocked display entity registry, which the panels use
to count entities per device and per integration.

Mocked subscriptions emit their first message from a timeout. Subscribing
is synchronous in the mock, so an immediate callback lands before the
subscriber is ready: `createCollection` overwrote it with its empty
initial fetch, and pages that ignore messages received before their first
render dropped it.

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

* Serve integration manifests for the demo's config entries

The integration page reads back the manifest it fetches without guarding,
so opening one for a domain the demo had no manifest for threw. Bluetooth
now ships its manifest through the connectivity fixtures, and manifest/get
falls back to a generated manifest rather than answering with undefined,
which also covers the entries that already lacked one.

Adds a demo E2E test that opens the Bluetooth panel and asserts its counts
are non-zero. The connectivity mocks reach the panel through lazy mock
registration and deferred first emissions, neither of which the demo suite
covered; without them the panel renders but stays empty.

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

* Cover the Bluetooth scanner details subscription in the demo E2E test

The dashboard's adapter count comes from the config entries, so the test
passed even with the scanner details subscription broken, while the
adapter page and network map stayed incomplete.

The adapter page renders a settings button only for adapters that are not
remote scanners, which it knows from the scanner type in those details, so
asserting one button across the three adapters covers the subscription
without asserting on copy.

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

* Keep connectivity entities when switching demos

Switching demos replaces the whole state map, and only the energy entities
were added back, so every connectivity state disappeared until a reload.

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

* Add Serial to the demo

Load the usb integration and mock the serial port listing, so the Serial
panel renders in the demo with connected, available and disconnected
ports.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-04 13:19:36 +02:00
1cda8ee42d Add Bluetooth to the demo (#53967)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-09-04 12:30:33 +02:00
87a4a70352 Fix websocket hang in picture card (#53930)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 12:23:16 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
046cf4de14 Update dependency typescript-eslint to v8.69.0 (#53961)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 19:57:06 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
d0d5b93106 Update CodeMirror (#53960)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 19:56:25 +02:00
Paul BotteinandGitHub 3f2c0dae1b Only select ha-input-copy text on click when readonly (#53959) 2026-09-03 19:43:22 +02:00
Aidan TimsonandGitHub 5dea28504e Improve config panels type safety (#53955)
* Improve config type safety

* Type config search event targets

* Type config event handler targets

* Restore
2026-09-03 15:46:28 +02:00
8e1c4e93cf Add reusable state class selector (#53880)
* Add state class selector

* Update src/components/ha-state-class-picker.ts

Co-authored-by: Paul Bottein <[email protected]>

* Fix linter issue

* Rename `state_classes_filter` to `state_classes` as suggested

* Update and rename stateClasses property

* Fis missed rename

* Add default values

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-09-03 15:42:38 +02:00
5513abbe8f Check if failed import is from us, or from user, and check if chunck 404's (#53940)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-03 15:15:46 +02:00
a9862a8f7c Clarify what uninstalling an app deletes (#53923)
* Clarify what uninstalling an app deletes

The uninstall dialog offered a single switch labeled "Also remove app
data". That named the wrong thing twice over: the switch sends
remove_config, which Supervisor applies to the app's configuration
folder, while the app's data folder is deleted on uninstall either way,
independent of the switch.

Relabel the switch after the folder it actually removes, and state in the
dialog that uninstalling permanently deletes the app's stored data, so
the unconditional part of the operation is no longer implied to be
optional.

Rename the local variable and the uninstallHassioAddon parameter to
removeConfig to match the API field they carry.

* Move the app name out of the uninstall dialog title

Dialog guidelines ask to keep user generated content out of titles, as
names can get long enough to be unreadable there. The name now carries
the sentence in the body, which already describes what is deleted.

* Scope the uninstall warning to the app's private data folder

Uninstalling deletes the app's data folder and, when the app maps it,
the public config folder. Files the app wrote through the share, media
or Home Assistant config mappings stay where they are, so promising
that all of its data goes away is wrong for those apps.

Name the folder the deletion is limited to, and say the configuration
folder is only deleted if the app uses one, matching how the Supervisor
API documents the field. Also switch the label to "delete" for
consistency with the sentence above it.

* Name what an app actually keeps in its data folder

Checked the 30 most installed apps for what they write there. It holds
databases (MariaDB, InfluxDB, Grafana, UniFi, Nginx Proxy Manager,
AdGuard), credentials and network identity (Matter fabrics, the Thread
network key, Tailscale node state, Z-Wave JS settings, Mosquitto
accounts) and internal state such as caches and SSH host keys.

"Any other files it created" reaches past all of that. ESPHome keeps
device configuration in the Home Assistant config folder, Node-RED sets
its user directory to /config, WireGuard writes its keys to /ssl, and
those survive an uninstall.

* Fix punctuation in uninstall dialog text

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-03 15:02:09 +02:00
WendelinandGitHub d23314f4ff History: Fix "add targets" on empty state (#53953)
Fix history empty targets button only on closed filterbar
2026-09-03 11:56:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fcc5b381d3 Bump fast-uri from 3.1.5 to 3.1.6 (#53946)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.6.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.6)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 06:23:38 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
564205a3f3 Update tsparticles to v4.4.0 (#53951)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 06:23:01 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
86fb936d14 Update dependency @octokit/auth-oauth-device to v8.0.5 (#53950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-03 06:22:44 +02:00
497b7fce49 Add offline devices count to Matter (#53949)
* Added offline devices count to Matter

* Change condition to check for unavailable nodes

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

* Improve offline devices error handling

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-03 06:22:26 +02:00
Paul BotteinandGitHub 251aac7e2e Hide empty labels row in more-info related view (#53939) 2026-09-02 19:16:01 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8d2106ccc8 Update dependency @formatjs/intl-datetimeformat to v7.6.1 (#53945)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-02 18:24:09 +02:00
Paul BotteinandGitHub 3de2637da7 Rename AI settings URL from /config/ai-tasks to /config/ai (#53933) 2026-09-02 18:05:59 +02:00
Jump-333andGitHub 36d048c8ab Add select-slider card feature for select/input_select tile control (#53876)
* Add select-slider card feature for select/input_select tile control

* Modified select-slider card feature to become just a style of the preexistent select-options

* Modified name icons to segmented and removed suggestion

* Fixed issues with punctuation and spaces

* Renamed segmented to buttons
2026-09-02 12:52:54 +00:00
Paul BotteinandGitHub 4d7d5231d6 Fix seek bar position jumps while dragging and seeking (#53924)
* Keep seek bar position while dragging

* Unify seek bar progress handling in a shared controller
2026-09-02 14:33:43 +03:00
Aidan TimsonandGitHub b5709a56b0 Wait for connectivity, serial, storage settings readiness (#53935)
* Wait for connectivity settings readiness

* Wait for serial settings readiness

* Wait for storage settings readiness

* Simplify delayed serial scenario

* Test delayed connectivity readiness
2026-09-02 14:25:27 +03:00
Bram KragtenandGitHub 1b834303d0 Fix stale trigger/condition lists (#53932) 2026-09-02 10:44:43 +02:00
Petar PetrovandGitHub b455a34998 Only pan sankey charts on touch devices once zoomed (#53931) 2026-09-02 10:36:09 +02:00
Paul BotteinandGitHub d84ef6b1bb Fit the picker popover to its list and align style with the select menu (#53801)
* Fit the picker popover to its list and align it with the select menu

* Use styleMap for the popover custom properties

* Fix combo box list not rendering for direct embedders
2026-09-02 10:15:53 +02:00
10ae5c1dd6 Rename AI tasks settings to AI and add Model Context Protocol card (#53906)
* Rename AI tasks settings to AI and add Model Context Protocol card

The AI settings page now shows a second card for the Model Context
Protocol server. When no mcp_server config entry exists, a centered
button starts the config flow. When enabled, the card shows the server
URL, an alphabetical list of registered LLM APIs with copy buttons for
their URLs, a configure button that opens the options flow, and a
disable action that deletes the config entry.

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

* Mock MCP server config entry and LLM APIs in demo

Lets the demo show the enabled state of the Model Context Protocol
card on the AI settings page.

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

* Rename MCP server URL row and mock MCP in the demo

The row heading did not say what makes this endpoint different from the
per API endpoints below it. Call it "Your MCP URL" and show the config
entry title, so it is clear which APIs it serves.

Mock an MCP server config entry and the registered LLM APIs in the demo.

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

* Simplify MCP card URLs to labeled copy buttons

Build the URLs from the origin Home Assistant is browsed on, so the
copied URL is reachable for whoever copies it.

Drop the URL text from the card and label the copy action, which was an
unlabeled icon that did not say it copies a URL. Name the API section
Individual MCP APIs so it is clear those are MCP endpoints too.

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

* Move MCP disable action into a header overflow menu

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

* Align MCP card header actions and drop the disable icon

Put the help and overflow buttons in the header row so they line up with
each other and the title, instead of positioning them absolutely.

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

* Rename MCP card row to Your MCP API

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

* Drop trailing word from MCP card description

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

* Put configure before copy and drop Calendar from the demo

Ordering the configure button first lines the copy buttons up across all
rows. Calendar is not an LLM API integrations register, so mock only
Assist and Music Assistant.

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

* Always show the MCP configure button

The options flow is now in core, so the entry always supports options.

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

* Tell users where to add the MCP URL

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

* Link the MCP docs from the card description

Point at the integration documentation for agent-specific instructions,
and call them AI agents.

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

* Drop the MCP card help icon

The description links the documentation, so the header icon is a second
link to the same page.

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

* Show MCP URLs in outlined rows

Give each URL its own outlined row with the URL under the name, and copy
with an icon button, so the URLs are visible before copying.

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

* Use hassUrl for MCP URLs and show load errors

Build the URLs with the standard helper instead of the window location.

Catch a failing load and show an alert. Without it a failed load left
the card with neither the URLs nor the enable button.

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

* Show a spinner while the MCP card loads

Without it the card showed only its description until the config entries
arrived.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-02 08:38:21 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
74967ef6ee Update dependency generate-license-file to v4.2.5 (#53929)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-02 08:23:09 +03:00
Maarten LakerveldandGitHub 46de23727f Remove ES5 specific code (#53899)
* Remove web components polyfills and ES5 custom elements adapter

All supported browsers (legacy floor: Chrome 59, Safari/iOS 12, Firefox 94)
have native shadow DOM and custom elements, so the webcomponents bundle,
ShadyCSS branch, and lit polyfill-support are unreachable. The legacy build
now emits ES2017 classes, so custom-elements-es5-adapter and the
window.loadES5Adapter hook (no known third-party consumers) are removed.
Also stops shipping the never-loaded dialog-polyfill css and drops both
now-unused dependencies.

* Remove keyed-es5 Terser workaround

The custom keyed directive existed because Terser with ecma: 5 miscompiled
the destructured update() parameters (#28732). The legacy build now minifies
with ecma: 2017, so the stock lit-html keyed directive works in both builds.

* Remove old-browser JS shims and stale ES5 build references

Drops the IE-only navigator.msMaxTouchPoints check, replaces the
toggleAttribute helper with the native method (polyfilled automatically for
Chrome < 69 in the legacy build), and removes babel excludes for the
uninstalled proxy-polyfill and unfetch packages. Updates comments that
still described the legacy build as ES5.

* Remove vendor prefixes for no-longer-supported browsers

Deletes -ms- prefixes (IE/EdgeHTML only) and -webkit-/-moz- prefixed
declarations that every supported browser understands unprefixed, or that
Lightning CSS re-adds automatically from the unprefixed property in
production builds. Blocks that only had prefixed user-select now use the
standard property (previously Firefox got no user-select there at all).
Converts the four -webkit-linear-gradient() declarations - the sole
gradient syntax on those sliders - to standard linear-gradient().

* Remove dead html_url custom panel support

html_url pointed to an HTML Import, a Polymer-era feature removed from
Chrome in 2019 and never shipped elsewhere. The loader has not handled the
html type for years (it fell through to a rejection), and core's
panel_custom integration no longer accepts the option, so the branch was
unreachable. Also drops the ha-panel-${name} legacy tag naming that was
keyed on html_url.

* Repair list-plugins-and-polyfills script for Babel 8

The audit script died at startup since the Babel 8 update: preset-env no
longer exposes lib/debug.js (logPlugin is now inlined locally, built on the
public getInclusionReasons helper) and babel-plugin-polyfill-corejs3 v1 no
longer ships lib/shipped-proposals.js (list inlined).

Instead of invoking the preset with a hand-mocked plugin API - the part
that kept drifting - the plugin listing now runs a real transform of an
empty file with preset-env in debug mode, declaring the same caller
capabilities as babel-loader. The polyfill listing now passes the
configured core-js version to core-js-compat, mirroring the provider's own
filtering so the report cannot list modules the installed core-js lacks.
Output is byte-identical to the direct-invocation approach. Also documents
the script in the build-scripts README.

* Correct macOS floor for Safari 26 in companion app UA regex

Safari 26 ships for macOS 14 Sonoma and 15 Sequoia, not only macOS 26, so
the SAFARI_TO_MACOS entry breaking the minimum-supported-macOS pattern
would have sent updated macOS 14/15 companion apps to the legacy build
once the modern floor reaches Safari 26.

* Remove orphaned values left behind by vendor prefix removal

* Require macOS 14.6 for Safari 26 in companion app UA regex
2026-09-02 08:15:36 +03:00
Paul BotteinandGitHub dc5d9f48bb Migrate add-to action list to grouped list style (#53921) 2026-09-01 16:28:30 +02:00
Paul BotteinandGitHub 3d3cdabfe7 Split more-info related and details views (#53920)
* Split more-info related and details views

* Update the related view e2e smoke case
2026-09-01 16:17:37 +02:00
Paul BotteinandGitHub 4b1f12c0ef Add seek support to the browser media player (#53917) 2026-09-01 14:44:21 +02:00
a9d7712fb2 Load the base map through core's tile proxy (#53845)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-09-01 10:26:18 +02:00
Krisjanis LejejsandGitHub a03efc1c5d Use the cloud auto-login flow in the voice satellite wizard (#53854)
Replace the wizard's own registration and sign-in screens with the cloud
panel's, extracted into cloud-register-card, so signing up mid-wizard gets
email-confirmation auto login instead of the legacy register plus login
polling. The wizard's copy of the login error ladder goes with it.

Drive the registration state from cloud/status rather than from the cloud
events, which carry nothing it does not already hold. The waiting view now
clears when the pending registration does, so a restart no longer leaves it
spinning on a registration the backend has forgotten.

Depends on the auto-login state fix in home-assistant/core#180423: without
it a retry loop that gives up before the register view stores its controller
leaves cloud/status reporting a pending registration with no reason, which
this no longer reads from the event instead.
2026-09-01 10:04:56 +03:00
9f7c353ad0 Group the connectivity panels behind a Connectivity settings page (#53905)
* Group the connectivity panels behind a Connectivity settings page

Move the connectivity panels (Matter, Zigbee, Z-Wave, KNX, MQTT, Thread,
Bluetooth, serial, infrared, radio frequency, Insteon, and tags) behind a new
Connectivity page. It sits with Voice assistants in the second settings group,
where those panels used to be listed individually.

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

* Update config panel e2e coverage for the Connectivity page

The connectivity panels moved off the settings root, so split the link smoke
cases and assert them on the Connectivity page instead. Add a route smoke case
for /config/connectivity.

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

---------

Co-authored-by: Claude <[email protected]>
2026-09-01 08:37:57 +03:00
lalexdotcomandGitHub 350fae4107 Report the fallback camera preview as loaded in ha-camera-stream (#53810)
`ha-camera-stream` declares a `load` event and both of its video players fire it through `fireEvent(this, "load")`, but the MJPEG and snapshot fallback `<img>` never does. Every camera without a working stream therefore goes through the one path that stays silent.

`hui-image` listens for that event to record `_lastImageHeight`, and that value is what drops the `.ratio` class so the container can size to its content. Without it the container keeps the 16:9 padding box it falls back to before anything is measured, while the picture is drawn at its own ratio and overflows. Measured against a 600x410 camera in a 600px wide card: the container stays 337.5px tall while the picture is drawn 410px tall, and it is still so ten seconds after the card is built.

Firing the event from the fallback image settles it: the container becomes 410px, matching the picture, and the 16:9 fallback goes back to being a placeholder for the first frame instead of a permanent state. Cameras that do have a stream are unaffected — they already fire `load` from their player.
2026-08-31 13:36:48 +00:00
e09c4084b7 Timer UI enhancements (#53793)
* Add timer data helpers for formatter-based display and duration serialization

Refactor computeDisplayTimer to take formatEntityState instead of hass so
context-migrated components can reuse it, add finishes_at to TimerEntity,
and add durationDataToTimerString for serializing duration input values.

* Modernize timer more-info dialog with state header and live countdown

Use ha-more-info-state-header with a ticking remaining-time display and
the standard more-info control layout. Adds timer to
DOMAINS_WITH_NEW_MORE_INFO, replacing the legacy state-card row.

* Add timer-actions and timer-presets card features

timer-actions shows start/pause/cancel/finish buttons (finish opt-in)
with state-aware disabling; start becomes restart while active.
timer-presets shows configurable one-tap durations that call timer.start.
Timer tile suggestions default to the timer-actions feature.

* Pulse timer red when it finishes

When a timer runs out or timer.finish is called, the tile icon and the
more-info countdown pulse red twice. Cancelling does not pulse: the new
timerJustFinished helper distinguishes the transitions via the
last_transition attribute. Honors prefers-reduced-motion.

* Apply suggestions from code review

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

* Send normalized duration for timer presets

Presets are parsed leniently by createDurationData like other duration
inputs, but the raw config value was sent to timer.start. A malformed
preset such as 1:nope:00 rendered as 1 h yet was rejected by core. Store
the normalized seconds so the label and the service call always match.

* Store timer presets on the entity instead of the card config

Review feedback: presets belong to the timer entity, like cover favorite
positions and light favorite colors. They now live in the entity registry
options and are edited in the more info dialog through the shared favorites
UI, which also brings reset and copy to other timers. The tile feature keeps
its style option but reads the presets from the registry.

* Reject a zero duration when adding or editing a timer preset

A zero duration passed the form's required check but was then treated as a
cancel, so saving the prefilled 0:00:00 silently did nothing. Form dialogs
now take an optional submit-time validate hook that blocks the submit and
shows a field error, and the preset dialog uses it to require at least one
second.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-08-31 14:12:26 +03:00
noksideandGitHub 946722daa2 Optimize update entities computation in hui-updates-card (#53878) 2026-08-31 12:28:18 +03:00
3b038d5372 Support multiple for media selector (#53815)
* Support multiple for media selector

* Update src/components/ha-selector/ha-selector-media.ts

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

* add to UI

* merge fix

* add helper

* Modify media item picker for clearable and image upload

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-31 12:02:03 +03:00
Paul BotteinandGitHub 6b7d387175 Fix weather forecast tabs in the more info dialog (#53841) 2026-08-30 19:52:50 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8a4202ea19 Update dependency lint-staged to v17.4.1 (#53887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 16:47:46 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2eaf1112f2 Update dependency @rspack/core to v2.2.1 (#53886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 16:47:26 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d30ff07394 Bump the codeql-action group across 1 directory with 2 updates (#53884)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.7 to 4.37.8
- [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/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/analyze` from 4.37.7 to 4.37.8
- [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/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-30 09:59:04 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5616b52979 Update dependency js-yaml to v5.4.1 (#53883)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 07:55:36 +03:00
Maarten LakerveldandGitHub 4219288701 Fix log entry details collapsing to an ellipsis on narrow screens (#53881)
The secondary line of system log entries was made inline-block in #53575
to apply text direction. An inline-block is an atomic inline, so the
parent's text-overflow: ellipsis hid the whole line whenever it did not
fit, leaving only "…" on mobile. Use unicode-bidi: isolate instead so
direction still applies while the text truncates normally.

Fixes #53879
2026-08-29 21:54:56 +02:00
noksideandGitHub 09b221cf45 Fix date, datetime, and color temperature initial values (#53863) 2026-08-29 16:21:44 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
89a467cac7 Update dependency @rspack/core to v2.2.0 (#53872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 07:15:53 +00:00
BrandonandGitHub c29b693038 Make the media selector clear button follow image_upload (#53866) 2026-08-29 09:14:05 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7ab178d045 Update Node.js to v24.20.0 (#53871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 09:08:01 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
538523c58b Update dependency generate-license-file to v4.2.4 (#53868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 09:07:39 +02:00
136cf0c8bf Revamp user settings page & navigation (#52070)
* refactor(profile): split general section into subpages with dashboard navigation

Replace the two-tab profile layout (general/security) with a dashboard-first
navigation structure. The general section is split into three focused subpages
(preferences, localization, browser), each wrapping the original row components
unchanged. A new dashboard page provides a user card (name, owner badge, logout)
and a navigation list linking to all four subpages.

Add ha-navigation-list as a reusable component for page navigation lists.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: remove orphan translation keys after general section split

Drop tabs.general and current_user, which were only used in the now-deleted
ha-profile-section-general.ts and the old profileSections tab config.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): align dashboard layout with Bluetooth panel pattern

Replace .content with .container (padding on wrapper, max-width/margin on
cards) to match the layout convention used in protocol panels like Bluetooth.
Always show the chevron in ha-navigation-list, removing the narrow condition.
Drop the unused narrow prop from ha-navigation-list.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor: move ha-config-navigation-list to src/components

Makes the component available outside the config panel. Update import
paths in the two existing config consumers and use it in the profile
dashboard. Remove the now-unused ha-navigation-list (which relied on
ha-md-list/ha-md-list-item).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: security subpage width and card spacing

Use .container > * instead of ha-card to apply max-width and margin,
since the security cards are custom elements whose inner ha-card lives
in their own shadow DOM and is unreachable from the parent stylesheet.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): move language setting to localization subpage

Language is a regional/cultural preference, consistent with the other
settings in localization (time zone, date and time formats).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): rename profile subpage labels

- "User preferences" -> "Appearance"
- "Browser settings" -> "This browser"
- "Mobile app settings" -> "This mobile app"

* feat(profile): surface theme picker on dashboard page

Move ha-pick-theme-row from the Appearance subpage to the profile
dashboard, between the user card and the navigation list. Theme and
dark mode are now accessible directly without navigating to a subpage.

Update Appearance description to reflect its new scope.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): restructure theme row layout and labels

Split dark mode and color pickers into separate ha-settings-row elements
with headings. Move reset button outside the color-row to keep the label
vertically stable when the button appears. Add padding to reset row.
Rename "Reset" to "Reset colors" for clarity.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* add title to theme settings card

* refactor(profile): apply theme row layout improvements to ha-theme-settings

Migrate the dark mode and color pickers layout from ha-pick-theme-row into
ha-theme-settings: use separate ha-settings-row elements with headings for
dark mode and custom colors, move reset button outside as a sibling element.
Add colors label to ThemeSettingsLabels interface.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): migrate theme settings to ha-list-item-base layout

Replace ha-settings-row elements in ha-theme-settings with ha-list-item-base
inside ha-list-base for a more compact appearance. Split color pickers into
individual rows (one per color). Reduce vertical padding to --ha-space-2.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* refactor(profile): combine color pickers into single row with label

Merge primary and accent color pickers into one ha-list-item-base row
under a "Custom colors" headline. Restore labels on individual pickers
and increase their min-width to 150px.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(profile): stack theme settings rows vertically in narrow mode

Reflect narrow prop on host and use ::part(base/end) to stack all
ha-list-item-base rows in column layout on narrow screens, preventing
headline truncation from oversized end slot controls.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* Fix test:e2e:demo

* fix broken links

* Add my links

* Use s instead of smal for ha-button size

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-08-28 21:57:59 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
920c858056 Update dependency @lokalise/node-api to v16.4.0 (#53865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 21:57:46 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3d2a4bfa04 Update dependency generate-license-file to v4.2.3 (#53864)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 21:57:43 +02:00
Paul BotteinandGitHub e2c9b2cb79 Always allow saving vacuum segment mapping (#53862) 2026-08-28 17:21:14 +02:00
Maarten LakerveldandGitHub d38ee185bf Allow negative number entry on iOS when the number selector has no min (#53852)
The iOS workaround from #52925 only applied when the selector had an
explicit negative min. The numeric threshold selector used by the power
triggers passes a number selector without a min, so the digit-only keypad
(without a minus key) was still shown. Treat a missing min as allowing
negatives too.

Also leave inputmode unset instead of forcing "text": on a number input
iOS then shows the Numbers and Punctuation keyboard, which has a minus
key and fits numeric entry better than the full QWERTY keyboard.

Fixes #53747
2026-08-28 16:50:01 +02:00
7e10f50419 Prefer neighbor relationship over raw LQI in ZHA network graph fallback (#53764)
* Prefer neighbor relationship over raw LQI in ZHA network graph fallback

* Create zha-network-data.test.ts

* Fix relative import paths and test connectivity via BFS instead of exact edges

* Add test coverage for extended relationship priority

* Apply suggestions from code review

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

* Just capitalizing relationshipPriority → RELATIONSHIP_PRIORITY in those two spots — nothing else changes.

* Update zha-network-data.ts

* Update zha-network-data.test.ts

* Update zha-network-data.ts

* Update zha-network-data.ts

* Update zha-network-data.test.ts

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-28 15:47:00 +03:00
noksideandGitHub 070fd96d6c Fix and centralize selector initial value handling (#53729)
* Fix and centralize selector initial value handling

* Fix unsupported selector initial values

* Omit undefined choose child values

* Fix device class and object selector initial values
2026-08-28 15:45:57 +03:00
Maarten LakerveldandGitHub 156840b6ff Fix RTL map labels and add English names to non-Latin ones (#53856)
* Load MapLibre RTL text plugin so Hebrew and Arabic map labels read correctly

The vector base map never registered MapLibre's RTL text plugin, so
right-to-left scripts were shaped left to right and every label came out
reversed. Ship @mapbox/mapbox-gl-rtl-text from /static/map/ alongside the
glyphs (no third-party host) and register it once, lazily, before the
first vector layer is created.

Fixes #53851

* Add the English name to map labels in non-Latin scripts

Before the move to vector tiles the CARTO basemap showed English names
everywhere. The OSM style shows local names, which most users cannot
read in Cyrillic, Arabic, Hebrew or CJK regions. Keep the local name and
add the English one from the tiles under it - in parentheses for street
labels, which cannot break lines. Latin-script names are left alone, so
Köln stays Köln.
2026-08-28 14:44:43 +03:00
5ff7a05ec5 feat: option to show add event button in calendar card (#53055)
* feat: option to show add event button in calendar card

* fix: remove translation file

* refactor: rename show_addfab to show_add_event

* fix: css calendar height

* feat: add event styling options

* Delete translations/backend/en.json

* fix: translation file

* fix: translations file

* fix: no box shadow when button below calendar

* fix: inconsistent narrow mode calculation

* fix: screen reader compability

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

* fix: margin-inline-start for rtl layout support

* fix: use literal union instead of string in editor

* fix: use sentence case

* Apply suggestions from code review

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

* fix: remove unused class

* feat: use visible parameter to toggle editor elements

* refactor: split add-fab into mulitple attributes for ha-full-calendar

* fix: lint type error

* fix: editor defaults

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-08-28 14:39:17 +03:00
c3643555ab Respect gas entity display precision in energy dashboard (#53712)
* Respect gas display precision in gas graph

The gas graph previously formatted the total gas consumption using the generic number formatter, which could ignore the display precision  configured on the selected gas entity.

Use the configured gas entity's display precision for both the visible total chip and the total consumption tooltip.

This keeps the gas graph consistent with the entity configuration. For example, a gas entity configured with 3 decimal places will display 0.009 m³ instead of being rounded to 0.01 m³.

* Use configured gas entity display precision in energy distribution card

The energy distribution card previously formatted gas usage with the generic
energy formatter, which could ignore the display precision configured on the
selected gas entity.

Use the display precision of the configured gas consumption entity when
formatting the gas value in the energy distribution card.

This keeps the displayed value consistent with the entity configuration. For
example, a gas entity configured with 3 decimal places will now display
0.009 m³ instead of being rounded to 0.01 m³.

* Respect gas display precision in energy sources table

The energy sources table previously formatted gas values using the generic number formatter, which could ignore the display precision configured on the selected gas entity.

Use the configured gas entity's display precision for both individual gas source rows and the gas total row.

This keeps gas values consistent with the entity configuration. For example, a gas entity configured with 3 decimal places will display 0.009 m³ instead of being rounded to 0.01 m³.

* Use highest gas display precision for energy distribution

The energy distribution card previously used the display precision of the first configured gas source for the aggregated gas value.

Use the highest configured display precision across all gas sources instead of special-casing the first source.

This avoids making source 0 authoritative for an aggregate value and preserves the greatest configured precision when multiple gas sources are present.

* Use highest gas display precision for energy distribution

The energy distribution card previously used the display precision of the first configured gas source for the aggregated gas value.

Use the highest configured display precision across all gas sources instead of special-casing the first source.

This avoids making source 0 authoritative for an aggregate value and preserves the greatest configured precision when multiple gas sources are present.

* Use highest gas display precision in gas graph

The gas graph previously used the display precision of the first configured gas source for its aggregated total.

Use the highest configured display precision across all gas sources instead of special-casing source 0.

This keeps the graph total and tooltip consistent with the greatest configured precision when multiple gas sources are present.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Use display_precision for entity precision

Replace dp with display_precision when reading precision from hass.entities, matching the EntityRegistryDisplayEntry type used by the frontend.

* Restore consumption formatting in energy distribution card

* Respect gas display precision in energy sources table

* Respect gas display precision in energy graph

* Respect display precision in formatConsumptionShort

* Pass gas display precision to consumption formatter

Add logic to calculate gas display precision based on available sources.

* Use gas display precision for graph y-axis

* Update distribution card on gas precision changes

* Update sources table on gas precision changes

* Update gas graph when display precision changes

Refresh the gas graph when a gas entity's display precision or unit of measurement changes, ensuring the configured precision is applied without waiting for new energy data.

* Update distribution card on gas unit changes

Refactor gas source display precision check and update unit of measurement comparison.

* Derive gas display precision with a getter

* Fix hass change handling in energy sources table

* Fix hass change handling in gas graph

* Fix hass change handling in energy distribution

* Fix gas unit narrowing in energy distribution

* Fix formatting in gas graph card

Refactor gasDisplayPrecisions assignment for better readability.

* Fix formatting in energy sources table

* Fix Prettier formatting in energy gas cards

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-28 11:15:12 +00:00
Paul BotteinandGitHub ae2c296272 Remove vacuum battery_level attribute unit (#53855) 2026-08-28 12:42:38 +02:00
1db1c8024e Improve low battery entity lookup (#53850)
* Improve low battery entity lookup

* Apply suggestions from code review

Co-authored-by: Maarten Lakerveld <[email protected]>

* Fix formatting

---------

Co-authored-by: Maarten Lakerveld <[email protected]>
2026-08-28 10:47:44 +02:00
renovate[bot]andGitHub 2f003ca999 Update dependency js-yaml to v5.4.0 (#53853) 2026-08-28 08:58:36 +01:00
Maarten LakerveldandGitHub b1d8a21aaa Don't close drawer when a nested tooltip hides (#53809)
wa-after-hide from a tooltip inside the drawer (e.g. the exact time
tooltip on a persistent notification) bubbled to the wa-drawer listener
and was treated as the drawer closing. Only handle the drawer's own event.

Fixes #52901
2026-08-28 08:45:28 +01:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cde2e66229 Update dependency typescript-eslint to v8.68.0 (#53848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 06:45:08 +02:00
Paulus SchoutsenandGitHub 71d5500ed8 Render camera favorites as camera tiles (#53814) 2026-08-28 00:05:02 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
462321a3c8 Update dependency eslint to v10.9.1 (#53846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:51 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
afc0c36dd9 Update dependency marked to v18.0.11 (#53847)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 22:02:43 +02:00
Bram Kragten 7f86074e07 Revert "Load the base map through core's tile proxy"
This reverts commit 4b9e47079c.
2026-08-27 19:15:16 +02:00
Bram KragtenandClaude Opus 5 4b9e47079c Load the base map through core's tile proxy
Core now proxies both vector and raster tiles, which is what lets them be
requested with an application User-Agent and without a referrer - a
browser can set neither. So the frontend stops talking to OpenStreetMap
and CARTO directly and goes through /api/map_tiles.

The proxy is token gated, so `ha-map` fetches one over the WebSocket
before setting up, following the brands token pattern: cached at module
level, refreshed well inside its lifetime so a dashboard left open for
days keeps working. The blocking wait is kept to about a second - a
backend without the proxy must not hold the map hostage - and the
remaining retries run in the background to ride through the window after
a restart where the WebSocket is up but the handler is not registered
yet.

Two things that are not obvious and cost a measurement each:

MapLibre's `transformRequest` has to return absolute URLs. Tiles are
fetched from a worker, which has no document to resolve a relative URL
against, and the TileJSON that core serves has relative `tiles`. Measured
with a relative TileJSON on one origin: with absolute URLs the style
loads and 8,456 features render; without them exactly one request is made
- the style - and nothing else loads, with no error reported anywhere.

Leaflet bakes its URL template at layer creation and throws while
building a tile URL if a template variable is undefined. So the raster
layer takes the token as an option Leaflet substitutes per request, which
also means a refreshed token is picked up without recreating the layer,
and an absent one is empty rather than missing: the tiles 403 and the
markers still draw.

The asset pipeline shrinks to generating two styles. Glyphs and sprites
come from the proxy, so the 48 MB build-time download, the digest
verification, the glyph range filtering and the bold-range guard are all
gone, along with 5.4 MB from the wheel. `localIdeographFontFamily` goes
too: the complete glyph set is reachable now, so CJK renders in Noto
rather than a device font.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 18:14:59 +02:00
Paul BotteinandGitHub e3b9c6e143 Fix the History timeline labels and tooltip placement (#53843)
* Draw History timeline names in a label column again

* Keep the timeline tooltip level with the hovered row
2026-08-27 17:36:06 +02:00
221f184cb1 Remove no-op User-Agent header from Nominatim requests (#53834)
* Remove no-op User-Agent header from Nominatim requests

`User-Agent` is a forbidden header name for browser fetch/XHR, so the
option passed to both Nominatim calls was silently dropped and the
browser's own User-Agent went out instead. Verified in Chromium against
a local echo server: the header never took effect, the outgoing UA was
the browser default, and zero preflights were received — had it been
applied, the non-safelisted header would have forced an OPTIONS
preflight that Nominatim does not answer.

Identification towards Nominatim is done by the `email` query parameter
both calls already send, which is what the Nominatim usage policy
accepts, so nothing changes on the wire. Drop the misleading dead code
and note in a comment why the header cannot be set from a browser.

`hass` is still needed by both functions for `hass.locale.language`, so
the signatures are unchanged.

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

* Apply suggestions from code review

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-08-27 15:04:28 +00:00
Petar PetrovandGitHub 27cbbdb83f Fix editor state leaking when switching automations, scripts, or scenes (#53826)
* Fix editor state leaking when switching automations, scripts, or scenes

Recreate the page when the item id in the route changes so YAML mode,
undo history, and scene live state cannot carry over to the next item.

* Declare itemId on editor routes instead of guessing from the path

Path-shape matching remounted nested config routers, Lovelace, and
developer tools. Recreate only where edit/show routes opt in.

* Do not restore scene live states when the editor is torn down

A remounted editor starts in review; disconnect only needs to drop
the subscription, not undo device changes from live mode.

* Use the normal create path when remounting itemId pages

Clear the current page instead of calling _createPanel directly so
load and the loading screen still run. Drop the redundant cache delete.

* Fix dialog-form tests leaking focus restore after jsdom teardown

Nested submit/cancel schedules a nextRender callback that ran after
Vitest tore down HTMLElement, failing CI with an unhandled rejection.
2026-08-27 17:04:18 +02:00
337e88560e Replace CARTO raster tiles with OpenStreetMap vector tiles (#53816)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-27 16:35:43 +02:00
Paul BotteinandGitHub 9c96c56558 Show serial port info in a bottom sheet on mobile (#53839) 2026-08-27 16:33:47 +02:00
Aidan TimsonandGitHub 27e0c4492c Refine entity details context section (#53797) 2026-08-27 15:21:26 +01:00
Paul BotteinandGitHub 80889c3cea Keep the row config when changing a device automation type (#53832) 2026-08-27 16:16:25 +02:00
Paul BotteinandGitHub cbbaf51e75 Keep the automation config when replacing a device in device automations (#53805) 2026-08-27 16:14:47 +02:00
Petar PetrovandGitHub 9778a54305 Stop a percentage Y axis expanding past 100 (#53824)
A percentage has a real ceiling the way zero is a real floor, but the gap that
keeps series off the plot edges did not know that: a battery reading 20-100%
rounded out to an axis labelled up to 120%, and one sitting flat at 100% — a
device left on the charger — reached 160%.

Recognise the unit at the two line-chart call sites and hold the axis at 100,
mirroring the existing zero clamp. Only while the data stays under it, since
power factor is also reported in % and is signed.
2026-08-27 17:14:04 +03:00
Petar PetrovandGitHub b9125537c0 Show Z-Wave neighbor connections in the network visualization (#53677)
* Show Z-Wave neighbor connections in the network visualization

* Fetch neighbors in bulk and hide them behind a toggle

* Warn that loading neighbor data turns off the adapter
2026-08-27 15:59:52 +03:00
Petar PetrovandGitHub fd6017a963 Guarantee a Y-axis gap on history and statistics charts (#53821)
* Guarantee a Y-axis gap on history and statistics charts

ECharts floors the axis minimum and ceils the maximum to a tick multiple,
so quantized states that land exactly on a tick leave the data flush against
the plot edge — which collapses the climate and humidifier action bands to
zero height, since an area fill is based at the axis minimum.

Widen the auto-scaled extent by a fraction of the span so that same rounding
always has something to round away, keeping the axis anchored at zero for
single-signed data and preserving the window ECharts gives a constant series.

* Widen the Y-axis gap threshold to 2% of the span

A nudge only large enough to break an exact tie left the gap unchanged
whenever the data sat just above a tick rather than on it, so a chart could
still render with a hairline of headroom.

Because ECharts rounds out to a whole tick afterwards, this constant does not
set the size of the resulting gap — it decides which axes count as too flat to
leave alone. At 2% the worst case over the tick cycle goes from nothing to
around 3% of the plot, while every measured chart keeps the framing it had.
2026-08-27 15:57:50 +03:00
Shay RedmondandGitHub e0f5d6d6b3 Small copy improvements for Cloud feature list (#53833) 2026-08-27 08:54:57 -04:00
193ab1b424 Deduplicate in-flight requests (#53786)
* Deduplicate in-flight requests

* Use callWS for in-flight request ownership

* Avoid mutating shared request results

* Deep-freeze shared in-flight request results

Centralize immutability in shareInFlightRequest using deep-freeze so
callers do not need readonly types on shared API shapes.

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-27 12:48:59 +00:00
Maarten LakerveldandGitHub 4a3b6db411 Make digital clock card font size themeable (#53808)
The digital clock hardcoded 1.5rem/3rem/4rem, which ignored
--ha-font-size-scale and could not be overridden from a theme. Use
--ha-clock-card-digital-font-size-{small,medium,large} with defaults based
on the font size scale (24px/48px/64px).

Fixes #51602
2026-08-27 12:05:11 +00:00
Aidan TimsonandGitHub 46271d0435 Show app icons for serial port consumers (#53827) 2026-08-27 13:26:19 +02:00
Aidan TimsonandGitHub d4bc02c86b Fix MQTT subscribe controls layout (#53831) 2026-08-27 13:25:40 +02:00
a12ae9a8ed Expand tree nodes on double-click in the automation editor (#53830)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-27 11:52:25 +01:00
Maarten LakerveldandGitHub 6095787294 Derive energy CSV time columns from exported rows (#53828)
The CSV header used the timestamps of every statistic in the energy
collection. Since the power sources chart (#27501) that includes the
power statistics, which are fetched at a finer period than the energy
statistics (hourly for a month view, where energy is daily), adding
empty hourly columns between the daily energy values. Collect the rows
first and build the time columns only from the data that is actually
exported.

Fixes #52381
2026-08-27 12:51:33 +02:00
Petar PetrovandGitHub 127702b96f Fix more-info dialog rewriting the URL after navigation (#53806) 2026-08-27 09:51:29 +01:00
Petar PetrovandGitHub 56a6c5d9fb Do not show empty Grid legend item on energy usage graph (#53822)
Do not add empty combined Grid series on energy usage graph
2026-08-27 11:49:22 +03:00
Petar PetrovandGitHub 3b686dc3b9 Prompt for unsaved changes on programmatic navigation (#53804)
* Prompt for unsaved changes on programmatic navigation

* Tidy unsaved-changes guard tests

* Drop navigations superseded while an unsaved-changes prompt is open
2026-08-27 11:09:09 +03:00
Aidan TimsonandGitHub c5e8dea750 Fix centering for no history found in graph card feature (#53795)
* Fix centering for no history found in graph card feature

* Drop classMap
2026-08-27 11:08:18 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
29cd46eb9e Update dependency minify-literals to v2.2.0 (#53812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-26 20:24:56 +02:00
Paul BotteinandGitHub 92b3081896 Clarify the replaced device state in pickers and target rows (#53807) 2026-08-26 19:48:52 +02:00
Shay RedmondandGitHub 527319ab5e Remove the unnecessary spacing around the ha-alert components (#53799)
Remove the unnecessary spacing around the subscription ha-alert components
2026-08-26 14:01:38 +00:00
Maarten LakerveldandGitHub 8d3877c388 Fix Expose page crash when filtered by assistant (#53803)
The assistants URL filter can skip creating a row for entities that are
only exposed via Cloud (Alexa/Google) manual config, but the sortable key
was still assigned to the missing row, throwing a TypeError and leaving
the page on an infinite spinner. Only assign the key when the row exists.

Fixes #53689
2026-08-26 13:43:50 +00:00
Aidan TimsonandGitHub 66b9fbd4bf Fix view background config reference (#53802) 2026-08-26 15:40:31 +02:00
298 changed files with 14808 additions and 3254 deletions
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:javascript-typescript"
+1 -1
View File
@@ -1 +1 @@
24.19.0
24.20.0
+9 -1
View File
@@ -34,6 +34,14 @@ In production, the following responsibilities are added:
- Minify HTML
- Bundle multiple imports so that the browser can fetch less files
- Generate a second version that is ES5 compatible
- Generate a second version that is compatible with older browsers (legacy build)
Configuration for all these steps are specified in [bundle.js](bundle.js).
## Auditing browser support changes
`node build-scripts/list-plugins-and-polyfills.js` prints, per browserslist
environment (modern/legacy), the Babel transforms preset-env enables and the
Core-JS polyfills that may be injected — as collapsible markdown ready to
paste into a PR. Use it to show the bundle impact when changing
`.browserslistrc` or the Babel configuration.
+1 -3
View File
@@ -121,7 +121,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
ignoreModuleNotFound: true,
},
],
// Import helpers and regenerator from runtime package.
// Import helpers from runtime package.
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
// corejs3 polyfill provider above otherwise redirects them to the
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
@@ -155,8 +155,6 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
"@lit-labs/virtualizer/polyfills",
"@webcomponents/scoped-custom-element-registry",
"element-internals-polyfill",
"proxy-polyfill",
"unfetch",
].map((p) => new RegExp(`/node_modules/${p}/`)),
],
},
+2 -2
View File
@@ -25,7 +25,7 @@ const SAFARI_TO_MACOS = {
16: [11, 0, 0],
17: [12, 0, 0],
18: [13, 0, 0],
26: [26, 0, 0],
26: [14, 6, 0],
};
const getCommonTemplateVars = () => {
@@ -89,7 +89,7 @@ const minifyHtml = (content, ext) => {
...htmlMinifierOptions,
conservativeCollapse: false,
minifyJS: terserOptions({
latestBuild: false, // Shared scripts should be ES5
latestBuild: false, // Shared scripts must satisfy the legacy targets
isTestBuild: true, // Don't need source maps
}),
}).then((wrapped) =>
-35
View File
@@ -42,37 +42,6 @@ function copyMdiIcons(staticDir) {
fs.copySync(polyPath("build/mdi"), staticPath("mdi"));
}
function copyPolyfills(staticDir) {
const staticPath = genStaticPath(staticDir);
// For custom panels using ES5 builds that don't use Babel 7+
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"),
staticPath("polyfills/")
);
// Web Component polyfills and adapters
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js"),
staticPath("polyfills/")
);
copyFileDir(
npmPath("@webcomponents/webcomponentsjs/webcomponents-bundle.js.map"),
staticPath("polyfills/")
);
// Lit polyfill support
fs.copySync(
npmPath("lit/polyfill-support.js"),
path.join(staticPath("polyfills/"), "lit-polyfill-support.js")
);
// dialog-polyfill css
copyFileDir(
npmPath("dialog-polyfill/dialog-polyfill.css"),
staticPath("polyfills/")
);
}
function copyFonts(staticDir) {
const staticPath = genStaticPath(staticDir);
// Local fonts
@@ -141,7 +110,6 @@ gulp.task("copy-static-app", async () => {
const staticDir = paths.app_output_static;
// Basic static files
fs.copySync(polyPath("public"), paths.app_output_root);
copyPolyfills(staticDir);
copyFonts(staticDir);
copyTranslations(staticDir);
copyLocaleData(staticDir);
@@ -163,7 +131,6 @@ gulp.task("copy-static-demo", async () => {
);
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
copyPolyfills(paths.demo_output_static);
await copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
@@ -176,7 +143,6 @@ gulp.task("copy-static-cast", async () => {
fs.copySync(polyPath("public/static"), paths.cast_output_static);
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
copyPolyfills(paths.cast_output_static);
await copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
@@ -223,7 +189,6 @@ gulp.task("copy-static-e2e-test-app", async () => {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
await copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
@@ -25,7 +25,7 @@ gulp.task("gen-sensor-entity-constants", async () => {
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
const deviceClassUnits = data.device_class_units ?? {};
const convertibleClassUnits = data.convertible_units ?? {};
const stateClasses = [...(data.state_classes ?? [])].sort();
const stateClasses = data.state_classes ?? [];
const stateClassUnits = data.state_class_units ?? {};
if (
!numericDeviceClasses.length ||
+62 -27
View File
@@ -1,38 +1,55 @@
#!/usr/bin/env node
// Script to print Babel plugins and Core JS polyfills that will be used by browserslist environments
import { version as babelVersion } from "@babel/core";
import presetEnv from "@babel/preset-env";
import compilationTargets from "@babel/helper-compilation-targets";
import { transformSync } from "@babel/core";
import compilationTargets, {
getInclusionReasons,
} from "@babel/helper-compilation-targets";
import coreJSCompat from "core-js-compat";
import { logPlugin } from "@babel/preset-env/lib/debug.js";
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
import { babelOptions } from "./bundle.cjs";
const detailsOpen = (heading) =>
`<details>\n<summary><h4>${heading}</h4></summary>\n`;
const detailsClose = "</details>\n";
const dummyAPI = {
version: babelVersion,
// eslint-disable-next-line @typescript-eslint/no-empty-function
assertVersion: () => {},
caller: (callback) =>
callback({
name: "Dummy Bundler",
supportsStaticESM: true,
supportsDynamicImport: true,
supportsTopLevelAwait: true,
supportsExportNamespaceFrom: true,
}),
targets: () => ({}),
// Copied from @babel/preset-env's internal `logPlugin`, which Babel 8 no
// longer exposes (the package rolls up into lib/index.js and exports nothing
// but the preset). Prints an item with the targets that require it.
const logPlugin = (item, targetVersions, list) => {
const filteredList = getInclusionReasons(item, targetVersions, list);
const support = list[item];
if (!support) {
console.log(` ${item}`);
return;
}
let formattedTargets = `{`;
let first = true;
for (const target of Object.keys(filteredList)) {
if (!first) formattedTargets += `,`;
first = false;
formattedTargets += ` ${target}`;
if (support[target]) formattedTargets += ` < ${support[target]}`;
}
formattedTargets += ` }`;
console.log(` ${item} ${formattedTargets}`);
};
// Copied from babel-plugin-polyfill-corejs3's generated
// corejs3ShippedProposalsList, which v1 no longer exposes (it is inlined in
// the package's rolled-up bundle).
const shippedProposalsList = new Set([
"esnext.array.group",
"esnext.array.group-to-map",
"esnext.iterator.zip",
"esnext.iterator.zip-keyed",
"esnext.symbol.metadata",
]);
// Generate filter function based on proposal/method inputs
// Copied and adapted from babel-plugin-polyfill-corejs3/esm/index.mjs
const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
if (proposals || method === "entry-global") return true;
if (shippedProposals && shippedPolyfills.default.has(name)) {
if (shippedProposals && shippedProposalsList.has(name)) {
return true;
}
if (name.startsWith("esnext.")) {
@@ -47,7 +64,9 @@ const polyfillFilter = (method, proposals, shippedProposals) => (name) => {
for (const buildType of ["Modern", "Legacy"]) {
const browserslistEnv = buildType.toLowerCase();
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
const presetEnvOpts = babelOpts.presets[0][1];
const presetEnvOpts = babelOpts.presets.find(
(preset) => Array.isArray(preset) && preset[0] === "@babel/preset-env"
)?.[1];
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
// removed preset-env's `useBuiltIns`), so read its options here.
const corejsOpts = babelOpts.plugins.find(
@@ -55,22 +74,38 @@ for (const buildType of ["Modern", "Legacy"]) {
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
)?.[1];
// Invoking preset-env in debug mode will log the included plugins
// Transforming an empty file with preset-env in debug mode logs the included
// plugins. The caller declares the same capabilities babel-loader does, so
// plugins gated on bundler support (e.g. transform-export-namespace-from)
// match the build.
presetEnvOpts.debug = true;
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
presetEnv.default(dummyAPI, {
...presetEnvOpts,
browserslistEnv,
debug: true,
transformSync("", {
...babelOpts,
configFile: false,
filename: "audit.js",
caller: {
name: "list-plugins-and-polyfills",
supportsStaticESM: true,
supportsDynamicImport: true,
supportsTopLevelAwait: true,
supportsExportNamespaceFrom: true,
},
});
console.log(detailsClose);
// Manually log the Core-JS polyfills using the same technique
if (corejsOpts) {
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
const targets = compilationTargets.default(babelOpts?.targets, {
const targets = compilationTargets(babelOpts.targets, {
browserslistEnv,
});
const polyfillList = coreJSCompat({ targets }).list.filter(
// `version` limits the list to modules the installed core-js ships,
// mirroring the provider's own filtering.
const polyfillList = coreJSCompat({
targets,
version: corejsOpts.version,
}).list.filter(
polyfillFilter(
corejsOpts.method,
corejsOpts.proposals,
+1 -4
View File
@@ -345,10 +345,7 @@ const createRspackConfig = ({
"lit/directives/join$": "lit/directives/join.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/directives/live$": "lit/directives/live.js",
"lit/directives/keyed$": latestBuild
? "lit/directives/keyed.js"
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
"lit/polyfill-support$": "lit/polyfill-support.js",
"lit/directives/keyed$": "lit/directives/keyed.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
+1 -3
View File
@@ -15,7 +15,6 @@ import {
saveTokens,
} from "../../../../src/common/auth/token_storage";
import { atLeastVersion } from "../../../../src/common/config/version";
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
@@ -197,8 +196,7 @@ class HcCast extends LitElement {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
toggleAttribute(
this,
this.toggleAttribute(
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
+4
View File
@@ -3,6 +3,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_registry";
import { energyEntities } from "../stubs/entities";
import { connectivityEntities } from "../stubs/connectivity/fixtures";
import { setDemoFloors } from "../stubs/floor_registry";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
@@ -53,6 +54,9 @@ export const setDemoConfig = async (
setDemoAreas(hass, config.areas);
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
// Replaced the whole state map above, so the entities that do not belong to a
// demo config have to be added back.
hass.addEntities(connectivityEntities());
// Let the new registries and entities reach the dashboard before saving the
// config, so dashboard strategies generate against them
+11 -1
View File
@@ -7,6 +7,12 @@ import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import {
connectivityCommands,
connectivityComponents,
connectivityEntities,
connectivityEntityRegistryEntries,
} from "./stubs/connectivity/fixtures";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
@@ -60,6 +66,7 @@ const CONFIG_PANEL_COMMANDS = [
"assist_pipeline/",
"config/entity_registry/settings/",
"slugify",
...connectivityCommands,
];
@customElement("ha-demo")
@@ -87,6 +94,7 @@ export class HaDemo extends HomeAssistantAppEl {
"assist_pipeline",
"hassio",
"hardware",
...connectivityComponents,
],
},
});
@@ -99,7 +107,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass);
mockTranslations(hass, localizePromise);
mockHistory(hass);
mockRecorder(hass);
mockTodo(hass);
@@ -172,9 +180,11 @@ export class HaDemo extends HomeAssistantAppEl {
created_at: 0,
modified_at: 0,
},
...connectivityEntityRegistryEntries,
]);
hass.addEntities(energyEntities());
hass.addEntities(connectivityEntities());
// Once config is loaded AND localize, set registries, entities and theme.
Promise.all([selectedDemoConfig, localizePromise]).then(
+8
View File
@@ -7,6 +7,7 @@ import { mockBlueprint } from "./blueprint";
import { mockCloud } from "./cloud";
import { mockConfig } from "./config";
import { mockConfigEntries } from "./config_entries";
import { mockConnectivity } from "./connectivity";
import { mockDeviceAutomation } from "./device_automation";
import { mockEntityRegistrySettings } from "./entity_registry_settings";
import { mockEntitySources } from "./entity_sources";
@@ -26,6 +27,7 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockCloud(hass);
mockConfig(hass);
mockConfigEntries(hass);
mockConnectivity(hass);
mockDeviceAutomation(hass);
mockEntitySources(hass);
mockBlueprint(hass);
@@ -43,4 +45,10 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockAssist(hass);
mockEntityRegistrySettings(hass);
mockSlugify(hass);
hass.mockWS("llm/api/list", () => ({
apis: [
{ id: "assist", name: "Assist" },
{ id: "music_assistant", name: "Music Assistant" },
],
}));
};
+12
View File
@@ -5,6 +5,7 @@ import type {
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
import type { IntegrationType } from "../../../src/data/integration";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { connectivityConfigEntries } from "./connectivity/fixtures";
const baseEntry = {
source: "user",
@@ -80,6 +81,17 @@ export const demoConfigEntries: {
title: "Comfort level",
},
},
{
type: "service",
entry: {
...baseEntry,
entry_id: "mock-mcp-server",
domain: "mcp_server",
title: "Assist, Music Assistant",
supports_options: true,
},
},
...connectivityConfigEntries,
];
const filterEntries = (filters?: {
@@ -0,0 +1,71 @@
import { manifest } from "../../manifest";
import { configEntry, device } from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const LOCAL_SOURCE = "00:1A:7D:DA:71:11";
export const PROXY_SOURCE = "E8:DB:84:A1:C2:30";
export const SHED_SOURCE = "A4:CF:12:9B:44:70";
const ADAPTER_ENTRY_ID = "mock-bluetooth";
const PROXY_LIVING_ENTRY_ID = "mock-bluetooth-proxy-living";
const PROXY_SHED_ENTRY_ID = "mock-bluetooth-proxy-shed";
export const bluetoothFixtures: ConnectivityFixtures = {
components: ["bluetooth"],
commands: ["bluetooth/"],
manifests: [manifest("bluetooth", "Bluetooth", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(
ADAPTER_ENTRY_ID,
"bluetooth",
`hci0 (${LOCAL_SOURCE})`,
{ source: "usb", supports_options: true }
),
},
{
type: "hub",
entry: configEntry(
PROXY_LIVING_ENTRY_ID,
"bluetooth",
"Living room proxy",
{ source: "esphome" }
),
},
{
type: "hub",
entry: configEntry(PROXY_SHED_ENTRY_ID, "bluetooth", "Shed proxy", {
source: "esphome",
}),
},
],
// Adapters and proxies are matched to their scanner by the bluetooth
// connection tuple, see ./mock.
devices: [
device(
"bluetooth-hci0",
"hci0",
"Home Assistant",
"Home Assistant Green",
ADAPTER_ENTRY_ID,
{ connections: [["bluetooth", LOCAL_SOURCE]] }
),
device(
"bluetooth-proxy-living",
"Living room proxy",
"Espressif",
"ESP32-C3",
PROXY_LIVING_ENTRY_ID,
{ area_id: "living_room", connections: [["bluetooth", PROXY_SOURCE]] }
),
device(
"bluetooth-proxy-shed",
"Shed proxy",
"Espressif",
"ESP32",
PROXY_SHED_ENTRY_ID,
{ connections: [["bluetooth", SHED_SOURCE]] }
),
],
};
@@ -0,0 +1,193 @@
import type {
BluetoothAllocationsData,
BluetoothDeviceData,
BluetoothScannerDetails,
BluetoothScannerState,
} from "../../../../../src/data/bluetooth";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
import { LOCAL_SOURCE, PROXY_SOURCE, SHED_SOURCE } from "./fixtures";
const SCANNERS: BluetoothScannerDetails[] = [
{
source: LOCAL_SOURCE,
connectable: true,
name: "hci0 (Home Assistant Green)",
adapter: "hci0",
scanner_type: "usb",
},
{
source: PROXY_SOURCE,
connectable: true,
name: "Living room proxy",
adapter: "esp32",
scanner_type: "remote",
},
{
source: SHED_SOURCE,
connectable: false,
name: "Shed proxy",
adapter: "esp32",
scanner_type: "remote",
},
];
const SCANNER_STATES: BluetoothScannerState[] = [
{
source: LOCAL_SOURCE,
adapter: "hci0",
current_mode: "active",
requested_mode: "active",
},
{
source: PROXY_SOURCE,
adapter: "esp32",
current_mode: "active",
requested_mode: "active",
},
{
// A proxy configured for passive scanning: it reports advertisements but
// cannot connect, matching its `connectable: false` scanner details.
source: SHED_SOURCE,
adapter: "esp32",
current_mode: "passive",
requested_mode: "passive",
},
];
const ALLOCATIONS: BluetoothAllocationsData[] = [
{
source: LOCAL_SOURCE,
slots: 5,
free: 3,
allocated: ["A4:C1:38:11:22:33", "E7:2E:00:B1:9A:1C"],
},
{
source: PROXY_SOURCE,
slots: 3,
free: 2,
allocated: ["FC:58:FA:12:34:56"],
},
];
interface DemoAdvertisement {
address: string;
name: string;
rssi: number;
source: string;
connectable?: boolean;
tx_power?: number;
manufacturer_data?: Record<number, string>;
service_data?: Record<string, string>;
service_uuids?: string[];
}
const ADVERTISEMENTS: DemoAdvertisement[] = [
{
address: "A4:C1:38:11:22:33",
name: "Govee H5075",
rssi: -58,
source: LOCAL_SOURCE,
manufacturer_data: { 60552: "000104a10b64" },
service_uuids: ["0000ec88-0000-1000-8000-00805f9b34fb"],
},
{
address: "E7:2E:00:B1:9A:1C",
name: "SwitchBot Meter",
rssi: -71,
source: LOCAL_SOURCE,
service_data: { "0000fd3d-0000-1000-8000-00805f9b34fb": "5400648c14" },
service_uuids: ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
},
{
address: "FC:58:FA:12:34:56",
name: "Xiaomi LYWSD03MMC",
rssi: -64,
source: PROXY_SOURCE,
service_data: { "0000fe95-0000-1000-8000-00805f9b34fb": "3058590e" },
},
{
address: "C4:7C:8D:6A:5B:20",
name: "Flower care",
rssi: -88,
source: SHED_SOURCE,
connectable: false,
service_uuids: ["0000fe95-0000-1000-8000-00805f9b34fb"],
},
{
address: "D0:36:9A:7F:11:80",
name: "Tile Mate",
rssi: -79,
source: PROXY_SOURCE,
connectable: false,
service_uuids: ["0000feed-0000-1000-8000-00805f9b34fb"],
},
{
address: "5C:C7:C1:04:9E:2A",
name: "Nut Find 3",
rssi: -93,
source: SHED_SOURCE,
connectable: false,
},
];
const buildAdvertisement = (
advertisement: DemoAdvertisement
): BluetoothDeviceData => ({
address: advertisement.address,
name: advertisement.name,
rssi: advertisement.rssi,
source: advertisement.source,
connectable: advertisement.connectable ?? true,
manufacturer_data: advertisement.manufacturer_data ?? {},
service_data: advertisement.service_data ?? {},
service_uuids: advertisement.service_uuids ?? [],
tx_power: advertisement.tx_power ?? -59,
time: Date.now() / 1000,
raw: null,
});
// Nudge the signal strength a little on every tick so the monitors and the
// network map look alive without the rows jumping around.
const jitter = (rssi: number) =>
Math.max(-99, Math.min(-30, rssi + Math.round(Math.random() * 4) - 2));
export const mockBluetooth = (hass: MockHomeAssistant) => {
hass.mockWS("bluetooth/subscribe_scanner_details", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.({ add: SCANNERS }))
);
hass.mockWS("bluetooth/subscribe_scanner_state", (_msg, _hass, onChange) =>
emitInitial(() => SCANNER_STATES.forEach((state) => onChange?.(state)))
);
hass.mockWS(
"bluetooth/subscribe_connection_allocations",
(msg: { config_entry_id?: string }, _hass, onChange) =>
emitInitial(() =>
onChange?.(
msg.config_entry_id
? ALLOCATIONS.filter((a) => a.source === LOCAL_SOURCE)
: ALLOCATIONS
)
)
);
hass.mockWS("bluetooth/subscribe_advertisements", (_msg, _hass, onChange) => {
let advertisements = ADVERTISEMENTS;
const stopInitial = emitInitial(() =>
onChange?.({ add: advertisements.map(buildAdvertisement) })
);
const interval = window.setInterval(() => {
advertisements = advertisements.map((advertisement) => ({
...advertisement,
rssi: jitter(advertisement.rssi),
}));
onChange?.({ change: advertisements.map(buildAdvertisement) });
}, 5000);
return () => {
stopInitial();
clearInterval(interval);
};
});
};
+60
View File
@@ -0,0 +1,60 @@
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
import { mqttFixtures } from "./mqtt/fixtures";
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
import { serialFixtures } from "./serial/fixtures";
import { tagsFixtures } from "./tags/fixtures";
import { threadFixtures } from "./thread/fixtures";
import type { ConnectivityFixtures } from "./types";
import { zhaFixtures } from "./zha/fixtures";
import { zwaveJsFixtures } from "./zwave_js/fixtures";
// Every integration reachable from Settings > Connectivity that has frontend
// data to mock. Each owns its own fixtures, so they can be added and removed
// one at a time.
const INTEGRATIONS: ConnectivityFixtures[] = [
bluetoothFixtures,
serialFixtures,
mqttFixtures,
matterFixtures,
infraredFixtures,
zwaveJsFixtures,
zhaFixtures,
radioFrequencyFixtures,
tagsFixtures,
threadFixtures,
];
const collect = <T>(
pick: (fixtures: ConnectivityFixtures) => T[] | undefined
) => INTEGRATIONS.flatMap((fixtures) => pick(fixtures) ?? []);
export const connectivityComponents = collect((f) => f.components);
export const connectivityCommands = collect((f) => f.commands);
export const connectivityConfigEntries = collect((f) => f.configEntries);
export const connectivityManifests = collect((f) => f.manifests);
export const connectivityDevices = collect((f) => f.devices);
export const connectivityEntityRegistryEntries = collect(
(f) => f.entityRegistryEntries
);
export const connectivityEntities = () =>
INTEGRATIONS.flatMap((fixtures) => fixtures.entities?.() ?? []);
/** Backend translation resources, merged per category. */
export const connectivityBackendTranslations = INTEGRATIONS.reduce<
Record<string, Record<string, string>>
>((resources, fixtures) => {
for (const [category, keys] of Object.entries(
fixtures.backendTranslations ?? {}
)) {
resources[category] = { ...resources[category], ...keys };
}
return resources;
}, {});
+129
View File
@@ -0,0 +1,129 @@
import type { ConfigEntry } from "../../../../src/data/config_entries";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
import type { EntityInput } from "../../../../src/fake_data/entities/types";
// Builders for the registry fixtures, so each integration only spells out what
// makes its own entries different.
const BASE_CONFIG_ENTRY = {
source: "user",
state: "loaded" as const,
supports_options: false,
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
num_subentries: 0,
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_domain: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
};
export const configEntry = (
entryId: string,
domain: string,
title: string,
extra: Partial<ConfigEntry> = {}
): ConfigEntry => ({
...BASE_CONFIG_ENTRY,
entry_id: entryId,
domain,
title,
...extra,
});
const BASE_DEVICE = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
entry_type: null,
created_at: 0,
modified_at: 0,
};
export const device = (
id: string,
name: string,
manufacturer: string,
model: string,
entryId: string,
extra: Partial<DeviceRegistryEntry> = {}
): DeviceRegistryEntry => ({
...BASE_DEVICE,
id,
name,
manufacturer,
model,
config_entries: [entryId],
primary_config_entry: entryId,
...extra,
});
const BASE_REGISTRY_ENTRY = {
config_subentry_id: null,
area_id: null,
disabled_by: null,
icon: null,
labels: [] as string[],
categories: {},
hidden_by: null,
entity_category: null,
options: null,
created_at: 0,
modified_at: 0,
};
export const registryEntry = (
entityId: string,
deviceId: string,
entryId: string,
platform: string,
name?: string
): EntityRegistryEntry => ({
...BASE_REGISTRY_ENTRY,
entity_id: entityId,
id: entityId,
unique_id: entityId,
device_id: deviceId,
config_entry_id: entryId,
platform,
name: name ?? null,
has_entity_name: name === undefined,
});
/**
* The demo's `addEntities` builds the display entity registry from the entity
* inputs, so mirror each state's registry entry onto it. Panels count entities
* per device and per integration.
*/
export const withRegistryLinks = (
entries: EntityRegistryEntry[],
states: Record<string, EntityInput>
): EntityInput[] =>
Object.values(states).map((state) => {
const entry = entries.find(
(candidate) => candidate.entity_id === state.entity_id
);
return entry
? { ...state, device_id: entry.device_id!, platform: entry.platform }
: state;
});
export const minutesAgo = (minutes: number) =>
new Date(Date.now() - minutes * 60000).toISOString();
+25
View File
@@ -0,0 +1,25 @@
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
import { mockMqtt } from "./mqtt/mock";
import { mockRadioFrequency } from "./radio_frequency/mock";
import { mockSerial } from "./serial/mock";
import { mockThread } from "./thread/mock";
import { mockZha } from "./zha/mock";
import { mockZwaveJs } from "./zwave_js/mock";
// The WebSocket mocks, code-split into the config panel chunk.
const MOCKS = [
mockBluetooth,
mockSerial,
mockMatter,
mockMqtt,
mockZwaveJs,
mockZha,
mockRadioFrequency,
mockThread,
];
export const mockConnectivity = (hass: MockHomeAssistant) => {
MOCKS.forEach((mock) => mock(hass));
};
@@ -0,0 +1,107 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
minutesAgo,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-broadlink";
const DEVICES = [
device(
"broadlink-living-room",
"Living room blaster",
"Broadlink",
"RM4 pro",
ENTRY_ID,
{ area_id: "living_room" }
),
device(
"broadlink-bedroom",
"Bedroom blaster",
"Broadlink",
"RM mini 3",
ENTRY_ID,
{ area_id: "bedroom" }
),
];
// The infrared panel is entity driven: the proxy entities live in the
// `infrared` domain while their registry platform stays the integration that
// provides them.
const REGISTRY_ENTRIES = [
registryEntry(
"infrared.living_room_blaster_emitter",
"broadlink-living-room",
ENTRY_ID,
"broadlink",
"Emitter"
),
registryEntry(
"infrared.living_room_blaster_receiver",
"broadlink-living-room",
ENTRY_ID,
"broadlink",
"Receiver"
),
registryEntry(
"infrared.bedroom_blaster_emitter",
"broadlink-bedroom",
ENTRY_ID,
"broadlink",
"Emitter"
),
];
export const infraredFixtures: ConnectivityFixtures = {
components: ["infrared"],
manifests: [
manifest("broadlink", "Broadlink", {
integration_type: "hub",
iot_class: "local_polling",
}),
],
configEntries: [
{ type: "hub", entry: configEntry(ENTRY_ID, "broadlink", "RM4 pro") },
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
// A proxy entity's state is the timestamp it was last used.
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"infrared.living_room_blaster_emitter": {
entity_id: "infrared.living_room_blaster_emitter",
state: minutesAgo(12),
attributes: {
friendly_name: "Living room blaster Emitter",
device_class: "emitter",
},
},
"infrared.living_room_blaster_receiver": {
entity_id: "infrared.living_room_blaster_receiver",
state: minutesAgo(3),
attributes: {
friendly_name: "Living room blaster Receiver",
device_class: "receiver",
},
},
"infrared.bedroom_blaster_emitter": {
entity_id: "infrared.bedroom_blaster_emitter",
state: minutesAgo(1440),
attributes: {
friendly_name: "Bedroom blaster Emitter",
device_class: "emitter",
},
},
}),
backendTranslations: {
entity_component: {
// The emitter is the default device class, stored under the "_" key.
"component.infrared.entity_component._.name": "Emitter",
"component.infrared.entity_component.receiver.name": "Receiver",
},
},
};
@@ -0,0 +1,119 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-matter";
const DEVICES = [
device(
"matter-kitchen-light",
"Kitchen ceiling",
"Nanoleaf",
"Essentials A19",
ENTRY_ID,
{ area_id: "kitchen", sw_version: "3.5.7" }
),
device(
"matter-side-door-lock",
"Side door lock",
"Aqara",
"Smart Lock U100",
ENTRY_ID,
{ sw_version: "1.2.0" }
),
device("matter-office-plug", "Office plug", "Eve", "Energy", ENTRY_ID, {
area_id: "office",
sw_version: "3.2.0",
}),
device("matter-patio-sensor", "Patio sensor", "Eve", "Weather", ENTRY_ID, {
area_id: "garden",
sw_version: "3.2.1",
}),
];
const REGISTRY_ENTRIES = [
registryEntry(
"light.kitchen_ceiling",
"matter-kitchen-light",
ENTRY_ID,
"matter"
),
registryEntry("lock.side_door", "matter-side-door-lock", ENTRY_ID, "matter"),
registryEntry("switch.office_plug", "matter-office-plug", ENTRY_ID, "matter"),
registryEntry(
"sensor.office_plug_power",
"matter-office-plug",
ENTRY_ID,
"matter"
),
registryEntry(
"sensor.patio_temperature",
"matter-patio-sensor",
ENTRY_ID,
"matter"
),
];
export const matterFixtures: ConnectivityFixtures = {
components: ["matter"],
commands: ["matter/"],
manifests: [manifest("matter", "Matter", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "matter", "Matter", {
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"light.kitchen_ceiling": {
entity_id: "light.kitchen_ceiling",
state: "on",
attributes: {
friendly_name: "Kitchen ceiling",
supported_color_modes: ["color_temp"],
color_mode: "color_temp",
brightness: 204,
},
},
"lock.side_door": {
entity_id: "lock.side_door",
state: "locked",
attributes: { friendly_name: "Side door lock" },
},
"switch.office_plug": {
entity_id: "switch.office_plug",
state: "on",
attributes: { friendly_name: "Office plug" },
},
"sensor.office_plug_power": {
entity_id: "sensor.office_plug_power",
state: "42.5",
attributes: {
friendly_name: "Office plug power",
device_class: "power",
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.patio_temperature": {
entity_id: "sensor.patio_temperature",
state: "14.2",
attributes: {
friendly_name: "Patio temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
}),
};
+404
View File
@@ -0,0 +1,404 @@
import type {
MatterCommissioningParameters,
MatterFabricData,
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
MatterNodeDiagnostics,
} from "../../../../../src/data/matter";
import { NetworkType, NodeType } from "../../../../../src/data/matter";
import type {
MatterLockInfo,
MatterLockUser,
MatterLockUsersResponse,
SetMatterLockCredentialResult,
} from "../../../../../src/data/matter-lock";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
const EXT_PAN_ID = "dead00beef00cafe";
const THREAD_NETWORK = "ha-thread";
const NODES: MatterNetworkTopologyNode[] = [
{
id: "otbr",
kind: "border_router",
network_type: "thread",
ha_device_id: null,
role: "leader",
available: true,
ext_address: "f6a1c30d2b4e5f61",
rloc16: 0x4000,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
host_name: "homeassistant",
vendor_name: "Home Assistant",
model_name: "OpenThread Border Router",
},
{
id: "wifi-ap",
kind: "wifi_ap",
network_type: "wifi",
ha_device_id: null,
available: true,
ssid: "Home",
bssid: "3c:37:86:11:22:33",
vendor_name: "Ubiquiti",
model_name: "U6 Pro",
},
{
id: "node-1",
kind: "matter",
network_type: "thread",
node_id: 1,
ha_device_id: "matter-kitchen-light",
available: true,
role: "router",
ext_address: "10a2b3c4d5e6f708",
rloc16: 0x8401,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
vendor_name: "Nanoleaf",
model_name: "Essentials A19",
},
{
id: "node-2",
kind: "matter",
network_type: "thread",
node_id: 2,
ha_device_id: "matter-side-door-lock",
available: true,
role: "sleepy_end_device",
ext_address: "20b3c4d5e6f70819",
rloc16: 0x8402,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
vendor_name: "Aqara",
model_name: "Smart Lock U100",
},
{
id: "node-3",
kind: "matter",
network_type: "wifi",
node_id: 3,
ha_device_id: "matter-office-plug",
available: true,
ssid: "Home",
vendor_name: "Eve",
model_name: "Energy",
},
{
id: "node-4",
kind: "matter",
network_type: "thread",
node_id: 4,
ha_device_id: "matter-patio-sensor",
available: false,
role: "end_device",
ext_address: "30c4d5e6f708192a",
rloc16: 0x8403,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
vendor_name: "Eve",
model_name: "Weather",
},
{
id: "thread-unknown-1",
kind: "thread_unknown",
network_type: "thread",
available: true,
role: "end_device",
ext_address: "40d5e6f708192a3b",
rloc16: 0x8404,
ext_pan_id: EXT_PAN_ID,
network_name: THREAD_NETWORK,
},
];
const connection = (
source: string,
target: string,
network: string,
strength: MatterNetworkTopologyConnection["strength"],
lqi?: number,
rssi?: number
): MatterNetworkTopologyConnection => ({
source,
target,
network,
strength,
source_to_target: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
target_to_source: { strength, lqi: lqi ?? null, rssi: rssi ?? null },
via_route_table: false,
path_cost: null,
});
const CONNECTIONS: MatterNetworkTopologyConnection[] = [
connection("otbr", "node-1", "thread", "strong", 245, -42),
connection("otbr", "node-2", "thread", "medium", 160, -68),
connection("node-1", "node-4", "thread", "weak", 84, -86),
connection("node-1", "thread-unknown-1", "thread", "medium", 172, -63),
connection("wifi-ap", "node-3", "wifi", "strong", undefined, -47),
];
const TOPOLOGY: MatterNetworkTopology = {
collected_at: Date.now() / 1000,
nodes: NODES,
connections: CONNECTIONS,
};
const buildTopology = (): MatterNetworkTopology => ({
...TOPOLOGY,
collected_at: Date.now() / 1000,
});
const FABRICS: MatterFabricData[] = [
{
fabric_id: 1,
vendor_id: 4939,
fabric_index: 1,
fabric_label: "Home Assistant",
vendor_name: "Home Assistant",
},
];
const NODE_TYPE_BY_ROLE: Record<string, NodeType> = {
router: NodeType.ROUTING_END_DEVICE,
sleepy_end_device: NodeType.SLEEPY_END_DEVICE,
end_device: NodeType.END_DEVICE,
};
const NODES_BY_DEVICE_ID = new Map(
NODES.filter((node) => node.ha_device_id).map((node) => [
node.ha_device_id!,
node,
])
);
const nodeIpAddress = (node: MatterNetworkTopologyNode): string =>
node.network_type === "thread"
? `fd11:2233:4455:6677::${(node.node_id ?? 0).toString(16)}`
: `192.168.1.${100 + (node.node_id ?? 0)}`;
// Diagnostics are derived from the topology so a device's transport,
// availability and node type match the map. The device page reads them per
// device, and gates its actions on `available` and `network_type`.
const buildNodeDiagnostics = (
node: MatterNetworkTopologyNode
): MatterNodeDiagnostics => ({
node_id: node.node_id!,
network_type:
node.network_type === "thread" ? NetworkType.THREAD : NetworkType.WIFI,
node_type: node.is_bridge
? NodeType.BRIDGE
: (NODE_TYPE_BY_ROLE[node.role ?? ""] ?? NodeType.END_DEVICE),
network_name: node.network_name ?? node.ssid ?? undefined,
ip_adresses: [nodeIpAddress(node)],
mac_address: node.ext_address?.match(/.{2}/g)?.join(":"),
available: node.available !== false,
active_fabrics: FABRICS,
active_fabric_index: 1,
});
// The backend resolves the device before acting, so both node commands answer
// the same way when it cannot.
const nodeNotFound = (deviceId: string) =>
Promise.reject({
code: "node_not_found",
message: `No Matter node for device ${deviceId}`,
});
// The manual and QR codes below are the Matter test payload for passcode
// 20202021 with discriminator 3840; the three have to agree.
const COMMISSIONING_PARAMETERS: MatterCommissioningParameters = {
setup_pin_code: 20202021,
setup_manual_code: "34970112332",
setup_qr_code: "MT:Y.K9042C00KA0648G00",
};
const LOCK_INFO: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: ["pin"],
max_users: 10,
max_pin_users: 10,
max_rfid_users: null,
max_credentials_per_user: 2,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: null,
max_rfid_length: null,
};
const initialLockUsers = (): MatterLockUser[] => [
{
user_index: 1,
user_name: "Anne",
user_unique_id: 1,
user_status: "occupied_enabled",
user_type: "unrestricted_user",
credential_rule: "single",
credentials: [{ type: "pin", index: 1 }],
next_user_index: 2,
},
{
user_index: 2,
user_name: "Cleaner",
user_unique_id: 2,
user_status: "occupied_disabled",
user_type: "week_day_schedule_user",
credential_rule: "single",
credentials: [{ type: "pin", index: 2 }],
next_user_index: null,
},
];
// The manage dialog reloads the list after every add, edit and delete, so the
// mocked services keep the lock's users rather than answering from a constant,
// which would make every change look like it was reverted.
const lockUsers = new Map<string, MatterLockUser[]>();
const usersFor = (entityId: string): MatterLockUser[] => {
let users = lockUsers.get(entityId);
if (!users) {
users = initialLockUsers();
lockUsers.set(entityId, users);
}
return users;
};
// Lowest free slot, the way a lock hands out user and credential indexes.
const nextFreeIndex = (taken: number[]): number => {
let index = 1;
while (taken.includes(index)) {
index += 1;
}
return index;
};
export const mockMatter = (hass: MockHomeAssistant) => {
hass.mockWS("matter/network_topology", () => buildTopology());
hass.mockWS("matter/subscribe_network_topology", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.(buildTopology()))
);
hass.mockWS("matter/node_diagnostics", (msg: { device_id: string }) => {
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
return node ? buildNodeDiagnostics(node) : nodeNotFound(msg.device_id);
});
hass.mockWS("matter/ping_node", (msg: { device_id: string }) => {
const node = NODES_BY_DEVICE_ID.get(msg.device_id);
return node
? { [nodeIpAddress(node)]: node.available !== false }
: nodeNotFound(msg.device_id);
});
hass.mockWS("matter/interview_node", () => undefined);
// Actions the device page offers for an available node. Without these the
// dialogs behind them fail with `command_not_mocked`.
hass.mockWS(
"matter/open_commissioning_window",
() => COMMISSIONING_PARAMETERS
);
hass.mockWS("matter/remove_matter_fabric", () => undefined);
hass.mockWS("matter/set_wifi_credentials", () => undefined);
hass.mockWS("matter/set_thread", () => undefined);
// The lock device exposes "Manage lock", whose dialog reads back the response
// of these services.
hass.mockService("matter", "get_lock_info", (_data, target) => ({
[target!.entity_id]: LOCK_INFO,
}));
hass.mockService("matter", "get_lock_users", (_data, target) => ({
// Copied, the way a real response would be: the dialog assigns the list to
// reactive state, so handing back the same array leaves it unchanged and
// the list never rerenders.
[target!.entity_id]: {
max_users: LOCK_INFO.max_users!,
users: usersFor(target!.entity_id).map((user) => ({
...user,
credentials: user.credentials.map((credential) => ({ ...credential })),
})),
} satisfies MatterLockUsersResponse,
}));
// Renames the user the credential below created, or edits an existing one.
hass.mockService("matter", "set_lock_user", (data, target) => {
const user = usersFor(target!.entity_id).find(
(candidate) => candidate.user_index === data?.user_index
);
if (user) {
if (data?.user_name !== undefined) {
user.user_name = data.user_name;
}
if (data?.user_type !== undefined) {
user.user_type = data.user_type;
}
if (data?.credential_rule !== undefined) {
user.credential_rule = data.credential_rule;
}
}
return {};
});
hass.mockService("matter", "clear_lock_user", (data, target) => {
const users = usersFor(target!.entity_id);
const index = users.findIndex(
(candidate) => candidate.user_index === data?.user_index
);
if (index !== -1) {
users.splice(index, 1);
}
return {};
});
// Adding a user starts here: the credential creates it, and the dialog reads
// the assigned index straight back to name it.
hass.mockService("matter", "set_lock_credential", (data, target) => {
const users = usersFor(target!.entity_id);
const userIndex =
(data?.user_index as number | null | undefined) ??
nextFreeIndex(
users
.map((user) => user.user_index)
.filter((i): i is number => i !== null)
);
const credentialIndex =
(data?.credential_index as number | null | undefined) ??
nextFreeIndex(
users.flatMap((user) =>
user.credentials
.map((credential) => credential.index)
.filter((i): i is number => i !== null)
)
);
const credential = {
type: (data?.credential_type as string) ?? "pin",
index: credentialIndex,
};
const user = users.find((candidate) => candidate.user_index === userIndex);
if (user) {
user.credentials = [...user.credentials, credential];
} else {
users.push({
user_index: userIndex,
user_name: null,
user_unique_id: userIndex,
user_status: data?.user_status ?? "occupied_enabled",
user_type: data?.user_type ?? "unrestricted_user",
credential_rule: "single",
credentials: [credential],
next_user_index: null,
});
}
return {
[target!.entity_id]: {
credential_index: credentialIndex,
user_index: userIndex,
next_credential_index: null,
} satisfies SetMatterLockCredentialResult,
};
});
};
@@ -0,0 +1,90 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-mqtt";
const DEVICES = [
device(
"mqtt-fridge-sensor",
"Fridge sensor",
"Xiaomi",
"LYWSD03MMC",
ENTRY_ID,
{ area_id: "kitchen" }
),
device(
"mqtt-garage-door",
"Garage door",
"Shelly",
"Shelly Plus 1",
ENTRY_ID
),
];
const REGISTRY_ENTRIES = [
registryEntry(
"sensor.fridge_temperature",
"mqtt-fridge-sensor",
ENTRY_ID,
"mqtt"
),
registryEntry(
"sensor.fridge_battery",
"mqtt-fridge-sensor",
ENTRY_ID,
"mqtt"
),
registryEntry("cover.garage_door", "mqtt-garage-door", ENTRY_ID, "mqtt"),
];
export const mqttFixtures: ConnectivityFixtures = {
components: ["mqtt"],
// `execute_script` too: the panel publishes through a script action.
commands: ["mqtt/", "execute_script"],
manifests: [manifest("mqtt", "MQTT", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "mqtt", "core-mosquitto", {
supports_options: true,
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"sensor.fridge_temperature": {
entity_id: "sensor.fridge_temperature",
state: "21.4",
attributes: {
friendly_name: "Fridge temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"sensor.fridge_battery": {
entity_id: "sensor.fridge_battery",
state: "92",
attributes: {
friendly_name: "Fridge battery",
device_class: "battery",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"cover.garage_door": {
entity_id: "cover.garage_door",
state: "closed",
attributes: { friendly_name: "Garage door", device_class: "garage" },
},
}),
};
+192
View File
@@ -0,0 +1,192 @@
import type {
MQTTDeviceDebugInfo,
MQTTMessage,
} from "../../../../../src/data/mqtt";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
const PAYLOADS: Record<string, () => string> = {
"homeassistant/status": () => "online",
"zigbee2mqtt/bridge/state": () => '{"state":"online"}',
default: () =>
JSON.stringify({
battery: 92,
linkquality: 120,
// Built from whole tenths, so the payload never carries the noise a
// float sum leaves behind, like 21.599999999999998.
temperature: (214 + Math.floor(Math.random() * 11)) / 10,
}),
};
// Subscriptions take a topic filter, but a message carries the topic it was
// actually published on, so a filter has to be resolved to one concrete topic
// before it can be echoed back.
const resolveFilter = (filter: string): string =>
filter
.split("/")
.flatMap((level) => {
if (level === "+") {
return ["kitchen"];
}
if (level === "#") {
return ["kitchen", "temperature"];
}
return [level];
})
.join("/") || "homeassistant/status";
const buildMessage = (topic: string, qos: number): MQTTMessage => ({
topic,
payload: (PAYLOADS[topic] ?? PAYLOADS.default)(),
qos,
retain: 0,
time: new Date().toISOString(),
});
// A filter matches a topic level by level: "+" stands for one level, "#" for
// the rest of them.
const filterMatches = (filter: string, topic: string): boolean => {
const filterLevels = filter.split("/");
const topicLevels = topic.split("/");
for (let index = 0; index < filterLevels.length; index += 1) {
if (filterLevels[index] === "#") {
return true;
}
if (index >= topicLevels.length) {
return false;
}
if (
filterLevels[index] !== "+" &&
filterLevels[index] !== topicLevels[index]
) {
return false;
}
}
return filterLevels.length === topicLevels.length;
};
// The panel's listen card and its publish button talk to each other through
// the broker, so the mock keeps the subscriptions and delivers to them.
const subscriptions = new Set<{
filter: string;
qos: number;
deliver: (message: MQTTMessage) => void;
}>();
const topicDebug = (topic: string) => ({
topic,
messages: [buildMessage(topic, 0)],
});
// Keyed by device, the way the backend builds this per requested device.
const DEBUG_INFO: Record<string, MQTTDeviceDebugInfo> = {
"mqtt-fridge-sensor": {
entities: [
{
entity_id: "sensor.fridge_temperature",
discovery_data: {
topic: "homeassistant/sensor/fridge/temperature/config",
payload: {
name: "Temperature",
state_topic: "zigbee2mqtt/fridge",
unit_of_measurement: "°C",
device_class: "temperature",
},
},
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
transmitted: [],
},
{
entity_id: "sensor.fridge_battery",
discovery_data: {
topic: "homeassistant/sensor/fridge/battery/config",
payload: {
name: "Battery",
state_topic: "zigbee2mqtt/fridge",
unit_of_measurement: "%",
device_class: "battery",
},
},
subscriptions: [topicDebug("zigbee2mqtt/fridge")],
transmitted: [],
},
],
triggers: [],
},
"mqtt-garage-door": {
entities: [
{
entity_id: "cover.garage_door",
discovery_data: {
topic: "homeassistant/cover/garage/config",
payload: {
name: "Garage door",
state_topic: "shellyplus1/status/cover:0",
command_topic: "shellyplus1/command/cover:0",
device_class: "garage",
},
},
subscriptions: [topicDebug("shellyplus1/status/cover:0")],
transmitted: [topicDebug("shellyplus1/command/cover:0")],
},
],
triggers: [],
},
};
export const mockMqtt = (hass: MockHomeAssistant) => {
hass.mockWS(
"mqtt/subscribe",
(msg: { topic: string; qos?: number }, _hass, onChange) => {
// Echo a message on the subscribed topic every few seconds so the
// listen card in the MQTT panel shows traffic.
const qos = msg.qos ?? 0;
const topic = resolveFilter(msg.topic);
const deliver = (message: MQTTMessage) => onChange?.(message);
const subscription = { filter: msg.topic, qos, deliver };
subscriptions.add(subscription);
const send = () => deliver(buildMessage(topic, qos));
const stopInitial = emitInitial(send);
const interval = window.setInterval(send, 3000);
return () => {
stopInitial();
clearInterval(interval);
subscriptions.delete(subscription);
};
}
);
// The panel publishes through a script action rather than a `mqtt/` command,
// so without this the publish button only ever reports a failure. Delivering
// to the matching subscriptions is what makes the two halves of the panel
// work together.
hass.mockWS(
"execute_script",
(msg: { sequence: { action?: string; data?: Record<string, any> }[] }) => {
msg.sequence
?.filter((action) => action.action === "mqtt.publish")
.forEach((action) => {
const topic = String(action.data?.topic ?? "");
const message: MQTTMessage = {
topic,
payload: String(action.data?.payload ?? ""),
qos: Number(action.data?.qos ?? 0),
retain: action.data?.retain ? 1 : 0,
time: new Date().toISOString(),
};
subscriptions.forEach((subscription) => {
if (filterMatches(subscription.filter, topic)) {
subscription.deliver(message);
}
});
});
return { context: { id: "mock-context" }, response: {} };
}
);
hass.mockWS(
"mqtt/device/debug_info",
(msg: { device_id: string }): MQTTDeviceDebugInfo =>
DEBUG_INFO[msg.device_id] ?? { entities: [], triggers: [] }
);
};
@@ -0,0 +1,69 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
minutesAgo,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-rf-bridge";
const DEVICES = [
device(
"rf-bridge-garage",
"Garage bridge",
"Sonoff",
"RF Bridge R2",
ENTRY_ID,
{ area_id: "garden" }
),
device("rf-bridge-shed", "Shed bridge", "Sonoff", "RF Bridge R2", ENTRY_ID),
];
const REGISTRY_ENTRIES = [
registryEntry(
"radio_frequency.garage_bridge",
"rf-bridge-garage",
ENTRY_ID,
"esphome",
"Transceiver"
),
registryEntry(
"radio_frequency.shed_bridge",
"rf-bridge-shed",
ENTRY_ID,
"esphome",
"Transceiver"
),
];
export const radioFrequencyFixtures: ConnectivityFixtures = {
components: ["radio_frequency"],
commands: ["radio_frequency/"],
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
configEntries: [
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"radio_frequency.garage_bridge": {
entity_id: "radio_frequency.garage_bridge",
state: minutesAgo(47),
attributes: { friendly_name: "Garage bridge Transceiver" },
},
"radio_frequency.shed_bridge": {
entity_id: "radio_frequency.shed_bridge",
state: "unknown",
attributes: { friendly_name: "Shed bridge Transceiver" },
},
}),
backendTranslations: {
entity_component: {
"component.radio_frequency.entity_component._.name": "Transceiver",
},
},
};
@@ -0,0 +1,25 @@
import type { RadioFrequencyTransmitter } from "../../../../../src/data/radio_frequency";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
const TRANSMITTERS: RadioFrequencyTransmitter[] = [
{
entity_id: "radio_frequency.garage_bridge",
device_id: "rf-bridge-garage",
config_entry_id: "mock-rf-bridge",
supported_frequency_ranges: [[433920000, 433920000]],
supported_modulations: ["OOK"],
},
{
entity_id: "radio_frequency.shed_bridge",
device_id: "rf-bridge-shed",
config_entry_id: "mock-rf-bridge",
supported_frequency_ranges: [[433920000, 433920000]],
supported_modulations: ["OOK"],
},
];
export const mockRadioFrequency = (hass: MockHomeAssistant) => {
hass.mockWS("radio_frequency/list", () => ({
transmitters: TRANSMITTERS,
}));
};
@@ -0,0 +1,6 @@
import type { ConnectivityFixtures } from "../types";
export const serialFixtures: ConnectivityFixtures = {
components: ["usb"],
commands: ["usb/"],
};
+107
View File
@@ -0,0 +1,107 @@
import type { SerialPortUsage } from "../../../../../src/data/usb";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
const PORTS: SerialPortUsage[] = [
{
device: "/dev/ttyUSB0",
resolved_device:
"/dev/serial/by-id/usb-Nabu_Casa_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
manufacturer: "Nabu Casa",
description: "Home Assistant Connect ZBT-1",
interface_description: "Connect ZBT-1",
interface_num: 0,
vid: "10C4",
pid: "EA60",
bcd_device: 256,
matching_integrations: ["zha"],
present: true,
consumers: [
{
kind: "config_entry",
title: "Home Assistant Connect ZBT-1",
active: true,
domain: "zha",
config_entry_id: "mock-zha",
slug: null,
},
],
discovery_flows: [],
},
{
device: "/dev/ttyACM0",
resolved_device:
"/dev/serial/by-id/usb-Zooz_800_Z-Wave_Stick_533D004242-if00",
serial_number: "533D004242",
manufacturer: "Zooz",
description: "800 Series Z-Wave Long Range",
interface_description: null,
interface_num: 0,
vid: "10C4",
pid: "EA60",
bcd_device: 256,
matching_integrations: ["zwave_js"],
present: true,
consumers: [
{
kind: "config_entry",
title: "Z-Wave",
active: true,
domain: "zwave_js",
config_entry_id: "mock-zwave-js",
slug: null,
},
],
discovery_flows: [],
},
{
device: "/dev/ttyUSB1",
resolved_device:
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
serial_number: "A50285BI",
manufacturer: "FTDI",
description: "FT232R USB UART",
interface_description: null,
interface_num: 0,
vid: "0403",
pid: "6001",
bcd_device: 1536,
matching_integrations: [],
present: true,
consumers: [],
discovery_flows: [],
},
{
// A port that is configured but not currently plugged in. Its add-on icon
// is requested straight from /api/hassio/addons/<slug>/icon, which the
// demo has no backend for, so the icon stays blank here.
device: "/dev/ttyUSB2",
resolved_device: null,
serial_number: "0001",
manufacturer: "Silicon Labs",
description: "CP2102 USB to UART Bridge Controller",
interface_description: null,
interface_num: 0,
vid: "10C4",
pid: "EA60",
bcd_device: null,
matching_integrations: [],
present: false,
consumers: [
{
kind: "app",
title: "ESPHome Device Builder",
active: false,
domain: null,
config_entry_id: null,
slug: "esphome",
},
],
discovery_flows: [],
},
];
export const mockSerial = (hass: MockHomeAssistant) => {
hass.mockWS("usb/list_serial_ports", () => PORTS);
hass.mockWS("usb/scan", () => undefined);
};
@@ -0,0 +1,10 @@
// Mocked WebSocket subscriptions are registered synchronously, so a callback
// invoked straight away can land before the subscriber is ready for it: pages
// that ignore messages received before their first render drop it, and
// `createCollection` overwrites it with the empty initial fetch. Emitting the
// first message from a timeout matches the real backend, which always answers
// asynchronously.
export const emitInitial = (send: () => void): (() => void) => {
const timeout = window.setTimeout(send, 0);
return () => clearTimeout(timeout);
};
@@ -0,0 +1,6 @@
import type { ConnectivityFixtures } from "../types";
export const tagsFixtures: ConnectivityFixtures = {
components: ["tag"],
commands: ["tag/"],
};
@@ -0,0 +1,31 @@
import { manifest } from "../../manifest";
import { configEntry } from "../helpers";
import type { ConnectivityFixtures } from "../types";
const THREAD_ENTRY_ID = "mock-thread";
const OTBR_ENTRY_ID = "mock-otbr";
export const threadFixtures: ConnectivityFixtures = {
components: ["thread", "otbr"],
commands: ["thread/", "otbr/"],
manifests: [
manifest("thread", "Thread", {
integration_type: "service",
iot_class: "local_polling",
}),
manifest("otbr", "Open Thread Border Router", {
integration_type: "service",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "service",
entry: configEntry(THREAD_ENTRY_ID, "thread", "Thread"),
},
{
type: "service",
entry: configEntry(OTBR_ENTRY_ID, "otbr", "Open Thread Border Router"),
},
],
};
+441
View File
@@ -0,0 +1,441 @@
import type { OTBRInfoDict } from "../../../../../src/data/otbr";
import type {
ThreadDataSet,
ThreadRouter,
} from "../../../../../src/data/thread";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
const HA_EXT_PAN_ID = "DEAD00BEEF00CAFE";
const AMAZON_EXT_PAN_ID = "0011223344556677";
const OTBR_EXT_ADDRESS = "f6a1c30d2b4e5f61";
const OTBR_BORDER_AGENT_ID = "230c6a1ac57f6f4be262acf32e5ef52c";
const ROUTERS: ThreadRouter[] = [
{
instance_name: "HomeAssistant OpenThreadBorderRouter",
addresses: ["192.168.1.10"],
border_agent_id: OTBR_BORDER_AGENT_ID,
brand: "homeassistant",
extended_address: OTBR_EXT_ADDRESS,
extended_pan_id: HA_EXT_PAN_ID,
model_name: "OpenThread Border Router",
network_name: "ha-thread",
server: "core-openthread-border-router.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Home Assistant",
},
{
instance_name: "HomePod mini",
addresses: ["192.168.1.24"],
border_agent_id: "6a1ac57f6f4be262acf32e5ef52c230c",
brand: "apple",
extended_address: "aabbccddeeff0011",
extended_pan_id: HA_EXT_PAN_ID,
model_name: "HomePod mini",
network_name: "ha-thread",
server: "homepod-mini.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Apple Inc.",
},
{
instance_name: "Nest Hub",
addresses: ["192.168.1.31"],
border_agent_id: "ac57f6f4be262acf32e5ef52c230c6a1",
brand: "google",
extended_address: "bbccddeeff001122",
extended_pan_id: HA_EXT_PAN_ID,
model_name: "Google Nest Hub",
network_name: "ha-thread",
server: "nest-hub.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Google Inc.",
},
{
instance_name: "Echo (4th Gen)",
addresses: ["192.168.1.42"],
border_agent_id: "57f6f4be262acf32e5ef52c230c6a1ac",
brand: "amazon",
extended_address: "ccddeeff00112233",
extended_pan_id: AMAZON_EXT_PAN_ID,
model_name: "Echo",
network_name: "AmazonThread",
server: "amazon-echo.local.",
thread_version: "1.3.0",
unconfigured: null,
vendor_name: "Amazon",
},
];
const decodeUtf8 = (value: string): string | undefined => {
const bytes = Uint8Array.from(
(value.match(/../g) ?? []).map((byte) => parseInt(byte, 16))
);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return undefined;
}
};
const tlv = (type: number, value: string) =>
type.toString(16).padStart(2, "0").toUpperCase() +
(value.length / 2).toString(16).padStart(2, "0").toUpperCase() +
value.toUpperCase();
const textToHex = (text: string) =>
Array.from(text)
.map((character) => character.charCodeAt(0).toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
const replaceTlvField = (value: string, type: number, replacement: string) => {
let index = 0;
let out = "";
let replaced = false;
while (index + 4 <= value.length) {
const fieldType = parseInt(value.slice(index, index + 2), 16);
const length = parseInt(value.slice(index + 2, index + 4), 16);
const end = index + 4 + length * 2;
out +=
fieldType === type ? tlv(type, replacement) : value.slice(index, end);
replaced = replaced || fieldType === type;
index = end;
}
return replaced ? out : out + tlv(type, replacement);
};
const buildDatasetTlv = (dataset: ThreadDataSet) =>
[
tlv(0x0e, "0000000000010000"),
tlv(0x00, `00${(dataset.channel ?? 15).toString(16).padStart(4, "0")}`),
tlv(0x35, "000040010200"),
tlv(0x02, dataset.extended_pan_id),
tlv(0x07, "FD11220000000000"),
tlv(0x05, "00112233445566778899AABBCCDDEEFF"),
dataset.network_name === null
? ""
: tlv(0x03, textToHex(dataset.network_name)),
tlv(0x01, (dataset.pan_id ?? "1234").padStart(4, "0")),
tlv(0x04, "1035060004001FFFE00C0402A0F7F800"),
tlv(0x0c, "02A0F7F8"),
].join("");
const parseDatasetTlv = (value: string) => {
if (value.length % 2 !== 0 || !/^[0-9A-Fa-f]*$/.test(value)) {
return undefined;
}
const fields = new Map<number, string>();
let index = 0;
while (index < value.length) {
if (index + 4 > value.length) {
return undefined;
}
const type = parseInt(value.slice(index, index + 2), 16);
const length = parseInt(value.slice(index + 2, index + 4), 16);
const start = index + 4;
const end = start + length * 2;
if (end > value.length) {
return undefined;
}
if (fields.has(type)) {
return undefined;
}
fields.set(type, value.slice(start, end).toUpperCase());
index = end;
}
const extendedPanId = fields.get(0x02);
const networkName = fields.get(0x03);
const activeTimestamp = fields.get(0x0e);
if (
!extendedPanId ||
extendedPanId.length !== 16 ||
!activeTimestamp ||
activeTimestamp.length !== 16
) {
return undefined;
}
const decodedName =
networkName === undefined ? null : decodeUtf8(networkName);
if (decodedName === undefined) {
return undefined;
}
const channel = fields.get(0x00);
const channelNumber = channel ? parseInt(channel.slice(2), 16) : null;
if (channelNumber === 0) {
return undefined;
}
return {
activeTimestamp,
extendedPanId,
networkName: decodedName,
panId: fields.get(0x01) ?? null,
channel: channelNumber,
};
};
const DATASETS: ThreadDataSet[] = [
{
channel: 15,
created: new Date(Date.now() - 86400000 * 30).toISOString(),
dataset_id: "ha-thread-dataset",
extended_pan_id: HA_EXT_PAN_ID,
network_name: "ha-thread",
pan_id: "1234",
preferred_border_agent_id: OTBR_BORDER_AGENT_ID,
preferred_extended_address: OTBR_EXT_ADDRESS,
preferred: true,
source: "otbr",
},
];
const DATASET_TLVS: Record<string, string> = Object.fromEntries(
DATASETS.map((dataset) => [dataset.dataset_id, buildDatasetTlv(dataset)])
);
const OTBR_INFO: OTBRInfoDict = {
[OTBR_EXT_ADDRESS]: {
active_dataset_tlvs: DATASET_TLVS["ha-thread-dataset"],
border_agent_id: OTBR_BORDER_AGENT_ID,
channel: 15,
extended_address: OTBR_EXT_ADDRESS,
extended_pan_id: HA_EXT_PAN_ID,
url: "http://core-openthread-border-router:8081",
},
};
let added = 0;
let created = 0;
const randomExtendedPanId = () =>
Array.from({ length: 8 }, () =>
Math.floor(Math.random() * 256)
.toString(16)
.padStart(2, "0")
)
.join("")
.toUpperCase();
const moveRouter = (
extendedAddress: string,
extendedPanId: string,
networkName: string | null,
datasetId: string
) => {
const info = OTBR_INFO[extendedAddress];
const moved = DATASETS.find((item) => item.dataset_id === datasetId);
if (info) {
info.extended_pan_id = extendedPanId;
info.active_dataset_tlvs =
DATASET_TLVS[datasetId] ?? info.active_dataset_tlvs;
info.channel = moved?.channel ?? info.channel;
}
const router = ROUTERS.find(
(candidate) => candidate.extended_address === extendedAddress
);
if (router) {
router.extended_pan_id = extendedPanId;
router.network_name = networkName;
announce(router);
}
};
type RouterListener = (event: {
key: string;
type: "router_discovered" | "router_removed";
data: ThreadRouter;
}) => void;
const listeners = new Set<RouterListener>();
const announce = (router: ThreadRouter) =>
listeners.forEach((listener) =>
listener({
key: router.extended_address,
type: "router_discovered",
data: router,
})
);
export const mockThread = (hass: MockHomeAssistant) => {
hass.mockWS("thread/discover_routers", (_msg, _hass, onChange) => {
const listener = onChange as RouterListener | undefined;
if (listener) {
listeners.add(listener);
}
const stopInitial = emitInitial(() => ROUTERS.forEach(announce));
return () => {
stopInitial();
if (listener) {
listeners.delete(listener);
}
};
});
hass.mockWS("thread/list_datasets", () => ({
datasets: DATASETS.map((dataset) => ({ ...dataset })),
}));
hass.mockWS("thread/get_dataset_tlv", (msg: { dataset_id: string }) => {
const value = DATASET_TLVS[msg.dataset_id];
if (!value) {
throw new Error(`Dataset ${msg.dataset_id} not found`);
}
return { tlv: value };
});
hass.mockWS("otbr/info", () => OTBR_INFO);
hass.mockWS(
"thread/add_dataset_tlv",
(msg: { source: string; tlv: string }) => {
const parsed = parseDatasetTlv(msg.tlv);
if (!parsed) {
throw new Error("Invalid dataset");
}
const existing = DATASETS.find(
(candidate) => candidate.extended_pan_id === parsed.extendedPanId
);
if (existing) {
const current = parseDatasetTlv(DATASET_TLVS[existing.dataset_id]);
if (current && parsed.activeTimestamp <= current.activeTimestamp) {
return undefined;
}
existing.channel = parsed.channel;
existing.network_name = parsed.networkName;
existing.pan_id = parsed.panId;
DATASET_TLVS[existing.dataset_id] = msg.tlv.toUpperCase();
return undefined;
}
added += 1;
const dataset: ThreadDataSet = {
channel: parsed.channel,
created: new Date().toISOString(),
dataset_id: `added-dataset-${added}`,
extended_pan_id: parsed.extendedPanId,
network_name: parsed.networkName,
pan_id: parsed.panId,
preferred_border_agent_id: null,
preferred_extended_address: null,
preferred: false,
source: msg.source,
};
DATASETS.push(dataset);
DATASET_TLVS[dataset.dataset_id] = msg.tlv.toUpperCase();
return undefined;
}
);
hass.mockWS("thread/delete_dataset", (msg: { dataset_id: string }) => {
const index = DATASETS.findIndex(
(dataset) => dataset.dataset_id === msg.dataset_id
);
if (index === -1) {
throw new Error(`Dataset ${msg.dataset_id} not found`);
}
if (DATASETS[index].preferred) {
throw new Error("Preferred dataset cannot be deleted");
}
DATASETS.splice(index, 1);
delete DATASET_TLVS[msg.dataset_id];
return undefined;
});
hass.mockWS("thread/set_preferred_dataset", (msg: { dataset_id: string }) => {
DATASETS.forEach((dataset) => {
dataset.preferred = dataset.dataset_id === msg.dataset_id;
});
return undefined;
});
hass.mockWS(
"thread/set_preferred_border_agent",
(msg: {
dataset_id: string;
border_agent_id: string | null;
extended_address: string;
}) => {
const dataset = DATASETS.find(
(candidate) => candidate.dataset_id === msg.dataset_id
);
if (dataset) {
dataset.preferred_border_agent_id = msg.border_agent_id;
dataset.preferred_extended_address = msg.extended_address;
}
return undefined;
}
);
hass.mockWS("otbr/create_network", (msg: { extended_address: string }) => {
created += 1;
const dataset: ThreadDataSet = {
channel: 15,
created: new Date().toISOString(),
dataset_id: `created-dataset-${created}`,
extended_pan_id: randomExtendedPanId(),
network_name: `ha-thread-${created}`,
pan_id: "1234",
preferred_border_agent_id: null,
preferred_extended_address: null,
preferred: false,
source: "otbr",
};
DATASETS.push(dataset);
DATASET_TLVS[dataset.dataset_id] = buildDatasetTlv(dataset);
moveRouter(
msg.extended_address,
dataset.extended_pan_id,
dataset.network_name,
dataset.dataset_id
);
return undefined;
});
hass.mockWS(
"otbr/set_network",
(msg: { extended_address: string; dataset_id: string }) => {
const dataset = DATASETS.find(
(candidate) => candidate.dataset_id === msg.dataset_id
);
if (dataset) {
const info = OTBR_INFO[msg.extended_address];
if (info) {
info.channel = dataset.channel ?? info.channel;
}
moveRouter(
msg.extended_address,
dataset.extended_pan_id,
dataset.network_name,
dataset.dataset_id
);
}
return undefined;
}
);
hass.mockWS(
"otbr/set_channel",
(msg: { extended_address: string; channel: number }) => {
const info = OTBR_INFO[msg.extended_address];
if (info) {
info.channel = msg.channel;
const dataset = DATASETS.find(
(candidate) => candidate.extended_pan_id === info.extended_pan_id
);
if (dataset) {
dataset.channel = msg.channel;
DATASET_TLVS[dataset.dataset_id] = replaceTlvField(
DATASET_TLVS[dataset.dataset_id],
0x00,
`00${msg.channel.toString(16).padStart(4, "0")}`
);
info.active_dataset_tlvs = DATASET_TLVS[dataset.dataset_id];
}
}
return { delay: 120 };
}
);
};
+38
View File
@@ -0,0 +1,38 @@
import type { ConfigEntry } from "../../../../src/data/config_entries";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
import type {
IntegrationManifest,
IntegrationType,
} from "../../../../src/data/integration";
import type { TranslationCategory } from "../../../../src/data/translation";
import type { EntityInput } from "../../../../src/fake_data/entities/types";
export interface DemoConfigEntry {
entry: ConfigEntry;
type: IntegrationType;
}
/**
* Everything one connectivity integration contributes to the demo besides its
* WebSocket mocks. This is loaded eagerly, together with the registries, so it
* must not pull in the mocks (which are code-split into the config panel
* chunk). Each integration owns one of these, so they stay independent.
*/
export interface ConnectivityFixtures {
/** Components to load, so the integration's panel is reachable. */
components: string[];
/** WS command prefixes served by the integration's mock, if it has one. */
commands?: string[];
configEntries?: DemoConfigEntry[];
/** Manifests for the domains above, so their integration pages open. */
manifests?: IntegrationManifest[];
devices?: DeviceRegistryEntry[];
entityRegistryEntries?: EntityRegistryEntry[];
/** States for the entities above; built lazily so timestamps stay fresh. */
entities?: () => EntityInput[];
/** Backend translations the panel looks up, by category. */
backendTranslations?: Partial<
Record<TranslationCategory, Record<string, string>>
>;
}
+232
View File
@@ -0,0 +1,232 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-zha";
export const COORDINATOR_IEEE = "00:12:4b:00:24:c2:e1:00";
export const PORCH_IEEE = "84:2e:14:ff:fe:11:22:33";
export const MOTION_IEEE = "cc:cc:cc:ff:fe:44:55:66";
export const PLUG_IEEE = "00:15:8d:00:03:aa:bb:cc";
export const KITCHEN_IEEE = "84:2e:14:ff:fe:aa:bb:01";
export const LANDING_IEEE = "00:15:8d:00:03:aa:bb:02";
export const GARAGE_IEEE = "00:15:8d:00:03:aa:bb:03";
export const OFFICE_IEEE = "00:15:8d:00:03:aa:bb:04";
const DEVICES = [
device(
"zha-coordinator",
"Home Assistant Connect ZBT-1",
"Nabu Casa",
"Connect ZBT-1",
ENTRY_ID,
{
sw_version: "7.4.4.0",
connections: [["zigbee", COORDINATOR_IEEE]],
identifiers: [["zha", COORDINATOR_IEEE]],
}
),
device(
"zha-porch-light",
"Porch light",
"IKEA of Sweden",
"TRADFRI bulb E27 CWS 806lm",
ENTRY_ID,
{
connections: [["zigbee", PORCH_IEEE]],
identifiers: [["zha", PORCH_IEEE]],
}
),
device(
"zha-hall-motion",
"Hall motion",
"IKEA of Sweden",
"TRADFRI motion sensor",
ENTRY_ID,
{
connections: [["zigbee", MOTION_IEEE]],
identifiers: [["zha", MOTION_IEEE]],
}
),
device("zha-tv-plug", "TV plug", "Innr", "SP 220", ENTRY_ID, {
area_id: "living_room",
connections: [["zigbee", PLUG_IEEE]],
identifiers: [["zha", PLUG_IEEE]],
}),
device(
"zha-kitchen-switch",
"Kitchen switch",
"IKEA of Sweden",
"TRADFRI on/off switch",
ENTRY_ID,
{
area_id: "kitchen",
connections: [["zigbee", KITCHEN_IEEE]],
identifiers: [["zha", KITCHEN_IEEE]],
}
),
device(
"zha-landing-sensor",
"Landing sensor",
"Aqara",
"WSDCGQ11LM",
ENTRY_ID,
{
connections: [["zigbee", LANDING_IEEE]],
identifiers: [["zha", LANDING_IEEE]],
}
),
device(
"zha-garage-contact",
"Garage contact",
"Aqara",
"MCCGQ11LM",
ENTRY_ID,
{
connections: [["zigbee", GARAGE_IEEE]],
identifiers: [["zha", GARAGE_IEEE]],
}
),
device("zha-office-plug", "Office plug", "Innr", "SP 240", ENTRY_ID, {
area_id: "office",
connections: [["zigbee", OFFICE_IEEE]],
identifiers: [["zha", OFFICE_IEEE]],
}),
];
const REGISTRY_ENTRIES = [
registryEntry("light.porch", "zha-porch-light", ENTRY_ID, "zha"),
registryEntry(
"binary_sensor.hall_motion",
"zha-hall-motion",
ENTRY_ID,
"zha"
),
registryEntry("switch.tv_plug", "zha-tv-plug", ENTRY_ID, "zha"),
registryEntry(
"sensor.kitchen_switch_battery",
"zha-kitchen-switch",
ENTRY_ID,
"zha"
),
registryEntry(
"sensor.landing_temperature",
"zha-landing-sensor",
ENTRY_ID,
"zha"
),
registryEntry(
"binary_sensor.garage_contact",
"zha-garage-contact",
ENTRY_ID,
"zha"
),
registryEntry("switch.office_desk", "zha-office-plug", ENTRY_ID, "zha"),
];
export const AREA_BY_IEEE: Record<string, string> = Object.fromEntries(
DEVICES.flatMap((entry) => {
const areaId = entry.area_id;
return areaId
? entry.connections
.filter(([type]) => type === "zigbee")
.map(([, ieee]) => [ieee, areaId])
: [];
})
);
export const zhaFixtures: ConnectivityFixtures = {
components: ["zha"],
commands: ["zha/"],
manifests: [
manifest("zha", "Zigbee Home Automation", {
integration_type: "hub",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "zha", "Home Assistant Connect ZBT-1", {
supports_options: true,
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"light.porch": {
entity_id: "light.porch",
state: "off",
attributes: {
friendly_name: "Porch light",
supported_color_modes: ["hs"],
},
},
"binary_sensor.hall_motion": {
entity_id: "binary_sensor.hall_motion",
state: "off",
attributes: { friendly_name: "Hall motion", device_class: "motion" },
},
"switch.tv_plug": {
entity_id: "switch.tv_plug",
state: "unavailable",
attributes: { friendly_name: "TV plug" },
},
"sensor.kitchen_switch_battery": {
entity_id: "sensor.kitchen_switch_battery",
state: "78",
attributes: {
friendly_name: "Kitchen switch battery",
device_class: "battery",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"sensor.landing_temperature": {
entity_id: "sensor.landing_temperature",
state: "19.6",
attributes: {
friendly_name: "Landing temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"binary_sensor.garage_contact": {
entity_id: "binary_sensor.garage_contact",
state: "off",
attributes: { friendly_name: "Garage contact", device_class: "door" },
},
"switch.office_desk": {
entity_id: "switch.office_desk",
state: "on",
attributes: { friendly_name: "Office desk" },
},
}),
backendTranslations: {
config_panel: {
"component.zha.config_panel.zha_options.title": "Global options",
"component.zha.config_panel.zha_options.default_light_transition":
"Default light transition time (seconds)",
"component.zha.config_panel.zha_options.enhanced_light_transition":
"Enable enhanced light color/temperature transition from an off state",
"component.zha.config_panel.zha_options.always_prefer_xy_color_mode":
"Always prefer XY color mode",
"component.zha.config_panel.zha_alarm_options.title": "Alarm options",
"component.zha.config_panel.zha_alarm_options.alarm_master_code":
"Alarm master code",
"component.zha.config_panel.zha_alarm_options.alarm_failed_tries":
"Failed authentication attempts before restart",
"component.zha.config_panel.zha_alarm_options.alarm_arm_requires_code":
"Code required for arming",
},
},
};
+793
View File
@@ -0,0 +1,793 @@
import type {
Attribute,
AttributeConfigurationStatus,
Cluster,
ClusterConfigurationEvent,
Command,
Neighbor,
ReadAttributeServiceData,
ZHAConfiguration,
ZHADevice,
ZHADeviceEndpoint,
ZHAGroup,
ZHAEntityReference,
ZHAGroupMember,
ZHANetworkBackup,
ZHANetworkSettings,
} from "../../../../../src/data/zha";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
import {
AREA_BY_IEEE,
LANDING_IEEE,
COORDINATOR_IEEE,
GARAGE_IEEE,
KITCHEN_IEEE,
MOTION_IEEE,
OFFICE_IEEE,
PLUG_IEEE,
PORCH_IEEE,
} from "./fixtures";
const neighbor = (
ieee: string,
nwk: string,
lqi: string,
relationship: string,
depth = "1"
): Neighbor => ({ ieee, nwk, lqi, depth, relationship });
const DEVICES: ZHADevice[] = [
{
available: true,
name: "Nabu Casa Connect ZBT-1",
ieee: COORDINATOR_IEEE,
nwk: 0x0000,
lqi: 255,
rssi: "0",
last_seen: minutesAgo(0),
manufacturer: "Nabu Casa",
model: "Connect ZBT-1",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-coordinator",
user_given_name: "Home Assistant Connect ZBT-1",
power_source: "Mains",
device_type: "Coordinator",
active_coordinator: true,
signature: {},
neighbors: [
neighbor(PORCH_IEEE, "0x1a2b", "224", "Child"),
neighbor(PLUG_IEEE, "0x3c4d", "198", "Child"),
neighbor(OFFICE_IEEE, "0x7a8b", "211", "Child"),
],
routes: [],
},
{
available: true,
name: "TRADFRI bulb E27 CWS 806lm",
ieee: PORCH_IEEE,
nwk: 0x1a2b,
lqi: 224,
rssi: "-58",
last_seen: minutesAgo(2),
manufacturer: "IKEA of Sweden",
model: "TRADFRI bulb E27 CWS 806lm",
quirk_applied: true,
quirk_class: "zhaquirks.ikea.bulb.IkeaBulb",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-porch-light",
user_given_name: "Porch light",
power_source: "Mains",
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [
neighbor(COORDINATOR_IEEE, "0x0000", "224", "Parent", "0"),
neighbor(MOTION_IEEE, "0x5e6f", "142", "Child", "2"),
neighbor(KITCHEN_IEEE, "0x9c0d", "186", "Sibling", "1"),
],
routes: [],
},
{
available: true,
name: "TRADFRI motion sensor",
ieee: MOTION_IEEE,
nwk: 0x5e6f,
lqi: 142,
rssi: "-77",
last_seen: minutesAgo(9),
manufacturer: "IKEA of Sweden",
model: "TRADFRI motion sensor",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-hall-motion",
user_given_name: "Hall motion",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
routes: [],
},
{
available: false,
name: "Innr SP 220",
ieee: PLUG_IEEE,
nwk: 0x3c4d,
lqi: 198,
rssi: "-64",
last_seen: minutesAgo(240),
manufacturer: "Innr",
model: "SP 220",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4448,
device_reg_id: "zha-tv-plug",
user_given_name: "TV plug",
power_source: "Mains",
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
routes: [],
},
{
available: true,
name: "TRADFRI on/off switch",
ieee: KITCHEN_IEEE,
nwk: 0x9c0d,
lqi: 186,
rssi: "-69",
last_seen: minutesAgo(4),
manufacturer: "IKEA of Sweden",
model: "TRADFRI on/off switch",
quirk_applied: true,
quirk_class: "zhaquirks.ikea.onoffswitch.IkeaSwitch",
entities: [],
manufacturer_code: 4476,
device_reg_id: "zha-kitchen-switch",
user_given_name: "Kitchen switch",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
routes: [],
},
{
available: true,
name: "Aqara temperature sensor",
ieee: LANDING_IEEE,
nwk: 0xab12,
lqi: 164,
rssi: "-74",
last_seen: minutesAgo(6),
manufacturer: "Aqara",
model: "WSDCGQ11LM",
quirk_applied: true,
quirk_class: "zhaquirks.xiaomi.aqara.weather.Weather",
entities: [],
manufacturer_code: 4447,
device_reg_id: "zha-landing-sensor",
user_given_name: "Landing sensor",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
routes: [],
},
{
available: true,
name: "Aqara door sensor",
ieee: GARAGE_IEEE,
nwk: 0xcd34,
lqi: 118,
rssi: "-83",
last_seen: minutesAgo(21),
manufacturer: "Aqara",
model: "MCCGQ11LM",
quirk_applied: true,
quirk_class: "zhaquirks.xiaomi.aqara.magnet.Magnet",
entities: [],
manufacturer_code: 4447,
device_reg_id: "zha-garage-contact",
user_given_name: "Garage contact",
power_source: "Battery",
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
routes: [],
},
{
available: true,
name: "Innr SP 240",
ieee: OFFICE_IEEE,
nwk: 0x7a8b,
lqi: 211,
rssi: "-61",
last_seen: minutesAgo(1),
manufacturer: "Innr",
model: "SP 240",
quirk_applied: false,
quirk_class: "zigpy.device.Device",
entities: [],
manufacturer_code: 4448,
device_reg_id: "zha-office-plug",
user_given_name: "Office plug",
power_source: "Mains",
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [
neighbor(COORDINATOR_IEEE, "0x0000", "211", "Parent", "0"),
neighbor(LANDING_IEEE, "0xab12", "164", "Child", "2"),
neighbor(GARAGE_IEEE, "0xcd34", "118", "Child", "2"),
],
routes: [],
},
];
DEVICES.forEach((zhaDevice) => {
zhaDevice.area_id = AREA_BY_IEEE[zhaDevice.ieee];
});
const deviceByIeee = (ieee: string): ZHADevice =>
DEVICES.find((d) => d.ieee === ieee)!;
const ENDPOINT_ENTITIES: Record<string, { entity_id: string; name: string }[]> =
{
[PORCH_IEEE]: [{ entity_id: "light.porch", name: "Porch light" }],
[PLUG_IEEE]: [{ entity_id: "switch.tv_plug", name: "TV plug" }],
[OFFICE_IEEE]: [{ entity_id: "switch.office_desk", name: "Office desk" }],
};
const member = (ieee: string, endpointId = 1): ZHADeviceEndpoint => ({
device: deviceByIeee(ieee),
endpoint_id: endpointId,
entities: (ENDPOINT_ENTITIES[ieee] ?? []).map(
(entity) =>
({
...entity,
original_name: entity.name,
}) as ZHAEntityReference
),
});
const GROUPS: ZHAGroup[] = [
{
name: "Downstairs lights",
group_id: 1,
members: [member(PORCH_IEEE), member(OFFICE_IEEE)],
},
{
name: "Outdoor lights",
group_id: 2,
members: [member(PORCH_IEEE)],
},
];
const CONFIGURATION: ZHAConfiguration = {
data: {
zha_options: {
default_light_transition: 0,
enhanced_light_transition: false,
light_transitioning_flag: true,
always_prefer_xy_color_mode: true,
group_members_assume_state: true,
consider_unavailable_mains: 7200,
consider_unavailable_battery: 21600,
},
zha_alarm_options: {
alarm_master_code: "1234",
alarm_failed_tries: 3,
alarm_arm_requires_code: false,
},
},
schemas: {
zha_options: [
{
name: "default_light_transition",
required: true,
selector: { number: { min: 0, max: 2 ** 16 / 10, step: 0.1 } },
},
{
name: "enhanced_light_transition",
required: true,
selector: { boolean: {} },
},
{
name: "always_prefer_xy_color_mode",
required: true,
selector: { boolean: {} },
},
],
zha_alarm_options: [
{ name: "alarm_master_code", required: true, selector: { text: {} } },
{
name: "alarm_failed_tries",
required: true,
selector: { number: { min: 0, max: 2 ** 8, mode: "box" } },
},
{
name: "alarm_arm_requires_code",
required: true,
selector: { boolean: {} },
},
],
},
};
const BACKUPS: ZHANetworkBackup[] = [];
const NETWORK_SETTINGS: ZHANetworkSettings = {
radio_type: "ezsp",
device: { path: "/dev/ttyUSB0", baudrate: 115200, flow_control: "hardware" },
settings: {
backup_time: new Date(Date.now() - 3600000).toISOString(),
node_info: {
nwk: "0x0000",
ieee: COORDINATOR_IEEE,
logical_type: "coordinator",
},
network_info: {
extended_pan_id: "b0:23:2f:cc:aa:11:22:33",
pan_id: "0x1234",
nwk_update_id: 0,
nwk_manager_id: "0x0000",
channel: 15,
channel_mask: [15, 20, 25],
security_level: 5,
network_key: {
key: "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
tx_counter: 138234,
rx_counter: 0,
seq: 0,
partner_ieee: "ff:ff:ff:ff:ff:ff:ff:ff",
},
tc_link_key: {
key: "5a:69:67:42:65:65:41:6c:6c:69:61:6e:63:65:30:39",
tx_counter: 0,
rx_counter: 0,
seq: 0,
partner_ieee: COORDINATOR_IEEE,
},
key_table: [],
children: [PORCH_IEEE, PLUG_IEEE, OFFICE_IEEE],
nwk_addresses: {
[PORCH_IEEE]: "0x1a2b",
[MOTION_IEEE]: "0x5e6f",
[PLUG_IEEE]: "0x3c4d",
[KITCHEN_IEEE]: "0x9c0d",
[LANDING_IEEE]: "0xab12",
[GARAGE_IEEE]: "0xcd34",
[OFFICE_IEEE]: "0x7a8b",
},
stack_specific: {},
metadata: { ezsp: { stack_version: "7.4.4.0" } },
source: "[email protected]",
},
},
};
interface ClusterDefinition {
name: string;
attributes: Attribute[];
commands: Command[];
}
const numberField = (name: string, max: number) => ({
name,
required: true,
selector: { number: { min: 0, max, mode: "box" as const } },
});
const CLUSTERS: Record<number, ClusterDefinition> = {
0: {
name: "Basic",
attributes: [
{ name: "zcl_version", id: 0 },
{ name: "app_version", id: 1 },
{ name: "manufacturer", id: 4 },
{ name: "model", id: 5 },
],
commands: [],
},
1: {
name: "PowerConfiguration",
attributes: [
{ name: "battery_voltage", id: 32 },
{ name: "battery_percentage_remaining", id: 33 },
],
commands: [],
},
3: {
name: "Identify",
attributes: [{ name: "identify_time", id: 0 }],
commands: [
{
name: "identify",
id: 0,
type: "server",
schema: [numberField("identify_time", 65535)],
},
],
},
4: {
name: "Groups",
attributes: [{ name: "name_support", id: 0 }],
commands: [],
},
6: {
name: "OnOff",
attributes: [{ name: "on_off", id: 0 }],
commands: [
{ name: "off", id: 0, type: "server", schema: [] },
{ name: "on", id: 1, type: "server", schema: [] },
{ name: "toggle", id: 2, type: "server", schema: [] },
],
},
8: {
name: "LevelControl",
attributes: [{ name: "current_level", id: 0 }],
commands: [
{
name: "move_to_level",
id: 0,
type: "server",
schema: [
numberField("level", 254),
numberField("transition_time", 65535),
],
},
],
},
25: {
name: "Ota",
attributes: [{ name: "current_file_version", id: 2 }],
commands: [],
},
768: {
name: "ColorControl",
attributes: [
{ name: "current_hue", id: 0 },
{ name: "current_saturation", id: 1 },
{ name: "color_temperature", id: 7 },
],
commands: [
{
name: "move_to_color_temp",
id: 10,
type: "server",
schema: [
numberField("color_temp_mireds", 500),
numberField("transition_time", 65535),
],
},
],
},
1026: {
name: "TemperatureMeasurement",
attributes: [
{ name: "measured_value", id: 0 },
{ name: "min_measured_value", id: 1 },
{ name: "max_measured_value", id: 2 },
],
commands: [],
},
1280: {
name: "IasZone",
attributes: [
{ name: "zone_state", id: 0 },
{ name: "zone_type", id: 1 },
{ name: "zone_status", id: 2 },
],
commands: [],
},
2820: {
name: "ElectricalMeasurement",
attributes: [
{ name: "rms_voltage", id: 1285 },
{ name: "rms_current", id: 1288 },
{ name: "active_power", id: 1291 },
],
commands: [],
},
};
const clusterList = (inIds: number[], outIds: number[] = []): Cluster[] =>
[
...inIds.map((id) => ({ id, type: "in" })),
...outIds.map((id) => ({ id, type: "out" })),
].map(({ id, type }) => ({
name: CLUSTERS[id].name,
id,
endpoint_id: 1,
type,
}));
// A device binds from its client side, and group binding lists only those, so
// the remotes carry the `out` clusters they would have on real hardware and
// the mains-powered devices only their OTA one.
const DEVICE_CLUSTERS: Record<string, Cluster[]> = {
[COORDINATOR_IEEE]: clusterList([0]),
[PORCH_IEEE]: clusterList([0, 3, 4, 6, 8, 768], [25]),
[MOTION_IEEE]: clusterList([0, 1, 3, 1280], [3, 6, 8]),
[PLUG_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
[KITCHEN_IEEE]: clusterList([0, 1, 3], [3, 6, 8]),
[LANDING_IEEE]: clusterList([0, 1, 3, 1026]),
[GARAGE_IEEE]: clusterList([0, 1, 3, 1280]),
[OFFICE_IEEE]: clusterList([0, 3, 4, 6, 2820], [25]),
};
const DEFAULT_ATTRIBUTE_VALUES: Record<string, string> = {
"1:32": "30",
"1:33": "184",
"3:0": "0",
"4:0": "0",
"6:0": "1",
"8:0": "254",
"768:0": "42",
"768:1": "180",
"768:7": "370",
"1026:0": "2140",
"1026:1": "-2000",
"1026:2": "6000",
"1280:0": "1",
"1280:1": "21",
"1280:2": "0",
"2820:1285": "2300",
"2820:1288": "410",
"2820:1291": "94",
};
const writtenAttributes = new Map<string, string>();
const attributeKey = (data: ReadAttributeServiceData) =>
`${data.ieee}:${data.endpoint_id}:${data.cluster_id}:${data.attribute}`;
const attributeValue = (data: ReadAttributeServiceData): string => {
const written = writtenAttributes.get(attributeKey(data));
if (written !== undefined) {
return written;
}
if (data.cluster_id === 0) {
const device = DEVICES.find((candidate) => candidate.ieee === data.ieee);
if (data.attribute === 4) {
return device?.manufacturer ?? "";
}
if (data.attribute === 5) {
return device?.model ?? "";
}
return "3";
}
return (
DEFAULT_ATTRIBUTE_VALUES[`${data.cluster_id}:${data.attribute}`] ?? "0"
);
};
export const mockZha = (hass: MockHomeAssistant) => {
hass.mockWS("zha/devices", () => DEVICES);
hass.mockWS("zha/device", (msg: { ieee: string }) =>
DEVICES.find((device) => device.ieee === msg.ieee)
);
hass.mockWS("zha/groups", () => GROUPS);
hass.mockWS("zha/group", (msg: { group_id: number }) =>
GROUPS.find((group) => group.group_id === msg.group_id)
);
// Copied: both options editors mutate the fetched data as the user changes a
// control, so handing out the backing object would persist edits that were
// never saved. Only the update below writes to it.
hass.mockWS("zha/configuration", () => structuredClone(CONFIGURATION));
hass.mockWS("zha/network/settings", () => NETWORK_SETTINGS);
hass.mockWS("zha/topology/update", () => undefined);
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
DEVICES.filter(
(candidate) =>
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
)
);
hass.mockWS("zha/devices/bind", () => undefined);
hass.mockWS("zha/devices/unbind", () => undefined);
hass.mockWS("zha/groups/bind", () => undefined);
hass.mockWS("zha/groups/unbind", () => undefined);
hass.mockWS(
"zha/devices/clusters",
(msg: { ieee: string }) => DEVICE_CLUSTERS[msg.ieee] ?? []
);
hass.mockWS(
"zha/devices/clusters/attributes",
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.attributes ?? []
);
hass.mockWS(
"zha/devices/clusters/commands",
(msg: { cluster_id: number }) => CLUSTERS[msg.cluster_id]?.commands ?? []
);
hass.mockWS(
"zha/devices/clusters/attributes/value",
(msg: ReadAttributeServiceData) => attributeValue(msg)
);
hass.mockService("zha", "set_zigbee_cluster_attribute", (data) => {
const write = data as ReadAttributeServiceData & { value: unknown };
writtenAttributes.set(attributeKey(write), String(write.value));
return undefined;
});
hass.mockWS("zha/devices/groupable", () => [
member(PORCH_IEEE),
member(OFFICE_IEEE),
member(PLUG_IEEE),
]);
hass.mockWS("zha/network/backups/list", () => BACKUPS);
hass.mockWS("zha/devices/permit", () => () => undefined);
hass.mockWS(
"zha/devices/reconfigure",
(msg: { ieee: string }, _hass, onChange) => {
const deviceClusters = DEVICE_CLUSTERS[msg.ieee] ?? [];
const timers: number[] = [];
let cancelled = false;
const emit = (event: ClusterConfigurationEvent, step: number) => {
timers.push(
window.setTimeout(() => {
if (!cancelled) {
onChange!(event);
}
}, step * 400)
);
};
deviceClusters.forEach((cluster, index) => {
emit(
{
type: "zha_channel_bind",
zha_channel_msg_data: {
cluster_name: cluster.name,
cluster_id: cluster.id,
success: true,
},
},
index + 1
);
const attributes: AttributeConfigurationStatus[] = CLUSTERS[
cluster.id
].attributes.map((attribute) => ({
...attribute,
status: "SUCCESS",
min: 30,
max: 900,
change: 1,
}));
if (attributes.length) {
emit(
{
type: "zha_channel_configure_reporting",
zha_channel_msg_data: {
cluster_name: cluster.name,
cluster_id: cluster.id,
attributes,
},
},
index + 1
);
}
});
emit({ type: "zha_channel_cfg_done" }, deviceClusters.length + 1);
return () => {
cancelled = true;
timers.forEach((timer) => clearTimeout(timer));
};
}
);
hass.mockWS(
"zha/configuration/update",
(msg: { data: ZHAConfiguration["data"] }) => {
Object.entries(msg.data ?? {}).forEach(([section, values]) => {
CONFIGURATION.data[section] = {
...CONFIGURATION.data[section],
...values,
};
});
return undefined;
}
);
hass.mockWS("zha/network/backups/create", () => {
const backup: ZHANetworkBackup = {
backup_time: new Date().toISOString(),
// Copied, or changing the channel afterwards would rewrite the backup
// too, which is the one thing a backup must not do.
network_info: structuredClone(NETWORK_SETTINGS.settings.network_info),
node_info: structuredClone(NETWORK_SETTINGS.settings.node_info),
};
BACKUPS.push(backup);
return { backup, is_complete: true };
});
hass.mockWS(
"zha/network/change_channel",
(msg: { new_channel: "auto" | number }) => {
NETWORK_SETTINGS.settings.network_info.channel =
msg.new_channel === "auto" ? 25 : msg.new_channel;
return undefined;
}
);
hass.mockWS(
"zha/group/add",
(msg: {
group_name: string;
group_id?: number;
members?: ZHAGroupMember[];
}) => {
const group: ZHAGroup = {
name: msg.group_name,
group_id:
msg.group_id ??
GROUPS.reduce(
(highest, item) => Math.max(highest, item.group_id),
0
) + 1,
members: (msg.members ?? []).map((item) => member(item.ieee)),
};
GROUPS.push(group);
return group;
}
);
hass.mockWS("zha/group/remove", (msg: { group_ids: number[] }) => {
msg.group_ids.forEach((groupId) => {
const index = GROUPS.findIndex((group) => group.group_id === groupId);
if (index !== -1) {
GROUPS.splice(index, 1);
}
});
return GROUPS;
});
const findGroup = (groupId: number) => {
const group = GROUPS.find((candidate) => candidate.group_id === groupId);
if (!group) {
throw new Error(`Group ${groupId} not found`);
}
return group;
};
hass.mockWS(
"zha/group/members/add",
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
const group = findGroup(msg.group_id);
const known = new Set(group.members.map((item) => item.device.ieee));
group.members = [
...group.members,
...msg.members
.filter((item) => !known.has(item.ieee))
.map((item) => member(item.ieee)),
];
return group;
}
);
hass.mockWS(
"zha/group/members/remove",
(msg: { group_id: number; members: ZHAGroupMember[] }) => {
const group = findGroup(msg.group_id);
const dropped = new Set(msg.members.map((item) => item.ieee));
group.members = group.members.filter(
(item) => !dropped.has(item.device.ieee)
);
return group;
}
);
};
@@ -0,0 +1,201 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
const ENTRY_ID = "mock-zwave-js";
export const HOME_ID = 3245146787;
// Node IDs are carried in the `zwave_js` device identifiers, which is where the
// panels read them back from.
export const CONTROLLER_NODE_ID = 1;
export const HALLWAY_NODE_ID = 7;
export const DIMMER_NODE_ID = 12;
export const MOTION_NODE_ID = 15;
export const LOCK_NODE_ID = 18;
export const THERMOSTAT_NODE_ID = 20;
export const SENSOR_NODE_ID = 23;
export const OUTLET_NODE_ID = 31;
export const DEVICE_IDS_BY_NODE_ID: Record<number, string> = {
[CONTROLLER_NODE_ID]: "zwave-controller",
[HALLWAY_NODE_ID]: "zwave-hallway-switch",
[DIMMER_NODE_ID]: "zwave-dining-dimmer",
[MOTION_NODE_ID]: "zwave-garage-motion",
[LOCK_NODE_ID]: "zwave-back-door-lock",
[THERMOSTAT_NODE_ID]: "zwave-bedroom-thermostat",
[SENSOR_NODE_ID]: "zwave-basement-sensor",
[OUTLET_NODE_ID]: "zwave-porch-outlet",
};
const identifiers = (nodeId: number): [string, string][] => [
["zwave_js", `${HOME_ID}-${nodeId}`],
];
const DEVICES = [
device(
"zwave-controller",
"Z-Wave stick",
"Zooz",
"800 Series Z-Wave Long Range",
ENTRY_ID,
{ sw_version: "1.10", identifiers: identifiers(CONTROLLER_NODE_ID) }
),
device(
"zwave-dining-dimmer",
"Dining room dimmer",
"Inovelli",
"LZW31-SN",
ENTRY_ID,
{ identifiers: identifiers(DIMMER_NODE_ID) }
),
device("zwave-back-door-lock", "Back door lock", "Yale", "YRD226", ENTRY_ID, {
identifiers: identifiers(LOCK_NODE_ID),
}),
device(
"zwave-basement-sensor",
"Basement sensor",
"Aeotec",
"ZWA005 TriSensor",
ENTRY_ID,
{ identifiers: identifiers(SENSOR_NODE_ID) }
),
device("zwave-hallway-switch", "Hallway switch", "Zooz", "ZEN76", ENTRY_ID, {
area_id: "entrance",
identifiers: identifiers(HALLWAY_NODE_ID),
}),
device(
"zwave-garage-motion",
"Garage motion",
"Aeotec",
"MultiSensor 7",
ENTRY_ID,
{ identifiers: identifiers(MOTION_NODE_ID) }
),
device(
"zwave-bedroom-thermostat",
"Bedroom thermostat",
"Honeywell",
"T6 Pro",
ENTRY_ID,
{ area_id: "bedroom", identifiers: identifiers(THERMOSTAT_NODE_ID) }
),
device("zwave-porch-outlet", "Porch outlet", "Zooz", "ZEN15", ENTRY_ID, {
identifiers: identifiers(OUTLET_NODE_ID),
}),
];
const REGISTRY_ENTRIES = [
registryEntry(
"light.dining_room",
"zwave-dining-dimmer",
ENTRY_ID,
"zwave_js"
),
registryEntry("lock.back_door", "zwave-back-door-lock", ENTRY_ID, "zwave_js"),
registryEntry(
"sensor.basement_humidity",
"zwave-basement-sensor",
ENTRY_ID,
"zwave_js"
),
registryEntry("light.hallway", "zwave-hallway-switch", ENTRY_ID, "zwave_js"),
registryEntry(
"binary_sensor.garage_motion",
"zwave-garage-motion",
ENTRY_ID,
"zwave_js"
),
registryEntry(
"climate.bedroom",
"zwave-bedroom-thermostat",
ENTRY_ID,
"zwave_js"
),
registryEntry(
"switch.porch_outlet",
"zwave-porch-outlet",
ENTRY_ID,
"zwave_js"
),
];
export const zwaveJsFixtures: ConnectivityFixtures = {
components: ["zwave_js"],
commands: ["zwave_js/"],
manifests: [manifest("zwave_js", "Z-Wave", { integration_type: "hub" })],
configEntries: [
{
type: "hub",
entry: configEntry(ENTRY_ID, "zwave_js", "Z-Wave", {
supports_options: true,
supports_remove_device: true,
}),
},
],
devices: DEVICES,
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"light.dining_room": {
entity_id: "light.dining_room",
state: "on",
attributes: {
friendly_name: "Dining room dimmer",
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 128,
},
},
"lock.back_door": {
entity_id: "lock.back_door",
state: "unlocked",
attributes: { friendly_name: "Back door lock" },
},
"sensor.basement_humidity": {
entity_id: "sensor.basement_humidity",
state: "58",
attributes: {
friendly_name: "Basement humidity",
device_class: "humidity",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"light.hallway": {
entity_id: "light.hallway",
state: "off",
attributes: {
friendly_name: "Hallway switch",
supported_color_modes: ["onoff"],
},
},
"binary_sensor.garage_motion": {
entity_id: "binary_sensor.garage_motion",
state: "on",
attributes: { friendly_name: "Garage motion", device_class: "motion" },
},
"climate.bedroom": {
entity_id: "climate.bedroom",
state: "heat",
attributes: {
friendly_name: "Bedroom thermostat",
hvac_modes: ["off", "heat"],
current_temperature: 19.6,
temperature: 20.5,
min_temp: 7,
max_temp: 30,
supported_features: 1,
},
},
"switch.porch_outlet": {
entity_id: "switch.porch_outlet",
state: "off",
attributes: { friendly_name: "Porch outlet" },
},
}),
};
@@ -0,0 +1,229 @@
import type {
ZWaveJSController,
ZWaveJSNetwork,
ZWaveJSNodeStatisticsUpdatedMessage,
ZWaveJSNodeStatus,
ZwaveJSProvisioningEntry,
} from "../../../../../src/data/zwave_js";
import {
NodeStatus,
ProvisioningEntryStatus,
SecurityClass,
} from "../../../../../src/data/zwave_js";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { emitInitial } from "../subscription";
import {
CONTROLLER_NODE_ID,
DEVICE_IDS_BY_NODE_ID,
DIMMER_NODE_ID,
HALLWAY_NODE_ID,
HOME_ID,
LOCK_NODE_ID,
MOTION_NODE_ID,
OUTLET_NODE_ID,
SENSOR_NODE_ID,
THERMOSTAT_NODE_ID,
} from "./fixtures";
const node = (
nodeId: number,
status: NodeStatus,
overrides: Partial<ZWaveJSNodeStatus> = {}
): ZWaveJSNodeStatus => ({
node_id: nodeId,
ready: true,
status,
is_secure: true,
is_routing: true,
zwave_plus_version: 2,
highest_security_class: SecurityClass.S2_Authenticated,
is_controller_node: false,
has_firmware_update_cc: true,
...overrides,
});
const NODES: ZWaveJSNodeStatus[] = [
node(CONTROLLER_NODE_ID, NodeStatus.Alive, {
is_controller_node: true,
highest_security_class: SecurityClass.S2_AccessControl,
}),
node(HALLWAY_NODE_ID, NodeStatus.Alive),
node(DIMMER_NODE_ID, NodeStatus.Alive),
node(MOTION_NODE_ID, NodeStatus.Asleep, { is_routing: false }),
node(LOCK_NODE_ID, NodeStatus.Asleep, {
is_routing: false,
highest_security_class: SecurityClass.S2_AccessControl,
}),
node(THERMOSTAT_NODE_ID, NodeStatus.Alive),
node(SENSOR_NODE_ID, NodeStatus.Dead, { is_routing: false }),
node(OUTLET_NODE_ID, NodeStatus.Alive),
];
const CONTROLLER: ZWaveJSController = {
home_id: HOME_ID,
sdk_version: "7.19.3",
type: 1,
own_node_id: CONTROLLER_NODE_ID,
rf_region: null,
is_primary: true,
is_using_home_id_from_other_network: false,
is_sis_present: true,
was_real_primary: true,
is_suc: true,
// NodeType.Controller; the enum itself is not exported from data/zwave_js.
node_type: 0 as ZWaveJSController["node_type"],
firmware_version: "1.10",
manufacturer_id: 634,
product_id: 4,
product_type: 3,
supported_function_types: [],
suc_node_id: CONTROLLER_NODE_ID,
supports_timers: false,
is_rebuilding_routes: false,
// InclusionState.Idle
inclusion_state: 0,
nodes: NODES,
supports_long_range: true,
};
const NETWORK: ZWaveJSNetwork = {
client: {
state: "connected",
ws_server_url: "ws://localhost:3000",
server_version: "1.40.1",
driver_version: "13.2.0",
},
controller: CONTROLLER,
};
const PROVISIONING_ENTRIES: ZwaveJSProvisioningEntry[] = [
{
dsk: "51590-27189-49239-34778-15304-59293-52843-45852",
securityClasses: [SecurityClass.S2_Authenticated],
status: ProvisioningEntryStatus.Active,
additionalProperties: {},
manufacturer: "Zooz",
label: "ZEN32 Scene Controller",
},
];
// Node IDs each node can reach directly. Only requested when the map's
// neighbor overlay is toggled on.
const NEIGHBORS: Record<number, number[]> = {
[CONTROLLER_NODE_ID]: [HALLWAY_NODE_ID, DIMMER_NODE_ID, OUTLET_NODE_ID],
[HALLWAY_NODE_ID]: [
CONTROLLER_NODE_ID,
DIMMER_NODE_ID,
LOCK_NODE_ID,
THERMOSTAT_NODE_ID,
],
[DIMMER_NODE_ID]: [
CONTROLLER_NODE_ID,
HALLWAY_NODE_ID,
SENSOR_NODE_ID,
OUTLET_NODE_ID,
],
[MOTION_NODE_ID]: [THERMOSTAT_NODE_ID],
[LOCK_NODE_ID]: [HALLWAY_NODE_ID],
[THERMOSTAT_NODE_ID]: [HALLWAY_NODE_ID, MOTION_NODE_ID],
[SENSOR_NODE_ID]: [DIMMER_NODE_ID],
[OUTLET_NODE_ID]: [CONTROLLER_NODE_ID, DIMMER_NODE_ID],
};
// Route each node reports as its last working route back to the controller,
// so the map can draw the mesh instead of a star.
const ROUTES: Record<number, { repeaters: number[]; rssi: number }> = {
[HALLWAY_NODE_ID]: { repeaters: [], rssi: -44 },
[DIMMER_NODE_ID]: { repeaters: [], rssi: -48 },
[OUTLET_NODE_ID]: { repeaters: [], rssi: -57 },
[LOCK_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -72 },
[THERMOSTAT_NODE_ID]: { repeaters: [HALLWAY_NODE_ID], rssi: -66 },
[MOTION_NODE_ID]: {
repeaters: [HALLWAY_NODE_ID, THERMOSTAT_NODE_ID],
rssi: -79,
},
[SENSOR_NODE_ID]: { repeaters: [DIMMER_NODE_ID], rssi: -81 },
};
const NODE_IDS_BY_DEVICE_ID: Record<string, number> = Object.fromEntries(
Object.entries(DEVICE_IDS_BY_NODE_ID).map(([nodeId, deviceId]) => [
deviceId,
Number(nodeId),
])
);
const buildNodeStatistics = (
nodeId: number
): ZWaveJSNodeStatisticsUpdatedMessage => {
const route = ROUTES[nodeId];
return {
event: "statistics updated",
source: "node",
nodeId,
node_id: nodeId,
commands_tx: 1200 + nodeId * 7,
commands_rx: 980 + nodeId * 5,
commands_dropped_tx: 0,
commands_dropped_rx: nodeId === SENSOR_NODE_ID ? 4 : 0,
timeout_response: 0,
rtt: 24 + nodeId,
rssi: route?.rssi ?? null,
lwr: route
? {
protocol_data_rate: 3,
repeaters: route.repeaters.map(
(repeaterNodeId) => DEVICE_IDS_BY_NODE_ID[repeaterNodeId]
),
rssi: route.rssi,
repeater_rssi: route.repeaters.map(() => -55),
route_failed_between: null,
}
: null,
nlwr: null,
};
};
export const mockZwaveJs = (hass: MockHomeAssistant) => {
hass.mockWS("zwave_js/network_status", () => NETWORK);
hass.mockWS("zwave_js/network_neighbors", () => NEIGHBORS);
hass.mockWS("zwave_js/get_provisioning_entries", () => PROVISIONING_ENTRIES);
hass.mockWS("zwave_js/data_collection_status", () => ({
enabled: false,
opted_in: false,
}));
hass.mockWS("zwave_js/subscribe_s2_inclusion", () => () => undefined);
hass.mockWS("zwave_js/node_status", (msg: { device_id: string }) => {
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
return NODES.find((n) => n.node_id === nodeId) ?? NODES[0];
});
hass.mockWS(
"zwave_js/subscribe_node_statistics",
(msg: { device_id: string }, _hass, onChange) => {
const nodeId = NODE_IDS_BY_DEVICE_ID[msg.device_id];
if (nodeId === undefined || nodeId === CONTROLLER_NODE_ID) {
return () => undefined;
}
return emitInitial(() => onChange?.(buildNodeStatistics(nodeId)));
}
);
hass.mockWS(
"zwave_js/subscribe_controller_statistics",
(_msg, _hass, onChange) =>
emitInitial(() =>
onChange?.({
event: "statistics updated",
source: "controller",
messages_tx: 18234,
messages_rx: 17980,
messages_dropped_tx: 2,
messages_dropped_rx: 5,
nak: 0,
can: 3,
timeout_ack: 1,
timeout_response: 0,
timeout_callback: 0,
})
)
);
};
+2
View File
@@ -1,4 +1,5 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
const baseDevice = {
config_entries_subentries: {},
@@ -86,4 +87,5 @@ export const demoDevices: DeviceRegistryEntry[] = [
entry_type: null,
parent_device_id: "power-strip",
},
...connectivityDevices,
];
+11 -16
View File
@@ -1,19 +1,7 @@
import type { IntegrationManifest } from "../../../src/data/integration";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const manifest = (
domain: string,
name: string,
overrides: Partial<IntegrationManifest> = {}
): IntegrationManifest => ({
is_built_in: true,
domain,
name,
config_flow: true,
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
iot_class: "local_push",
...overrides,
});
import { connectivityManifests } from "./connectivity/fixtures";
import { manifest } from "./manifest";
const manifests: IntegrationManifest[] = [
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
@@ -62,11 +50,18 @@ const manifests: IntegrationManifest[] = [
integration_type: "helper",
iot_class: "local_polling",
}),
...connectivityManifests,
];
export const mockIntegration = (hass: MockHomeAssistant) => {
hass.mockWS("manifest/list", () => manifests);
hass.mockWS("manifest/get", (msg: { integration: string }) =>
manifests.find((m) => m.domain === msg.integration)
// Never answer with undefined: the integration page reads the manifest it
// gets back without guarding, so an unlisted domain would throw. The real
// backend always has a manifest for a domain that has config entries.
hass.mockWS(
"manifest/get",
(msg: { integration: string }) =>
manifests.find((m) => m.domain === msg.integration) ??
manifest(msg.integration, msg.integration)
);
};
+20
View File
@@ -0,0 +1,20 @@
import type { IntegrationManifest } from "../../../src/data/integration";
/**
* Builds a demo integration manifest. Lives in its own module so both the
* manifest registry and the per-integration fixtures that feed it can use it
* without importing each other.
*/
export const manifest = (
domain: string,
name: string,
overrides: Partial<IntegrationManifest> = {}
): IntegrationManifest => ({
is_built_in: true,
domain,
name,
config_flow: true,
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
iot_class: "local_push",
...overrides,
});
+54 -2
View File
@@ -1,6 +1,58 @@
import type { Tag } from "../../../src/data/tag";
import type { Tag, UpdateTagParams } from "../../../src/data/tag";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockTags = (hass: MockHomeAssistant) => {
hass.mockWS("tag/list", () => [{ id: "my-tag", name: "My Tag" }] as Tag[]);
const tags: Tag[] = [{ id: "my-tag", name: "My Tag" }];
let created = 0;
const find = (tagId: string) => tags.find((tag) => tag.id === tagId);
hass.mockWS("tag/list", () => tags.map((tag) => ({ ...tag })));
hass.mockWS(
"tag/create",
(msg: UpdateTagParams & { tag_id?: string }): Tag => {
if (msg.tag_id && find(msg.tag_id)) {
throw new Error(`Tag ${msg.tag_id} already exists`);
}
let id = msg.tag_id;
while (!id) {
created += 1;
id = find(`tag-${created}`) ? undefined : `tag-${created}`;
}
const tag: Tag = {
id,
name: msg.name,
description: msg.description,
};
tags.push(tag);
return { ...tag };
}
);
hass.mockWS(
"tag/update",
(msg: UpdateTagParams & { tag_id: string }): Tag => {
const tag = find(msg.tag_id);
if (!tag) {
throw new Error(`Tag ${msg.tag_id} not found`);
}
if ("name" in msg) {
tag.name = msg.name;
}
if ("description" in msg) {
tag.description = msg.description;
}
return { ...tag };
}
);
hass.mockWS("tag/delete", (msg: { tag_id: string }) => {
const index = tags.findIndex((tag) => tag.id === msg.tag_id);
if (index === -1) {
throw new Error(`Tag ${msg.tag_id} not found`);
}
tags.splice(index, 1);
return undefined;
});
};
+23 -4
View File
@@ -1,7 +1,26 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { connectivityBackendTranslations } from "./connectivity/fixtures";
export const mockTranslations = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_translations", (
/* msg: {language: string, category: string} */
) => ({ resources: {} }));
export const mockTranslations = (
hass: MockHomeAssistant,
localizePromise?: Promise<LocalizeFunc>
) => {
hass.mockWS(
"frontend/get_translations",
(msg: { language: string; category?: string }) => ({
resources:
(msg.category && connectivityBackendTranslations[msg.category]) || {},
})
);
// `hass.loadBackendTranslation` is a no-op in the mocked hass, so categories
// that are only requested through it never reach the WebSocket mock above.
// Seed every category into the resources, after the fragment translations so
// this merges on top of them.
(localizePromise ?? Promise.resolve()).then(() =>
hass.addTranslations(
Object.assign({}, ...Object.values(connectivityBackendTranslations))
)
);
};
+1 -3
View File
@@ -693,9 +693,7 @@ class HaGallery extends LitElement {
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
user-select: initial;
--ha-sidebar-width: 300px;
--ha-sidebar-expanded-width: 300px;
--ha-sidebar-expanded-item-width: 292px;
+19
View File
@@ -16,6 +16,25 @@ const ENTITIES = [
duration: "0:05:00",
},
},
{
entity_id: "timer.active_timer",
state: "active",
attributes: {
friendly_name: "Active timer",
duration: "0:10:00",
remaining: "0:10:00",
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
},
},
{
entity_id: "timer.paused_timer",
state: "paused",
attributes: {
friendly_name: "Paused timer",
duration: "0:10:00",
remaining: "0:03:21",
},
},
];
@customElement("demo-more-info-timer")
+19 -21
View File
@@ -47,12 +47,12 @@
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.9",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.2",
"@codemirror/view": "6.43.11",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.0",
"@formatjs/intl-datetimeformat": "7.6.1",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
@@ -85,12 +85,11 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.3.2",
"@tsparticles/preset-links": "4.3.2",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
@@ -101,16 +100,15 @@
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.1",
"hls.js": "1.7.2",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
"js-yaml": "5.3.0",
"js-yaml": "5.4.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -118,7 +116,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.10",
"marked": "18.0.11",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -150,13 +148,13 @@
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.65.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.4",
"@lokalise/node-api": "16.4.1",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.1.10",
"@rspack/core": "2.2.2",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -180,7 +178,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.9.0",
"eslint": "10.9.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -190,9 +188,9 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"generate-license-file": "4.2.5",
"glob": "13.0.6",
"globals": "17.11.0",
"globals": "17.12.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -202,12 +200,12 @@
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.3.0",
"lint-staged": "17.4.1",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.1.0",
"minify-literals": "2.2.0",
"pinst": "3.0.0",
"prettier": "3.9.6",
"rspack-manifest-plugin": "5.2.2",
@@ -217,7 +215,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"typescript-eslint": "8.69.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
@@ -235,6 +233,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.19.0"
"node": "24.20.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.4"
version = "20260826.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+7 -3
View File
@@ -62,12 +62,16 @@ export function computeCssColor(color: string): string {
/**
* Get a color from document's styles
* @param color - Named theme color (examples: `red`, `primary-text`)
* @returns Resolved color; initial color if not found in document's styles
* @param style - Styles to resolve against, defaults to the document body
* @returns Resolved color; initial color if not found in the styles
*/
export function resolveThemeColor(color: string): string {
export function resolveThemeColor(
color: string,
style?: CSSStyleDeclaration
): string {
const cssColor = computeCssVariableName(color);
if (cssColor.startsWith("--")) {
const resolved = getComputedStyle(document.body)
const resolved = (style ?? getComputedStyle(document.body))
.getPropertyValue(cssColor)
.trim();
return resolved || color;
+22 -5
View File
@@ -1,4 +1,4 @@
import { wcagLuminance, wcagContrast } from "culori";
import { parse, wcagLuminance, wcagContrast } from "culori";
import { theme2hex } from "./convert-color";
/**
@@ -51,11 +51,28 @@ export const getRGBContrastRatio = (
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
/**
* Returns a contrasted color (black or white) based on the luminance of another color
* Tells whether a color can be measured, which a CSS function that is passed
* through unevaluated cannot, and whether it covers what is behind it
* @param color - Color (HEX, rgb/rgba, named color) to check
* @returns Whether a contrast against this color says anything
*/
export const isOpaqueColor = (color: string): boolean => {
const parsed = parse(color.trim());
return parsed !== undefined && (parsed.alpha ?? 1) === 1;
};
/**
* Returns a contrasted color (black or white) for another color
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
* @returns HEX color, whichever of black and white has the higher contrast ratio
*/
export const getContrastedColorHex = (color: string): string => {
const lum = wcagLuminance(theme2hex(color));
return lum > 0.5 ? "#000000" : "#ffffff";
const hex = theme2hex(color.trim());
// culori throws on a color it cannot read
if (!parse(hex)) {
return "#ffffff";
}
return wcagContrast(hex, "#000000") >= wcagContrast(hex, "#ffffff")
? "#000000"
: "#ffffff";
};
@@ -33,7 +33,6 @@ export const filterPanelStyles = css`
--ha-card-border-radius: var(--ha-border-radius-square);
}
ha-expansion-panel::part(summary) {
-webkit-user-select: none;
user-select: none;
}
.content {
@@ -0,0 +1,152 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HaSlider } from "../../components/ha-slider";
import type { MediaPlayerEntity } from "../../data/media-player";
import { formatMediaTime, getCurrentProgress } from "../../data/media-player";
const PENDING_SEEK_TIMEOUT_MS = 5000;
const PENDING_SEEK_TOLERANCE_S = 2;
const PROGRESS_INTERVAL_MS = 1000;
export interface MediaProgressControllerOptions {
getStateObj: () => MediaPlayerEntity | undefined;
getSlider: () => HaSlider | undefined;
}
/**
* Drives a media progress slider: ticks it while the media plays, holds it
* on the target after a seek until the player state catches up, and leaves
* it alone while the user drags it. The controller owns the slider value;
* the host must not bind `.value` and reads `progress` for position text.
*/
export class MediaProgressController implements ReactiveController {
/** Current position in media seconds, pending seek included. */
public progress?: number;
private _host: ReactiveControllerHost;
private _options: MediaProgressControllerOptions;
private _interval?: number;
private _pendingPosition?: number;
private _pendingSince = 0;
constructor(
host: ReactiveControllerHost,
options: MediaProgressControllerOptions
) {
this._host = host;
this._options = options;
host.addController(this);
}
public hostUpdate(): void {
this._computeProgress();
}
public hostUpdated(): void {
this._writeSlider();
this._syncInterval();
}
public hostDisconnected(): void {
this._stopInterval();
}
/**
* Report a seek so the displayed position moves to the target immediately
* instead of jumping back until the player state reflects the seek.
*/
public seek(position: number): void {
this._pendingPosition = position;
this._pendingSince = Date.now();
this._tick();
}
private _tick(): void {
this._computeProgress();
this._writeSlider();
this._host.requestUpdate();
}
private _syncInterval(): void {
const stateObj = this._options.getStateObj();
if (
stateObj?.state === "playing" &&
stateObj.attributes.media_duration &&
stateObj.attributes.media_position !== undefined
) {
if (!this._interval) {
this._interval = window.setInterval(
() => this._tick(),
PROGRESS_INTERVAL_MS
);
}
} else {
this._stopInterval();
}
}
private _stopInterval(): void {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
private _computeProgress(): void {
const stateObj = this._options.getStateObj();
if (
!stateObj ||
!stateObj.attributes.media_duration ||
stateObj.attributes.media_position === undefined
) {
this.progress = undefined;
return;
}
this.progress = this._applyPendingSeek(
getCurrentProgress(stateObj),
stateObj.state === "playing",
stateObj.attributes.media_duration
);
}
private _applyPendingSeek(
current: number,
playing: boolean,
duration: number
): number {
if (this._pendingPosition === undefined) {
return current;
}
const elapsedMs = Date.now() - this._pendingSince;
const target = Math.min(
this._pendingPosition + (playing ? elapsedMs / 1000 : 0),
duration
);
if (
elapsedMs > PENDING_SEEK_TIMEOUT_MS ||
Math.abs(current - target) <= PENDING_SEEK_TOLERANCE_S
) {
this._pendingPosition = undefined;
return current;
}
return target;
}
private _writeSlider(): void {
const slider = this._options.getSlider();
if (!slider) {
return;
}
slider.valueFormatter = formatMediaTime;
if (slider.matches(":state(dragging)")) {
return;
}
slider.value = this.progress ?? 0;
}
}
@@ -0,0 +1,70 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { HassEntity } from "home-assistant-js-websocket";
import { timerTimeRemaining } from "../../data/timer";
/**
* Tracks the live remaining time of a timer entity. While the timer is
* active, the host is re-rendered every second with an updated
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
*
* The host must call `setStateObj` whenever its timer entity changes.
*/
export class TimerRemainingTimeController implements ReactiveController {
public timeRemaining?: number;
private _host: ReactiveControllerHost;
private _stateObj?: HassEntity;
private _interval?: number;
constructor(host: ReactiveControllerHost) {
this._host = host;
host.addController(this);
}
public setStateObj(stateObj: HassEntity | undefined): void {
this._stateObj = stateObj;
this._startInterval();
}
public hostConnected(): void {
this._startInterval();
}
public hostDisconnected(): void {
this._clearInterval();
}
private _startInterval(): void {
this._clearInterval();
if (!this._stateObj) {
this.timeRemaining = undefined;
return;
}
this._calculateRemaining();
if (this._stateObj.state === "active") {
this._interval = window.setInterval(() => {
this._calculateRemaining();
this._host.requestUpdate();
}, 1000);
}
}
private _clearInterval(): void {
if (this._interval) {
clearInterval(this._interval);
this._interval = undefined;
}
}
private _calculateRemaining(): void {
this.timeRemaining = this._stateObj
? timerTimeRemaining(this._stateObj)
: undefined;
}
}
+5 -10
View File
@@ -145,16 +145,11 @@ export const applyThemesOnElement = (
element.__themes = { cacheKey, keys: newTheme?.keys };
// Set and/or reset styles
if (window.ShadyCSS) {
// Use ShadyCSS if available
window.ShadyCSS.styleSubtree(/** @type {!HTMLElement} */ element, styles);
} else {
for (const s in styles) {
if (s === null) {
element.style.removeProperty(s);
} else {
element.style.setProperty(s, styles[s]);
}
for (const s in styles) {
if (s === null) {
element.style.removeProperty(s);
} else {
element.style.setProperty(s, styles[s]);
}
}
};
-25
View File
@@ -1,25 +0,0 @@
// Toggle Attribute Polyfill because it's too new for some browsers
export const toggleAttribute = (
el: HTMLElement,
name: string,
force?: boolean
) => {
if (force !== undefined) {
force = !!force;
}
if (el.hasAttribute(name)) {
if (force) {
return true;
}
el.removeAttribute(name);
return false;
}
if (force === false) {
return false;
}
el.setAttribute(name, "");
return true;
};
+1 -1
View File
@@ -29,7 +29,7 @@ export const computeEntityEntryName = (
fallbackStateObj?: HassEntity
): string | undefined => {
const name =
entry.name ||
entry.name ??
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
@@ -1 +0,0 @@
export const webComponentsSupported = "attachShadow" in Element.prototype;
-50
View File
@@ -1,50 +0,0 @@
/**
* ES5-compatible implementation of the keyed directive.
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
*
* This implementation avoids parameter destructuring in the update() method,
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
*
* Used only for ES5 builds (legacy browsers). Modern builds use the original
* lit-html keyed directive.
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
import { directive, Directive } from "lit-html/directive.js";
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {
private _key: unknown = nothing;
render(k: unknown, v: unknown) {
this._key = k;
return v;
}
update(part: unknown, args: [unknown, unknown]) {
const k = args[0];
const v = args[1];
if (k !== this._key) {
// Clear the part before returning a value. The one-arg form of
// setCommittedValue sets the value to a sentinel which forces a
// commit the next render.
setCommittedValue(part as Part);
this._key = k;
}
return v;
}
}
/**
* Associates a renderable value with a unique key. When the key changes, the
* previous DOM is removed and disposed before rendering the next value, even
* if the value - such as a template - is the same.
*
* This is useful for forcing re-renders of stateful components, or working
* with code that expects new data to generate new HTML elements, such as some
* animation techniques.
*/
export const keyed = directive(KeyedES5);
+191 -2
View File
@@ -42,6 +42,7 @@ export const updateHistoryState = (patch: Record<string, unknown>) => {
*/
export const replaceCurrentUrl = (url: string) => {
mainWindow.history.replaceState(mainWindow.history.state, "", url);
rememberCurrentEntry();
};
/**
@@ -80,6 +81,105 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
return ensureDialogsClosed(timestamp);
};
/**
* Lets a page with unsaved changes (e.g. the automation editor) veto
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
* true when navigation may proceed.
*/
export interface UnsavedChangesGuard {
isDirty(): boolean;
prompt(): Promise<boolean>;
}
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.add(guard);
};
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.delete(guard);
};
const dirtyGuards = (): UnsavedChangesGuard[] =>
[...unsavedChangesGuards].filter((guard) => guard.isDirty());
let pendingUnsavedPrompt: Promise<boolean> | undefined;
/**
* Counts navigations that changed the history entry, so a navigation held up
* by an unsaved-changes prompt can tell whether a newer one has moved the app
* on in the meantime.
*/
let committedNavigations = 0;
interface HistoryEntry {
path: string;
/** Path of the entry behind this one, stamped by `performNavigation`. */
from?: string;
}
const readEntry = (): HistoryEntry => ({
path: currentPath(),
from: mainWindow.history.state?.from,
});
/**
* How far a pop moved from `entry`, signed, or undefined when it cannot be told:
* the entry behind us is the one our `from` names, the one ahead is the one
* whose `from` names us. A stack with the same path on both sides matches both,
* and back is then by far the likelier press.
*/
const popStep = (entry: HistoryEntry): number | undefined => {
if (currentPath() === entry.from) {
return -1;
}
return mainWindow.history.state?.from === entry.path ? 1 : undefined;
};
let currentEntry: HistoryEntry = readEntry();
const rememberCurrentEntry = () => {
currentEntry = readEntry();
};
/** Where the pops held while a prompt is open left us. */
let heldEntry: HistoryEntry | undefined;
let heldSteps = 0;
/** The entry `goBack()` asked to leave; the pop it triggers is not prompted. */
let popRequestedFromPath: string | undefined;
/**
* Asks each dirty guard whether navigation may proceed. Returns true when
* nothing is dirty or every prompt was confirmed. Concurrent navigations
* share one pending prompt instead of stacking dialogs; the dirty check runs
* before joining it, so a navigation triggered from inside a prompt (e.g. by
* its save action) cannot deadlock on its own promise.
*/
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
const guards = dirtyGuards();
if (!guards.length) {
return Promise.resolve(true);
}
if (!pendingUnsavedPrompt) {
pendingUnsavedPrompt = (async () => {
try {
for (const guard of guards) {
// eslint-disable-next-line no-await-in-loop
if (!(await guard.prompt())) {
return false;
}
}
return true;
} finally {
pendingUnsavedPrompt = undefined;
}
})();
}
return pendingUnsavedPrompt;
};
const buildHistoryState = (
data: Record<string, unknown> | undefined,
from?: string
@@ -91,7 +191,7 @@ const buildHistoryState = (
return { ...state, from };
};
export const navigate = async (path: string, options?: NavigateOptions) => {
const performNavigation = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
@@ -124,12 +224,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
);
}
rememberCurrentEntry();
fireEvent(mainWindow, "location-changed", {
replace,
});
committedNavigations += 1;
return true;
};
export const navigate = async (path: string, options?: NavigateOptions) => {
// Only guard actual departures: navigating to the current path keeps the
// page, and any unsaved state on it, mounted.
if (path !== currentPath()) {
const navigationsBeforePrompt = committedNavigations;
if (!(await ensureUnsavedChangesConfirmed())) {
return false;
}
if (committedNavigations !== navigationsBeforePrompt) {
// Another navigation landed while the prompt was waiting for an answer,
// so this destination is stale. Dropping it keeps a late answer from
// pulling the user back off the page they are on now.
return false;
}
}
return performNavigation(path, options);
};
/**
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
@@ -142,6 +262,9 @@ export const canGoBack = (): boolean =>
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
* Deliberately not guarded against unsaved changes: pages with such a guard
* confirm in their own back handlers, and delete flows leave through here
* after the edited item is already gone.
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -152,9 +275,75 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
// Read after closing dialogs: their history entries are popped by then, so
// this is the state of the page entry.
if (canGoBack()) {
popRequestedFromPath = currentEntry.path;
mainWindow.history.back();
return;
}
await navigate(fallbackPath || "/", { replace: true });
await performNavigation(fallbackPath || "/", { replace: true });
};
/**
* Handles a history pop before the router acts on it. A pop cannot be cancelled,
* so one away from a page with unsaved changes holds the route and prompts;
* `resume` runs once the user agrees to leave. The prompt must add no history
* entry of its own, or the entries the pop left would be truncated.
*/
export const handleHistoryPop = (resume: () => void): void => {
if (heldEntry) {
const heldStep = popStep(heldEntry);
if (heldStep !== undefined) {
heldSteps += heldStep;
heldEntry = readEntry();
}
return;
}
if (currentPath() === currentEntry.path) {
// A dialog's history entry was popped, not a page.
rememberCurrentEntry();
resume();
return;
}
const step = popStep(currentEntry);
if (
popRequestedFromPath === currentEntry.path ||
// Not a pop we could undo, so do not hold it.
step === undefined ||
!dirtyGuards().length
) {
popRequestedFromPath = undefined;
rememberCurrentEntry();
committedNavigations += 1;
resume();
return;
}
heldSteps = step;
heldEntry = readEntry();
const navigationsAtPop = committedNavigations;
ensureUnsavedChangesConfirmed().then(
(confirmed) => {
const steps = heldSteps;
heldEntry = undefined;
if (committedNavigations !== navigationsAtPop) {
// A navigation landed while the prompt was open.
return;
}
if (!confirmed) {
// go(0) would reload the document, and at zero we are already back.
if (steps) {
mainWindow.history.go(-steps);
}
return;
}
rememberCurrentEntry();
committedNavigations += 1;
resume();
},
() => {
heldEntry = undefined;
}
);
};
-19
View File
@@ -1,19 +0,0 @@
export const startMediaProgressInterval = (
interval: number | undefined,
callback: () => void,
intervalMs = 1000
): number => {
if (interval) {
return interval;
}
return window.setInterval(callback, intervalMs);
};
export const stopMediaProgressInterval = (
interval: number | undefined
): number | undefined => {
if (interval) {
clearInterval(interval);
}
return undefined;
};
@@ -0,0 +1,37 @@
import deepFreeze from "deep-freeze";
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
export const shareInFlightRequest = <T>(
owner: object,
key: string,
fetcher: () => Promise<T>
): Promise<T> => {
let requests = inFlightRequests.get(owner);
if (!requests) {
requests = new Map();
inFlightRequests.set(owner, requests);
}
const ownerRequests = requests;
const existing = ownerRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const request = fetcher()
.then((result) => deepFreeze(result) as T)
.finally(() => {
if (ownerRequests.get(key) !== request) {
return;
}
ownerRequests.delete(key);
if (ownerRequests.size === 0) {
inFlightRequests.delete(owner);
}
});
ownerRequests.set(key, request);
return request;
};
@@ -328,6 +328,7 @@ export class StateHistoryChartLine extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
onFractionDigits: (digits) => {
if (digits !== this._yAxisFractionDigits) {
this._yAxisFractionDigits = digits;
+1
View File
@@ -446,6 +446,7 @@ export class StatisticsChart extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
// Bar charts stay anchored at 0, so precision must reflect the
// 0-based range that is actually rendered.
includeZero: !yAxisScale,
+121 -26
View File
@@ -1,13 +1,32 @@
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
import { getPrecision, nice, round } from "echarts/lib/util/number";
// A range smaller than this fraction of the axis magnitude is floating-point
// noise (e.g. from summed statistics), not real precision.
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
// precision derived here cannot drift from the ticks it renders.
const SPLIT_NUMBER = 5;
// How thin a gap between the data and the plot edge counts as no gap at all,
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
// maximum to a tick multiple, which usually leaves headroom, but quantized
// states often land exactly on a tick and get none — collapsing area-filled
// series, which are drawn from their value down to the axis minimum. Widening
// the extent by this much before that rounding bumps any axis with less
// headroom out to a full tick, and leaves the rest where they are.
const GAP_FRACTION_OF_SPAN = 0.02;
// A percentage has a real ceiling the way zero is a real floor, so the gap must
// not push the axis past it. Not every `%` sensor is bounded — power factor is
// signed and can read over 100 — so this only applies while the data stays under.
const PERCENT_MAX = 100;
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
// observed data range, by asking ECharts for the same tick interval it will
// render. This matches the precision it actually draws, so labels are neither
// truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(
min: number,
max: number,
@@ -22,13 +41,7 @@ export function computeYAxisFractionDigits(
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
// Rounding the mantissa to a nice value only ever carries to the next power
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
return Math.max(0, -niceExponent);
return getPrecision(nice(range / SPLIT_NUMBER, true));
}
interface YAxisExtentValues {
@@ -44,34 +57,116 @@ const resolveYAxisBound = (
values: YAxisExtentValues
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
// always before the label formatter runs, so recomputing the fraction digits
// here keeps zoomed-in labels distinct. The callbacks return the original
// bounds unchanged, so auto-scaling still applies when a bound is not set.
// A constant series has no span for the gap to be a fraction of, so ECharts
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
// expansion a flat series relies on for its window. Run that expansion here and
// return fixed bounds, which also suppresses the gap.
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
const precision = getPrecision(interval);
return {
min: round(Math.floor(lo / interval) * interval, precision),
max: round(Math.ceil(hi / interval) * interval, precision),
};
};
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
// callbacks with the extent of the visible (zoom-filtered) data on every
// dataZoom, and always before the label formatter runs, so the fraction digits
// recomputed here track the zoomed range. A callback returns `undefined`
// wherever auto-scaling should stand, and a number only where the axis has to be
// pinned: an explicit bound, the zero anchor, or a constant series.
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
// Such an axis also gets no gap: pushing it below zero would defeat the zero
// anchoring and leave the bars floating above the axis.
includeZero?: boolean;
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
// a limit the data itself never crosses.
unit?: string;
onFractionDigits: (digits: number) => void;
}): {
min: (values: YAxisExtentValues) => number | undefined;
max: (values: YAxisExtentValues) => number | undefined;
boundaryGap: [number, number];
splitNumber: number;
} {
const { min, max, includeZero, onFractionDigits } = options;
const { min, max, includeZero, unit, onFractionDigits } = options;
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
const resolveBounds = (values: YAxisExtentValues) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
if (
includeZero ||
!Number.isFinite(values.min) ||
!Number.isFinite(values.max)
) {
return { min: resolvedMin, max: resolvedMax, gap: 0 };
}
if (values.min === values.max) {
const flat = flatSeriesExtent(values.min, [
resolvedMin !== undefined,
resolvedMax !== undefined,
]);
// The expansion is a fraction of the magnitude, so a constant series near
// the ceiling would otherwise overshoot it too.
const flatMax =
naturalMax !== undefined && values.max <= naturalMax
? Math.min(flat.max, naturalMax)
: flat.max;
return {
min: resolvedMin ?? flat.min,
max: resolvedMax ?? flatMax,
gap: 0,
};
}
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
// Never let the gap carry a series past a boundary it does not itself cross.
const floor = values.min >= 0 ? 0 : undefined;
const ceiling =
naturalMax !== undefined && values.max <= naturalMax
? naturalMax
: values.max <= 0
? 0
: undefined;
return {
min:
resolvedMin ??
(floor !== undefined && values.min - floor < gap ? floor : undefined),
max:
resolvedMax ??
(ceiling !== undefined && ceiling - values.max < gap
? ceiling
: undefined),
gap,
};
};
return {
// Always emit the key. `setOption` merges the Y axis rather than replacing
// it, so a conditionally spread gap would survive a chart switching to a
// zero-anchored type and leave its bars floating.
boundaryGap: includeZero
? [0, 0]
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
splitNumber: SPLIT_NUMBER,
min: (values) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
const extentMin = resolvedMin ?? values.min;
const extentMax = resolvedMax ?? values.max;
const bounds = resolveBounds(values);
onFractionDigits(
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
computeYAxisFractionDigits(
bounds.min ?? values.min - bounds.gap,
bounds.max ?? values.max + bounds.gap,
includeZero
)
);
return resolvedMin;
return bounds.min;
},
max: (values) => resolveYAxisBound(max, values),
max: (values) => resolveBounds(values).max,
};
}
+6 -36
View File
@@ -204,45 +204,17 @@ export class HaDataTable extends LitElement {
this._checkedRowsChanged();
}
public selectAll(): void {
public selectAll(extraFilter?: (row: DataTableRowData) => boolean): void {
this._checkedRows = (this._filteredData || [])
.filter((data) => data.selectable !== false)
.filter(
(data) =>
data.selectable !== false && (!extraFilter || extraFilter(data))
)
.map((data) => data[this.id]);
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
public select(ids: string[], clear?: boolean): void {
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 = rowLookup.get(id);
if (row?.selectable !== false && !checkedRows.has(id)) {
this._checkedRows.push(id);
checkedRows.add(id);
}
});
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
public unselect(ids: string[]): void {
ids.forEach((id) => {
const index = this._checkedRows.indexOf(id);
if (index > -1) {
this._checkedRows.splice(index, 1);
}
});
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
public connectedCallback() {
super.connectedCallback();
if (this._filteredData?.length) {
@@ -1235,10 +1207,8 @@ export class HaDataTable extends LitElement {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.mdc-data-table__header-row {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
scrollbar-width: none;
}
.mdc-data-table__cell,
+1 -1
View File
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
getStatisticIds,
getStatisticLabel,
type StatisticsMetaData,
} from "../../data/recorder";
import { getStatisticIds } from "../../data/recorder_statistic_ids";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item";
-5
View File
@@ -293,12 +293,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
);
overflow: hidden;
color: var(--primary-text-color);
-webkit-backdrop-filter: var(
--ha-dialog-surface-backdrop-filter,
none
);
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
-webkit-user-select: text;
user-select: text;
}
+1 -1
View File
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
static styles = css`
.issue-type {
position: relative;
padding: 8px;
padding: var(--ha-alert-padding, 8px);
display: flex;
}
.icon {
-13
View File
@@ -1061,31 +1061,18 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2s infinite ease-in-out;
animation: sk-bounce 2s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1s;
animation-delay: -1s;
}
@-webkit-keyframes sk-bounce {
0%,
100% {
-webkit-transform: scale(0);
}
50% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bounce {
0%,
100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
-1
View File
@@ -70,7 +70,6 @@ export class HaBadge extends LitElement {
--ha-card-background,
var(--card-background-color, white)
);
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
backdrop-filter: var(--ha-card-backdrop-filter, none);
border-width: var(--ha-card-border-width, 1px);
box-shadow: var(--ha-card-box-shadow, none);
-11
View File
@@ -432,13 +432,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
}
}
wa-drawer::part(dialog)::backdrop {
-webkit-backdrop-filter: var(
--ha-bottom-sheet-scrim-backdrop-filter,
var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
)
);
backdrop-filter: var(
--ha-bottom-sheet-scrim-backdrop-filter,
var(
@@ -470,10 +463,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
var(--card-background-color, var(--ha-color-surface-default))
)
);
-webkit-backdrop-filter: var(
--ha-bottom-sheet-surface-backdrop-filter,
var(--ha-dialog-surface-backdrop-filter, none)
);
backdrop-filter: var(
--ha-bottom-sheet-surface-backdrop-filter,
var(--ha-dialog-surface-backdrop-filter, none)
-1
View File
@@ -13,7 +13,6 @@ export class HaCard extends LitElement {
--ha-card-background,
var(--card-background-color, white)
);
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
backdrop-filter: var(--ha-card-backdrop-filter, none);
box-shadow: var(--ha-card-box-shadow, none);
box-sizing: border-box;
@@ -1,12 +1,12 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-nav";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
import "./ha-icon-next";
import "./ha-svg-icon";
import "./item/ha-list-item-button";
import "./list/ha-list-nav";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../types";
@customElement("ha-config-navigation-list")
class HaConfigNavigationList extends LitElement {
+12 -5
View File
@@ -56,7 +56,7 @@ export class HaControlSelect extends LitElement {
this.updateComplete.then(() => {
// eslint-disable-next-line lit/prefer-query-decorators
const option = this.shadowRoot?.querySelector(
`#option-${this.options![index].value}`
`[data-index="${index}"]`
) as HTMLElement;
option?.focus();
});
@@ -144,7 +144,11 @@ export class HaControlSelect extends LitElement {
this.options,
(option) => option.value,
(option, index) =>
this._renderOption(option, index === this._tabbableIndex)
this._renderOption(
option,
index,
index === this._tabbableIndex
)
)
: nothing
}
@@ -159,12 +163,17 @@ export class HaControlSelect extends LitElement {
return selectedIndex === -1 ? 0 : selectedIndex;
}
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
private _renderOption(
option: ControlSelectOption,
index: number,
tabbable: boolean
) {
const isSelected = this.value === option.value;
return html`
<div
id=${`option-${option.value}`}
data-index=${index}
class=${classMap({
option: true,
selected: isSelected,
@@ -323,8 +332,6 @@ export class HaControlSelect extends LitElement {
.option .content span {
display: block;
width: 100%;
-webkit-hyphens: auto;
-moz-hyphens: auto;
hyphens: auto;
}
:host([vertical]) {
-8
View File
@@ -393,10 +393,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
}
wa-dialog::part(dialog) {
-webkit-backdrop-filter: var(
--ha-dialog-surface-backdrop-filter,
none
);
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
box-shadow: var(--dialog-box-shadow, var(--wa-shadow-l));
color: var(--primary-text-color);
@@ -429,10 +425,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
}
wa-dialog::part(dialog)::backdrop {
-webkit-backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
+4
View File
@@ -95,6 +95,10 @@ export class HaDrawer extends LitElement {
}
private _handleAfterHide(ev: Event) {
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
if (ev.target !== ev.currentTarget) {
return;
}
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
-5
View File
@@ -63,11 +63,6 @@ class HaFaded extends LitElement {
}
.faded {
cursor: pointer;
-webkit-mask-image: linear-gradient(
to bottom,
black 25%,
transparent 100%
);
mask-image: linear-gradient(to bottom, black 25%, transparent 100%);
overflow-y: hidden;
}
@@ -1,6 +1,13 @@
import type { Selector } from "../../data/selector";
import {
getSelectorInitialValue,
getSelectorInitialValueOrUndefined,
} from "./get-selector-initial-value";
import type { HaFormData, HaFormSchema } from "./types";
interface ComputeInitialHaFormDataOptions {
skipUnsupportedSelectors?: boolean;
}
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
@@ -18,7 +25,8 @@ const setDefaultValue = (
};
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[]
schema: HaFormSchema[] | readonly HaFormSchema[],
options?: ComputeInitialHaFormDataOptions
): Record<string, any> => {
const data = {};
schema.forEach((field) => {
@@ -33,7 +41,7 @@ export const computeInitialHaFormData = (
} else if ("default" in field) {
data[field.name] = setDefaultValue(field, field.default);
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema);
const expandableData = computeInitialHaFormData(field.schema, options);
if (field.required || Object.keys(expandableData).length) {
// Only add expandable data if it's required or any of its children have initial values.
data[field.name] = expandableData;
@@ -62,104 +70,11 @@ export const computeInitialHaFormData = (
seconds: 0,
};
} else if ("selector" in field) {
const selector: Selector = field.selector;
if ("device" in selector) {
data[field.name] = selector.device?.multiple ? [] : "";
} else if ("entity" in selector) {
data[field.name] = selector.entity?.multiple ? [] : "";
} else if ("area" in selector) {
data[field.name] = selector.area?.multiple ? [] : "";
} else if ("label" in selector) {
data[field.name] = selector.label?.multiple ? [] : "";
} else if ("boolean" in selector) {
data[field.name] = false;
} else if (
"addon" in selector ||
"attribute" in selector ||
"file" in selector ||
"icon" in selector ||
"serial_port" in selector ||
"template" in selector ||
"text" in selector ||
"theme" in selector ||
"object" in selector
) {
data[field.name] = "";
} else if ("number" in selector) {
data[field.name] = selector.number?.min ?? 0;
} else if ("select" in selector) {
if (selector.select?.options.length) {
const firstOption = selector.select.options[0];
const val =
typeof firstOption === "string" ? firstOption : firstOption.value;
data[field.name] = selector.select.multiple ? [val] : val;
}
} else if ("country" in selector) {
if (selector.country?.countries?.length) {
data[field.name] = selector.country.countries[0];
}
} else if ("language" in selector) {
if (selector.language?.languages?.length) {
data[field.name] = selector.language.languages[0];
}
} else if ("duration" in selector) {
data[field.name] = {
hours: 0,
minutes: 0,
seconds: 0,
};
} else if ("time" in selector) {
data[field.name] = "00:00:00";
} else if ("date" in selector || "datetime" in selector) {
const now = new Date().toISOString().slice(0, 10);
data[field.name] = `${now}T00:00:00`;
} else if ("color_rgb" in selector) {
data[field.name] = [0, 0, 0];
} else if ("color_temp" in selector) {
data[field.name] = selector.color_temp?.min_mireds ?? 153;
} else if (
"action" in selector ||
"trigger" in selector ||
"condition" in selector
) {
data[field.name] = [];
} else if ("media" in selector || "target" in selector) {
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
);
const initialValue = options?.skipUnsupportedSelectors
? getSelectorInitialValueOrUndefined(field.selector)
: getSelectorInitialValue(field.selector);
if (initialValue !== undefined) {
data[field.name] = initialValue;
}
}
});
@@ -1,25 +1,90 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type { Selector } from "../../data/selector";
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorFallbackValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_FALLBACK_VALUES = {
action: undefined,
addon: undefined,
automation_behavior: undefined,
app: undefined,
area: undefined,
areas_display: undefined,
attribute: undefined,
assist_pipeline: undefined,
boolean: () => false,
choose: undefined,
color_rgb: undefined,
condition: undefined,
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: undefined,
date: undefined,
datetime: undefined,
device: undefined,
device_class: undefined,
duration: undefined,
entity: undefined,
entity_name: undefined,
statistic: undefined,
file: undefined,
floor: undefined,
label: undefined,
language: undefined,
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
selector: undefined,
serial_port: undefined,
state: undefined,
state_class: undefined,
backup_location: undefined,
stt: undefined,
target: undefined,
template: undefined,
text: undefined,
time: undefined,
icon: undefined,
media: undefined,
theme: undefined,
timezone: undefined,
button_toggle: undefined,
trigger: undefined,
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
},
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorFallbackValues;
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
const type = Object.keys(selector)[0] as SelectorType;
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
return fallbackValue?.(selector as never);
};
@@ -0,0 +1,141 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorInitialValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_INITIAL_VALUES = {
action: () => [],
addon: () => "",
automation_behavior: undefined,
app: undefined,
area: (selector) => (selector.area?.multiple ? [] : ""),
areas_display: undefined,
attribute: () => "",
assist_pipeline: undefined,
boolean: () => false,
choose: (selector) => {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
return {};
}
const childValue = getSelectorInitialValueOrUndefined(
selector.choose.choices[firstChoice].selector
);
return childValue === undefined
? { active_choice: firstChoice }
: { active_choice: firstChoice, [firstChoice]: childValue };
},
color_rgb: () => [0, 0, 0],
condition: () => [],
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: (selector) => selector.country?.countries?.[0],
date: () => new Date().toISOString().slice(0, 10),
datetime: () => `${new Date().toISOString().slice(0, 10)} 00:00:00`,
device: (selector) => (selector.device?.multiple ? [] : ""),
device_class: (selector) =>
selector.device_class?.multiple ? [] : undefined,
duration: () => ({
hours: 0,
minutes: 0,
seconds: 0,
}),
entity: (selector) => (selector.entity?.multiple ? [] : ""),
entity_name: undefined,
statistic: undefined,
file: () => "",
floor: undefined,
label: (selector) => (selector.label?.multiple ? [] : ""),
language: (selector) => selector.language?.languages?.[0],
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: (selector) => {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
return type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
},
object: (selector) => (selector.object?.multiple ? [] : ""),
period: undefined,
qr_code: undefined,
select: (selector) => {
const select = selector.select;
if (!select?.options.length) {
return undefined;
}
const firstOption = select.options[0];
const value =
typeof firstOption === "string" ? firstOption : firstOption.value;
return select.multiple ? [value] : value;
},
selector: undefined,
serial_port: () => "",
state: (selector) => (selector.state?.multiple ? [] : ""),
state_class: (selector) => (selector.state_class?.multiple ? [] : undefined),
backup_location: undefined,
stt: undefined,
target: () => ({}),
template: () => "",
text: (selector) => (selector.text?.multiple ? [] : ""),
time: () => "00:00:00",
icon: () => "",
media: (selector) => (selector.media?.multiple ? [] : {}),
theme: () => "",
timezone: undefined,
button_toggle: undefined,
trigger: () => [],
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
},
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorInitialValues;
export const getSelectorInitialValueOrUndefined = (
selector: Selector
): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
};
export const getSelectorInitialValue = (selector: Selector): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
const initialValue = SELECTOR_INITIAL_VALUES[type];
if (!initialValue) {
throw new Error(`Selector ${type} not supported in initial form data`);
}
return initialValue(selector as never);
};
+26 -6
View File
@@ -1,4 +1,5 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
@@ -12,6 +13,7 @@ import {
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { configContext } from "../data/context";
@@ -120,6 +122,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@query("wa-popover") private _popover?: WaPopover;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@@ -130,6 +134,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@state() private _popoverWidth = 0;
@state() private _popoverMinHeight = 0;
@state() private _openedNarrow = false;
@state() private _unknownValue = false;
@@ -240,9 +246,12 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
style=${styleMap({
"--body-width": `${this._popoverWidth}px`,
"--body-min-height": `${this._popoverMinHeight}px`,
})}
without-arrow
distance="-4"
distance="0"
.placement=${this.popoverPlacement}
.for=${this.popoverAnchor ? null : "picker"}
.anchor=${this.popoverAnchor ?? null}
@@ -265,12 +274,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
private _renderComboBox(dialogMode = false) {
if (!this._opened) {
// The list sizes the popover, so it is rendered as it opens, not once shown.
if (!this._pickerWrapperOpen && !this._opened) {
return nothing;
}
return html`
<ha-picker-combo-box
id="combo-box"
.shown=${this._opened}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel}
.value=${this._selectedValue ?? this.value}
@@ -339,6 +350,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _dialogOpened = () => {
this._opened = true;
// Filtering must not shrink the popover under the size it opened with.
this._popoverMinHeight = this._popover?.body.offsetHeight ?? 0;
fireEvent(this, "picker-opened");
requestAnimationFrame(() => {
// Set initial field value if needed
@@ -372,6 +385,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._opened = false;
this._pickerWrapperOpen = false;
this._popoverMinHeight = 0;
this._selectedValue = undefined;
this._unsubscribeTinyKeys?.();
fireEvent(this, "picker-closed");
@@ -474,6 +488,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
wa-popover {
--wa-space-l: 0;
/* The surface of a dropdown menu, not of a dialog. */
--wa-panel-border-radius: var(--ha-border-radius-md);
}
wa-popover::part(dialog)::backdrop {
@@ -486,14 +502,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
--ha-generic-picker-max-width,
var(--ha-generic-picker-width, max(var(--body-width), 250px))
);
max-height: 500px;
height: 70vh;
display: flex;
flex-direction: column;
box-shadow: var(--ha-box-shadow-m);
height: fit-content;
min-height: var(--body-min-height, 0);
max-height: min(70vh, 500px);
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
max-height: min(70vh, 400px);
}
}
+1
View File
@@ -31,6 +31,7 @@ export class HaHeaderBar extends LitElement {
--app-header-background-color,
var(--primary-background-color)
);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding: var(--header-bar-padding);
}
+1 -3
View File
@@ -53,9 +53,7 @@ export class HaMarkdown extends LitElement {
display: block;
}
ha-markdown-element {
-ms-user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
}
ha-markdown-element > *:first-child {
margin-top: 0;
+445
View File
@@ -0,0 +1,445 @@
import { mdiDelete, mdiPlayBox, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../common/dom/fire_event";
import { supportsFeature } from "../common/entity/supports-feature";
import type { MediaPickedEvent } from "../data/media-player";
import {
MediaClassBrowserSettings,
MediaPlayerEntityFeature,
} from "../data/media-player";
import type { MediaSelector, MediaSelectorValue } from "../data/selector";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-form/ha-form";
import type { SchemaUnion } from "./ha-form/types";
import "./media-player/ha-media-browser-thumbnail";
import { showMediaBrowserDialog } from "./media-player/show-media-browser-dialog";
import { ensureArray } from "../common/array/ensure-array";
import "./ha-picture-upload";
const MANUAL_SCHEMA = [
{ name: "media_content_id", required: false, selector: { text: {} } },
{ name: "media_content_type", required: false, selector: { text: {} } },
] as const;
const INCLUDE_DOMAINS = ["media_player"];
const EMPTY_FORM = {};
@customElement("ha-media-item-picker")
export class HaMediaItemPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: MediaSelector;
@property({ attribute: false }) public value?: MediaSelectorValue;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean, reflect: true }) public required = true;
@property({ type: Boolean }) public multiple = false;
@property({ attribute: false }) public context?: {
filter_entity?: string | string[];
};
private _contextEntities: string[] | undefined;
private get _hasAccept(): boolean {
return !!this.selector?.media?.accept?.length;
}
willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("context")) {
if (!this._hasAccept) {
this._contextEntities = ensureArray(this.context?.filter_entity);
}
}
}
protected render() {
const entityId = this._getActiveEntityId();
const stateObj = entityId ? this.hass.states[entityId] : undefined;
const supportsBrowse =
!entityId ||
(stateObj &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
if (this.selector.media?.image_upload && !this.value) {
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-picture-upload
.hass=${this.hass}
.value=${null}
.contentIdHelper=${this.selector.media?.content_id_helper}
select-media
full-media
@media-picked=${this._pictureUploadMediaPicked}
></ha-picture-upload>`;
}
const hideEntityPicker =
this._hasAccept ||
(this._contextEntities && this._contextEntities.length <= 1);
return html`
${
hideEntityPicker
? nothing
: html`
<ha-entity-picker
.value=${entityId}
.label=${
this.label ||
this.hass.localize(
"ui.components.selectors.media.pick_media_player"
)
}
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
.hideClearIcon=${!!this._contextEntities}
.includeDomains=${INCLUDE_DOMAINS}
.includeEntities=${this._contextEntities}
.allowCustomEntity=${!this._contextEntities}
@value-changed=${this._entityChanged}
></ha-entity-picker>
`
}
${
!supportsBrowse
? html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-alert>
${this.hass.localize(
"ui.components.selectors.media.browse_not_supported"
)}
</ha-alert>
<ha-form
.hass=${this.hass}
.data=${this.value || EMPTY_FORM}
.schema=${MANUAL_SCHEMA}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
></ha-form>
`
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-card
outlined
tabindex="0"
role="button"
aria-label=${
!this.value?.media_content_id
? this.hass.localize(
"ui.components.selectors.media.pick_media"
)
: this.value.metadata?.title || this.value.media_content_id
}
@click=${this._pickMedia}
@keydown=${this._handleKeyDown}
class=${
this.disabled || (!entityId && !this._hasAccept)
? "disabled"
: ""
}
>
<div class="content-container">
<div class="thumbnail">
${
this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
>
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${this.value.metadata.thumbnail}
></ha-media-browser-thumbnail>
</div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${
!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class ===
"directory"
? this.value.metadata
.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox
}
></ha-svg-icon>
</div>
`
}
</div>
<div class="title">
${
!this.value?.media_content_id
? this.hass.localize(
"ui.components.selectors.media.pick_media"
)
: this.value.metadata?.title ||
this.value.media_content_id
}
</div>
${
!this.multiple || !this.value
? nothing
: html`
<div class="delete">
<ha-icon-button
.label=${this.hass.localize("ui.common.delete")}
.path=${mdiDelete}
@click=${this._clearValue}
></ha-icon-button>
</div>
`
}
</div>
</ha-card>
${
this.selector.media?.image_upload
? html`<div>
<ha-button
appearance="plain"
size="s"
variant="danger"
@click=${this._clearValue}
>
${this.hass.localize(
"ui.components.picture-upload.clear_picture"
)}
</ha-button>
</div>`
: nothing
}`
}
${
hideEntityPicker && this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing
}
`;
}
private _computeLabelCallback = (
schema: SchemaUnion<typeof MANUAL_SCHEMA>
): string =>
this.hass.localize(`ui.components.selectors.media.${schema.name}`);
private _computeHelperCallback = (
schema: SchemaUnion<typeof MANUAL_SCHEMA>
): string =>
this.hass.localize(`ui.components.selectors.media.${schema.name}_detail`);
private _entityChanged(ev: CustomEvent) {
ev.stopPropagation();
if (!this._hasAccept && this.context?.filter_entity) {
fireEvent(this, "value-changed", {
value: {
media_content_id: "",
media_content_type: "",
metadata: {
browse_entity_id: ev.detail.value,
},
},
});
} else {
fireEvent(this, "value-changed", {
value: {
entity_id: ev.detail.value,
media_content_id: "",
media_content_type: "",
},
});
}
}
private _pickMedia() {
showMediaBrowserDialog(this, {
action: "pick",
entityId: this._getActiveEntityId(),
navigateIds: this.value?.metadata?.navigateIds,
accept: this.selector.media?.accept,
defaultId: this.value?.media_content_id,
defaultType: this.value?.media_content_type,
hideContentType: this.selector.media?.hide_content_type,
contentIdHelper: this.selector.media?.content_id_helper,
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
fireEvent(this, "value-changed", {
value: {
...this.value,
media_content_id: pickedMedia.item.media_content_id,
media_content_type: pickedMedia.item.media_content_type,
metadata: {
title: pickedMedia.item.title,
thumbnail: pickedMedia.item.thumbnail,
media_class: pickedMedia.item.media_class,
children_media_class: pickedMedia.item.children_media_class,
navigateIds: pickedMedia.navigateIds?.map((id) => ({
media_content_type: id.media_content_type,
media_content_id: id.media_content_id,
})),
...(!this._hasAccept && this.context?.filter_entity
? { browse_entity_id: this._getActiveEntityId() }
: {}),
},
},
});
},
});
}
private _getActiveEntityId(): string | undefined {
const metaId = this.value?.metadata?.browse_entity_id;
return (
this.value?.entity_id ||
(metaId && this._contextEntities?.includes(metaId) && metaId) ||
this._contextEntities?.[0]
);
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._pickMedia();
}
}
private _pictureUploadMediaPicked(ev) {
const pickedMedia = ev.detail as MediaPickedEvent;
fireEvent(this, "value-changed", {
value: {
...this.value,
media_content_id: pickedMedia.item.media_content_id,
media_content_type: pickedMedia.item.media_content_type,
metadata: {
title: pickedMedia.item.title,
thumbnail: pickedMedia.item.thumbnail,
media_class: pickedMedia.item.media_class,
children_media_class: pickedMedia.item.children_media_class,
navigateIds: pickedMedia.navigateIds?.map((id) => ({
media_content_type: id.media_content_type,
media_content_id: id.media_content_id,
})),
},
},
});
}
private _clearValue(ev) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: undefined });
}
static styles = css`
ha-entity-picker {
display: block;
margin-bottom: 16px;
}
ha-alert {
display: block;
margin-bottom: 16px;
}
ha-card {
position: relative;
width: 100%;
box-sizing: border-box;
cursor: pointer;
transition: background-color 180ms ease-in-out;
min-height: 56px;
margin-bottom: var(--ha-space-1);
}
ha-card:hover:not(.disabled),
ha-card:focus:not(.disabled) {
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
}
ha-card:focus {
outline: none;
}
ha-card.disabled {
pointer-events: none;
color: var(--disabled-text-color);
}
.content-container {
display: flex;
align-items: center;
padding: 8px;
gap: var(--ha-space-3);
}
ha-card .thumbnail {
width: 40px;
height: 40px;
flex-shrink: 0;
position: relative;
box-sizing: border-box;
border-radius: var(--ha-border-radius-md);
overflow: hidden;
}
ha-card .image {
border-radius: var(--ha-border-radius-md);
}
.folder {
--mdc-icon-size: 24px;
}
.title {
font-size: var(--ha-font-size-m);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
flex: 1;
min-width: 0;
}
.image {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
--ha-media-browser-thumbnail-fit: cover;
}
.centered-image {
margin: 4px;
--ha-media-browser-thumbnail-fit: contain;
}
.icon-holder {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-item-picker": HaMediaItemPicker;
}
}
+151 -68
View File
@@ -13,6 +13,7 @@ import {
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { repeat } from "lit/directives/repeat";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
@@ -74,6 +75,10 @@ type PickerComboBoxRowElement = HTMLDivElement & {
value: string;
};
// Under this count the list is rendered without the virtualizer, so it can size the
// popover to its content instead of filling a fixed height.
const MAX_PLAIN_LIST_ITEMS = 12;
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const PADDING_ID = "___padding___";
@@ -153,6 +158,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
/**
* Whether the surface holding the list is done animating in. Defaults to
* true so direct embedders render immediately; ha-generic-picker sets it
* once its popover has opened, so the virtualizer never measures rows
* through the opening animation's scale.
*/
@property({ type: Boolean }) public shown = true;
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
@property({ attribute: false }) public sections?: (
| {
@@ -178,6 +191,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query(".plain-list") private _plainListElement?: HTMLElement;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@state()
@@ -186,6 +201,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _items: PickerComboBoxItem[] = [];
@state() private _plainList = false;
@state() private _selectedSection?: string;
public setFieldValue(value: string) {
@@ -195,7 +212,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
protected get scrollableElement(): HTMLElement | null {
return this.virtualizerElement as HTMLElement | null;
return this._listElement ?? null;
}
private get _listElement(): HTMLElement | undefined {
return this._plainList ? this._plainListElement : this.virtualizerElement;
}
@state() private _sectionTitle?: string;
@@ -221,10 +242,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
public willUpdate() {
if (!this.hasUpdated) {
loadVirtualizer();
this._selectedSection = this.selectedSection;
this._allItems = this._getItems();
this._items = this._allItems;
this._updateListMode();
}
}
@@ -238,6 +259,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (!this._search || this.sections?.length) {
this._items = this._allItems;
}
this._updateListMode();
}
// Filtering keeps the mode it opened with, only the full list decides it.
private _updateListMode() {
this._plainList =
!this.sections?.length && this._allItems.length <= MAX_PLAIN_LIST_ITEMS;
if (!this._plainList) {
loadVirtualizer();
}
}
protected render() {
@@ -271,36 +302,61 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
`
: nothing
}
<div class="virtualizer-wrapper">
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
.layout=${
this.value && this._valuePinned
? {
pin: {
index: this._getInitialSelectedIndex(),
block: "center",
},
}
: undefined
}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
<div class="list-wrapper ${this._plainList ? "" : "virtualized"}">
${this._plainList ? this._renderPlainList() : this._renderVirtualList()}
${this.renderScrollableFades()}
</div>`;
}
private _renderPlainList() {
return html`
<div
class="plain-list ${this._listScrolled ? "scrolled" : ""}"
tabindex="0"
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
>
${repeat(this._items, this._keyFunction, this._renderItem)}
</div>
`;
}
private _renderVirtualList() {
// The virtualizer measures its rows, so it must not do it through the scale
// the surface animates in with.
if (!this.shown) {
return nothing;
}
return html`
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
.layout=${
this.value && this._valuePinned
? {
pin: {
index: this._getInitialSelectedIndex(),
block: "center",
},
}
: undefined
}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
`;
}
private _renderSectionButtons() {
if (!this.sections || this.sections.length === 0) {
return nothing;
@@ -564,7 +620,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._items = this._getItems();
// Reset scroll position when filter changes
this.virtualizerElement?.element(0)?.scrollIntoView();
this._resetListScroll();
}
private _registerKeyboardShortcuts() {
@@ -578,6 +634,26 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
private _resetListScroll() {
if (this._plainList) {
this._listElement?.scrollTo({ top: 0 });
return;
}
this.virtualizerElement?.element(0)?.scrollIntoView();
}
private _scrollRowIntoView(index: number) {
if (this._plainList) {
this._listElement
?.querySelector(`#list-item-${index}`)
?.scrollIntoView({ block: "nearest" });
return;
}
this.virtualizerElement?.element(index)?.scrollIntoView({
block: "nearest",
});
}
private _focusList() {
if (this._selectedItemIndex === -1) {
this._initializeSelectedIndex();
@@ -589,7 +665,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
* or fall back to the first item when searching (skipping section titles).
*/
private _initializeSelectedIndex(): void {
if (!this.virtualizerElement?.items?.length) {
if (!this._items.length) {
return;
}
const initialIndex = this._getInitialSelectedIndex();
@@ -599,11 +675,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
let index = initialIndex;
// Skip section titles (strings)
if (typeof this.virtualizerElement.items[index] === "string") {
if (typeof this._items[index] === "string") {
index += 1;
}
// Bounds check: ensure index is valid after skipping section title
if (index >= this.virtualizerElement.items.length) {
if (index >= this._items.length) {
return;
}
this._selectedItemIndex = index;
@@ -613,13 +689,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectNextItem = (ev?: KeyboardEvent) => {
ev?.stopPropagation();
ev?.preventDefault();
if (!this.virtualizerElement) {
if (!this._listElement) {
return;
}
this._searchFieldElement?.focus();
const items = this.virtualizerElement.items as PickerComboBoxItem[];
const items = this._items;
const maxItems = items.length - 1;
@@ -661,14 +737,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectPreviousItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (!this.virtualizerElement) {
if (!this._listElement) {
return;
}
if (this._selectedItemIndex > 0) {
const nextIndex = this._selectedItemIndex - 1;
const items = this.virtualizerElement.items as PickerComboBoxItem[];
const items = this._items;
if (!items[nextIndex]) {
return;
@@ -690,13 +766,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectFirstItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
if (!this._listElement || !this._items.length) {
return;
}
const nextIndex = 0;
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
if (typeof this._items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -707,13 +783,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectLastItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
if (!this._listElement || !this._items.length) {
return;
}
const nextIndex = this.virtualizerElement.items.length - 1;
const nextIndex = this._items.length - 1;
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
if (typeof this._items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -723,16 +799,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
};
private _scrollToSelectedItem = () => {
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._listElement?.querySelector(".selected")?.classList.remove("selected");
this.virtualizerElement
?.element(this._selectedItemIndex)
?.scrollIntoView({ block: "nearest" });
this._scrollRowIntoView(this._selectedItemIndex);
requestAnimationFrame(() => {
this.virtualizerElement
this._listElement
?.querySelector(`#list-item-${this._selectedItemIndex}`)
?.classList.add("selected");
});
@@ -749,14 +821,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _pickItem = (ev: KeyboardEvent, newTab: boolean) => {
ev.stopPropagation();
if (
this.virtualizerElement?.items?.length !== undefined &&
this.virtualizerElement.items.length < 4 && // it still can have a section title and a padding item
this.virtualizerElement.items.filter((item) => typeof item !== "string")
.length === 1
this._items.length < 4 && // it still can have a section title and a padding item
this._items.filter((item) => typeof item !== "string").length === 1
) {
(
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
).forEach((item, index) => {
this._items.forEach((item, index) => {
if (typeof item !== "string" && !item.disabled) {
this._fireSelectedEvents(item.id, index, newTab);
}
@@ -774,18 +842,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
// if filter button is focused
ev.preventDefault();
const item = this.virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
const item = this._items[this._selectedItemIndex];
if (item && !item.disabled) {
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
}
};
private _resetSelectedItem() {
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._listElement?.querySelector(".selected")?.classList.remove("selected");
this._selectedItemIndex = -1;
}
@@ -793,11 +857,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
typeof item === "string" ? item : item?.id;
private _getInitialSelectedIndex() {
if (!this.virtualizerElement || this._search || !this.value) {
if (this._search || !this.value) {
return 0;
}
const index = this.virtualizerElement.items.findIndex(
const index = this._items.findIndex(
(item) =>
typeof item !== "string" &&
(item as PickerComboBoxItem).id === this.value
@@ -820,6 +884,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
flex-direction: column;
padding-top: var(--ha-space-4);
flex: 1;
min-height: 0;
}
:host([clearable]) {
@@ -851,23 +916,41 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
}
.virtualizer-wrapper {
.list-wrapper {
position: relative;
flex: 1;
flex: 0 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
lit-virtualizer {
/* The virtualizer is size contained, so it fills a height rather than
providing one. Asking for the whole viewport leaves the popover to cap it. */
.list-wrapper.virtualized {
flex: 1 1 100vh;
}
/* A sheet has its own height, so the list fills it instead of sizing it. */
:host([mode="dialog"]) .list-wrapper {
flex: 1;
}
lit-virtualizer:focus-visible {
lit-virtualizer,
.plain-list {
flex: 1;
min-height: 0;
}
.plain-list {
overflow: auto;
}
lit-virtualizer:focus-visible,
.plain-list:focus-visible {
outline: none;
}
lit-virtualizer.scrolled {
.scrolled {
border-top: 1px solid var(--ha-color-border-neutral-quiet);
}
+72 -378
View File
@@ -1,33 +1,10 @@
import { mdiPlayBox, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event";
import { supportsFeature } from "../../common/entity/supports-feature";
import type { MediaPickedEvent } from "../../data/media-player";
import {
MediaClassBrowserSettings,
MediaPlayerEntityFeature,
} from "../../data/media-player";
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-form/ha-form";
import type { SchemaUnion } from "../ha-form/types";
import "../media-player/ha-media-browser-thumbnail";
import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog";
import "../ha-media-item-picker";
import { ensureArray } from "../../common/array/ensure-array";
import "../ha-picture-upload";
const MANUAL_SCHEMA = [
{ name: "media_content_id", required: false, selector: { text: {} } },
{ name: "media_content_type", required: false, selector: { text: {} } },
] as const;
const INCLUDE_DOMAINS = ["media_player"];
const EMPTY_FORM = {};
import { fireEvent } from "../../common/dom/fire_event";
@customElement("ha-selector-media")
export class HaMediaSelector extends LitElement {
@@ -35,7 +12,8 @@ export class HaMediaSelector extends LitElement {
@property({ attribute: false }) public selector!: MediaSelector;
@property({ attribute: false }) public value?: MediaSelectorValue;
@property({ attribute: false }) public value?:
MediaSelectorValue | MediaSelectorValue[];
@property() public label?: string;
@@ -49,367 +27,83 @@ export class HaMediaSelector extends LitElement {
filter_entity?: string | string[];
};
private _contextEntities: string[] | undefined;
private get _hasAccept(): boolean {
return !!this.selector?.media?.accept?.length;
private _renderPicker(
helper?: string,
value?: MediaSelectorValue,
index?: number,
changeCallback?
): TemplateResult {
return html`<ha-media-item-picker
.hass=${this.hass}
.context=${this.context}
.selector=${this.selector}
.value=${value}
.label=${index == null ? this.label : undefined}
.helper=${helper}
.disabled=${this.disabled}
.required=${this.required}
.multiple=${index != null}
@value-changed=${changeCallback}
.idx=${index}
></ha-media-item-picker>`;
}
willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("context")) {
if (!this._hasAccept) {
this._contextEntities = ensureArray(this.context?.filter_entity);
private _multiValueChanged = (event: CustomEvent) => {
event.stopPropagation();
const target = event.currentTarget as any;
if (!target) return;
const idx = target.idx;
let newValue: MediaSelectorValue[] | undefined;
if (idx === undefined) return;
if (!this.value || !Array.isArray(this.value)) {
newValue = event.detail.value ? [event.detail.value] : undefined;
} else {
newValue = [...this.value];
if (event.detail.value) {
if (idx > this.value.length) {
newValue.push(event.detail.value);
} else {
newValue[idx] = event.detail.value;
}
} else {
newValue.splice(idx, 1);
if (newValue.length === 0) {
newValue = undefined;
}
}
}
}
fireEvent(this, "value-changed", { value: newValue });
};
protected render() {
const entityId = this._getActiveEntityId();
const stateObj = entityId ? this.hass.states[entityId] : undefined;
const supportsBrowse =
!entityId ||
(stateObj &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
if (this.selector.media?.image_upload && !this.value) {
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-picture-upload
.hass=${this.hass}
.value=${null}
.contentIdHelper=${this.selector.media?.content_id_helper}
select-media
full-media
@media-picked=${this._pictureUploadMediaPicked}
></ha-picture-upload>`;
if (this.selector.media?.multiple) {
const value = ensureArray(this.value);
const result: TemplateResult[] = this.label
? [html`<label>${this.label}</label>`]
: [];
if (value) {
result.push(
...value.map((v, i) =>
this._renderPicker(undefined, v, i, this._multiValueChanged)
)
);
}
result.push(
this._renderPicker(
this.helper,
undefined,
value?.length || 0,
this._multiValueChanged
)
);
return result;
}
return html`
${
this._hasAccept ||
(this._contextEntities && this._contextEntities.length <= 1)
? nothing
: html`
<ha-entity-picker
.value=${entityId}
.label=${
this.label ||
this.hass.localize(
"ui.components.selectors.media.pick_media_player"
)
}
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
.hideClearIcon=${!!this._contextEntities}
.includeDomains=${INCLUDE_DOMAINS}
.includeEntities=${this._contextEntities}
.allowCustomEntity=${!this._contextEntities}
@value-changed=${this._entityChanged}
></ha-entity-picker>
`
}
${
!supportsBrowse
? html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-alert>
${this.hass.localize(
"ui.components.selectors.media.browse_not_supported"
)}
</ha-alert>
<ha-form
.hass=${this.hass}
.data=${this.value || EMPTY_FORM}
.schema=${MANUAL_SCHEMA}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
></ha-form>
`
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-card
outlined
tabindex="0"
role="button"
aria-label=${
!this.value?.media_content_id
? this.hass.localize(
"ui.components.selectors.media.pick_media"
)
: this.value.metadata?.title || this.value.media_content_id
}
@click=${this._pickMedia}
@keydown=${this._handleKeyDown}
class=${
this.disabled || (!entityId && !this._hasAccept)
? "disabled"
: ""
}
>
<div class="content-container">
<div class="thumbnail">
${
this.value?.metadata?.thumbnail
? html`
<div
class="${classMap({
"centered-image":
!!this.value.metadata.media_class &&
["app", "directory"].includes(
this.value.metadata.media_class
),
})}
image"
>
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${this.value.metadata.thumbnail}
></ha-media-browser-thumbnail>
</div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${
!this.value?.media_content_id
? mdiPlus
: this.value?.metadata?.media_class
? MediaClassBrowserSettings[
this.value.metadata.media_class ===
"directory"
? this.value.metadata
.children_media_class ||
this.value.metadata.media_class
: this.value.metadata.media_class
].icon
: mdiPlayBox
}
></ha-svg-icon>
</div>
`
}
</div>
<div class="title">
${
!this.value?.media_content_id
? this.hass.localize(
"ui.components.selectors.media.pick_media"
)
: this.value.metadata?.title ||
this.value.media_content_id
}
</div>
</div>
</ha-card>
${
this.selector.media?.clearable
? html`<div>
<ha-button
appearance="plain"
size="s"
variant="danger"
@click=${this._clearValue}
>
${this.hass.localize(
"ui.components.picture-upload.clear_picture"
)}
</ha-button>
</div>`
: nothing
}`
}
`;
}
private _computeLabelCallback = (
schema: SchemaUnion<typeof MANUAL_SCHEMA>
): string =>
this.hass.localize(`ui.components.selectors.media.${schema.name}`);
private _computeHelperCallback = (
schema: SchemaUnion<typeof MANUAL_SCHEMA>
): string =>
this.hass.localize(`ui.components.selectors.media.${schema.name}_detail`);
private _entityChanged(ev: CustomEvent) {
ev.stopPropagation();
if (!this._hasAccept && this.context?.filter_entity) {
fireEvent(this, "value-changed", {
value: {
media_content_id: "",
media_content_type: "",
metadata: {
browse_entity_id: ev.detail.value,
},
},
});
} else {
fireEvent(this, "value-changed", {
value: {
entity_id: ev.detail.value,
media_content_id: "",
media_content_type: "",
},
});
}
}
private _pickMedia() {
showMediaBrowserDialog(this, {
action: "pick",
entityId: this._getActiveEntityId(),
navigateIds: this.value?.metadata?.navigateIds,
accept: this.selector.media?.accept,
defaultId: this.value?.media_content_id,
defaultType: this.value?.media_content_type,
hideContentType: this.selector.media?.hide_content_type,
contentIdHelper: this.selector.media?.content_id_helper,
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
fireEvent(this, "value-changed", {
value: {
...this.value,
media_content_id: pickedMedia.item.media_content_id,
media_content_type: pickedMedia.item.media_content_type,
metadata: {
title: pickedMedia.item.title,
thumbnail: pickedMedia.item.thumbnail,
media_class: pickedMedia.item.media_class,
children_media_class: pickedMedia.item.children_media_class,
navigateIds: pickedMedia.navigateIds?.map((id) => ({
media_content_type: id.media_content_type,
media_content_id: id.media_content_id,
})),
...(!this._hasAccept && this.context?.filter_entity
? { browse_entity_id: this._getActiveEntityId() }
: {}),
},
},
});
},
});
}
private _getActiveEntityId(): string | undefined {
const metaId = this.value?.metadata?.browse_entity_id;
return (
this.value?.entity_id ||
(metaId && this._contextEntities?.includes(metaId) && metaId) ||
this._contextEntities?.[0]
return this._renderPicker(
this.helper,
Array.isArray(this.value) ? this.value[0] : this.value
);
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._pickMedia();
}
}
private _pictureUploadMediaPicked(ev) {
const pickedMedia = ev.detail as MediaPickedEvent;
fireEvent(this, "value-changed", {
value: {
...this.value,
media_content_id: pickedMedia.item.media_content_id,
media_content_type: pickedMedia.item.media_content_type,
metadata: {
title: pickedMedia.item.title,
thumbnail: pickedMedia.item.thumbnail,
media_class: pickedMedia.item.media_class,
children_media_class: pickedMedia.item.children_media_class,
navigateIds: pickedMedia.navigateIds?.map((id) => ({
media_content_type: id.media_content_type,
media_content_id: id.media_content_id,
})),
},
},
});
}
private _clearValue() {
fireEvent(this, "value-changed", { value: undefined });
}
static styles = css`
ha-entity-picker {
display: block;
margin-bottom: 16px;
}
ha-alert {
display: block;
margin-bottom: 16px;
}
ha-card {
position: relative;
width: 100%;
box-sizing: border-box;
cursor: pointer;
transition: background-color 180ms ease-in-out;
min-height: 56px;
}
ha-card:hover:not(.disabled),
ha-card:focus:not(.disabled) {
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
}
ha-card:focus {
outline: none;
}
ha-card.disabled {
pointer-events: none;
color: var(--disabled-text-color);
}
.content-container {
display: flex;
align-items: center;
padding: 8px;
gap: var(--ha-space-3);
}
ha-card .thumbnail {
width: 40px;
height: 40px;
flex-shrink: 0;
position: relative;
box-sizing: border-box;
border-radius: var(--ha-border-radius-md);
overflow: hidden;
}
ha-card .image {
border-radius: var(--ha-border-radius-md);
}
.folder {
--mdc-icon-size: 24px;
}
.title {
font-size: var(--ha-font-size-m);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
flex: 1;
min-width: 0;
}
.image {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
--ha-media-browser-thumbnail-fit: cover;
}
.centered-image {
margin: 4px;
--ha-media-browser-thumbnail-fit: contain;
}
.icon-holder {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
`;
}
declare global {
@@ -13,6 +13,7 @@ import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
import "../ha-md-list";
@@ -237,10 +238,14 @@ export class HaObjectSelector extends LitElement {
private async _addItem(ev) {
ev.stopPropagation();
const schema = this._schema(this.selector);
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
schema: this._schema(this.selector),
data: {},
schema,
data: computeInitialHaFormData(schema, {
skipUnsupportedSelectors: true,
}),
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
@@ -293,7 +298,7 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "value-changed", { value: "" });
return;
}
@@ -99,6 +99,10 @@ const SELECTOR_SCHEMAS = {
},
},
},
{
name: "multiple",
selector: { boolean: {} },
},
] as const,
number: [
{
@@ -0,0 +1,46 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { StateClassSelector } from "../../data/selector";
import "../ha-state-class-picker";
import { SENSOR_STATE_CLASSES } from "../../data/sensor_entity_constants";
@customElement("ha-selector-state_class")
export class HaStateClassSelector extends LitElement {
@property({ attribute: false }) public selector!: StateClassSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-state-class-picker
.value=${this.value}
.multiple=${this.selector.state_class?.multiple ?? false}
.stateClasses=${this.selector.state_class?.state_classes ?? SENSOR_STATE_CLASSES}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-state-class-picker>
`;
}
static styles = css`
ha-state-class-picker {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-state_class": HaStateClassSelector;
}
}
+3 -2
View File
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { Selector } from "../../data/selector";
import type { Selector, SelectorType } from "../../data/selector";
import {
handleLegacyDeviceSelector,
handleLegacyEntitySelector,
@@ -49,6 +49,7 @@ const LOAD_ELEMENTS = {
selector: () => import("./ha-selector-selector"),
serial_port: () => import("./ha-selector-serial-port"),
state: () => import("./ha-selector-state"),
state_class: () => import("./ha-selector-state-class"),
backup_location: () => import("./ha-selector-backup-location"),
stt: () => import("./ha-selector-stt"),
target: () => import("./ha-selector-target"),
@@ -70,7 +71,7 @@ const LOAD_ELEMENTS = {
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
};
} satisfies Record<SelectorType, () => Promise<unknown>>;
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
+2 -6
View File
@@ -12,7 +12,6 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { toggleAttribute } from "../common/dom/toggle_attribute";
import { stringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle";
@@ -296,7 +295,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("alwaysExpand")) {
toggleAttribute(this, "expanded", this.alwaysExpand);
this.toggleAttribute("expanded", this.alwaysExpand);
}
if (!changedProps.has("hass")) {
return;
@@ -665,9 +664,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
background-color: var(--sidebar-background-color);
width: 100%;
box-sizing: border-box;
@@ -897,7 +894,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
.menu ha-icon-button {
-webkit-transform: scaleX(var(--scale-direction));
transform: scaleX(var(--scale-direction));
}
+221
View File
@@ -0,0 +1,221 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { SENSOR_STATE_CLASSES } from "../data/sensor_entity_constants";
import { computeStateClassName } from "../data/entity/state_class";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
export const getStateClassOptions = (
localize: LocalizeFunc,
stateClasses: string[]
): PickerComboBoxItem[] =>
SENSOR_STATE_CLASSES.filter((stateClass) =>
stateClasses.includes(stateClass)
).map((stateClass) => {
const primary = computeStateClassName(localize, stateClass);
return { id: stateClass, primary, sorting_label: primary };
});
@customElement("ha-state-class-picker")
export class HaStateClassPicker extends LitElement {
@property({ attribute: false }) public value?: string | string[];
@property({ type: Boolean }) public multiple = false;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ attribute: false }) public stateClasses?: string[];
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
private _translationsLoaded = false;
protected willUpdate() {
if (this._translationsLoaded || !this._i18n) {
return;
}
this._translationsLoaded = true;
this._i18n.loadBackendTranslation("entity_component", "sensor");
}
private get _value(): string[] {
return this.value ? ensureArray(this.value) : [];
}
private _stateClassName(stateClass: string): string {
return this._i18n
? computeStateClassName(this._i18n.localize, stateClass)
: stateClass;
}
private _options = memoizeOne(
(
localize: LocalizeFunc | undefined,
stateClasses: string[]
): PickerComboBoxItem[] =>
localize ? getStateClassOptions(localize, stateClasses) : []
);
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(
this._i18n?.localize,
this.stateClasses || SENSOR_STATE_CLASSES
);
return this.multiple
? this._availableOptions(options, this._value)
: options;
};
private _valueRenderer = (value: string) =>
html`<span slot="headline">${this._stateClassName(value)}</span>`;
private _notFoundLabel = (search: string) => {
const term = html`<b>'${search}'</b>`;
return this._i18n
? this._i18n.localize("ui.components.state-class-picker.no_match", {
term,
})
: html`No state classes found for ${term}`;
};
protected render() {
const localize = this._i18n?.localize;
const emptyLabel = localize?.(
"ui.components.state-class-picker.no_state_classes"
);
if (this.multiple) {
const value = this._value;
return html`
${
value.length
? html`
<ha-chip-set>
${repeat(
value,
(stateClass) => stateClass,
(stateClass) => {
const label = this._stateClassName(stateClass);
return html`
<ha-input-chip
.item=${stateClass}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
<ha-generic-picker
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.addButtonLabel=${
this.label ?? localize?.("ui.components.state-class-picker.add")
}
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
}
return html`
<ha-generic-picker
.label=${
this.label ??
localize?.("ui.components.state-class-picker.state_class")
}
.value=${this.value as string | undefined}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || undefined });
}
private _itemAdded(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
const stateClass = ev.detail.value;
if (!stateClass || this._value.includes(stateClass)) {
return;
}
this._setValue([...this._value, stateClass]);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const stateClass = (ev.currentTarget as HTMLElement & { item: string })
.item;
this._setValue(this._value.filter((item) => item !== stateClass));
}
private _setValue(value: string[]) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
static styles = css`
:host {
display: block;
}
ha-generic-picker {
display: block;
width: 100%;
}
ha-chip-set {
padding: 8px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-state-class-picker": HaStateClassPicker;
}
}
+133 -105
View File
@@ -9,7 +9,8 @@ import {
} from "../resources/theme/color/color.globals";
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
import "./ha-button";
import "./ha-settings-row";
import "./item/ha-list-item-base";
import "./list/ha-list-base";
import "./ha-theme-picker";
import "./input/ha-input";
import "./radio/ha-radio-group";
@@ -25,6 +26,7 @@ export interface ThemeSettingsLabels {
autoMode?: string;
lightMode?: string;
darkMode?: string;
colors?: string;
primaryColor?: string;
accentColor?: string;
reset?: string;
@@ -42,7 +44,7 @@ export class HaThemeSettings extends LitElement {
@property() public heading?: string;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@@ -62,98 +64,121 @@ export class HaThemeSettings extends LitElement {
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
: this.hass.themes.default_theme;
const showDarkMode =
curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme);
return html`
<ha-settings-row .narrow=${this.narrow} ?empty=${!this.showThemePicker}>
${
this.heading
? html`<span slot="heading">${this.heading}</span>`
: nothing
}
${
this.description
? html`<span slot="description">${this.description}</span>`
: nothing
}
<ha-list-base>
${
this.showThemePicker
? html`
<ha-theme-picker
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
.disabled=${this.themePickerDisabled}
?include-default=${this.includeDefault}
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
<ha-list-item-base>
${
this.heading
? html`<span slot="headline">${this.heading}</span>`
: nothing
}
${
this.description
? html`<span slot="supporting-text"
>${this.description}</span
>`
: nothing
}
<ha-theme-picker
slot="end"
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
.disabled=${this.themePickerDisabled}
?include-default=${this.includeDefault}
@value-changed=${this._handleThemeSelection}
></ha-theme-picker>
</ha-list-item-base>
`
: nothing
}
</ha-settings-row>
${
curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme)
? html`<div class="inputs">
<ha-radio-group
@change=${this._handleDarkMode}
name="dark_mode"
.ariaLabel=${this.labels?.mode ?? "Theme mode"}
.value=${
themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"
${
showDarkMode
? html`
<ha-list-item-base>
<span slot="headline"
>${this.labels?.mode ?? "Theme mode"}</span
>
<ha-radio-group
slot="end"
@change=${this._handleDarkMode}
name="dark_mode"
.value=${
themeSettings?.dark === undefined
? "auto"
: themeSettings.dark
? "dark"
: "light"
}
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.labels?.autoMode ?? "Auto"}
</ha-radio-option>
<ha-radio-option value="light">
${this.labels?.lightMode ?? "Light"}
</ha-radio-option>
<ha-radio-option value="dark">
${this.labels?.darkMode ?? "Dark"}
</ha-radio-option>
</ha-radio-group>
</ha-list-item-base>
`
: nothing
}
${
curTheme === HOME_ASSISTANT_THEME
? html`
<ha-list-item-base>
<span slot="headline"
>${this.labels?.colors ?? "Custom colors"}</span
>
<div slot="end" class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.labels?.primaryColor ?? "Primary color"}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.labels?.accentColor ?? "Accent color"}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
</div>
</ha-list-item-base>
${
themeSettings?.primaryColor || themeSettings?.accentColor
? html`
<div class="reset-row">
<ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.labels?.reset ?? "Reset"}
</ha-button>
</div>
`
: nothing
}
orientation="horizontal"
>
<ha-radio-option value="auto">
${this.labels?.autoMode ?? "Auto"}
</ha-radio-option>
<ha-radio-option value="light">
${this.labels?.lightMode ?? "Light"}
</ha-radio-option>
<ha-radio-option value="dark">
${this.labels?.darkMode ?? "Dark"}
</ha-radio-option>
</ha-radio-group>
${
curTheme === HOME_ASSISTANT_THEME
? html`<div class="color-pickers">
<ha-input
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
type="color"
.label=${this.labels?.primaryColor ?? "Primary color"}
.name=${"primaryColor"}
@change=${this._handleColorChange}
></ha-input>
<ha-input
.value=${themeSettings?.accentColor || DefaultAccentColor}
type="color"
.label=${this.labels?.accentColor ?? "Accent color"}
.name=${"accentColor"}
@change=${this._handleColorChange}
></ha-input>
${
themeSettings?.primaryColor ||
themeSettings?.accentColor
? html` <ha-button
appearance="plain"
size="s"
@click=${this._resetColors}
>
${this.labels?.reset ?? "Reset"}
</ha-button>`
: nothing
}
</div>`
: nothing
}
</div>`
: nothing
}
`
: nothing
}
</ha-list-base>
`;
}
@@ -231,33 +256,36 @@ export class HaThemeSettings extends LitElement {
a {
color: var(--primary-color);
}
.inputs {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: 0 var(--ha-space-3);
ha-list-base {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-radio-group {
display: flex;
justify-content: center;
margin-inline-end: var(--ha-space-3);
ha-theme-picker {
min-width: 150px;
}
.color-pickers {
display: flex;
justify-content: flex-end;
align-items: center;
flex-grow: 1;
gap: var(--ha-space-2);
}
ha-input {
min-width: 75px;
flex-grow: 1;
margin: 0 var(--ha-space-1);
.color-pickers ha-input {
min-width: 150px;
flex: 1;
}
ha-theme-picker {
display: block;
:host([narrow]) ha-list-item-base::part(base) {
flex-direction: column;
align-items: flex-start;
}
:host([narrow]) ha-list-item-base::part(end) {
width: 100%;
}
:host([narrow]) .color-pickers {
width: 100%;
}
.reset-row {
display: flex;
justify-content: flex-end;
padding-inline-end: var(--ha-space-4);
padding-bottom: var(--ha-space-4);
}
`;
}
+3
View File
@@ -113,6 +113,9 @@ export class HaInputCopy extends LitElement {
}
private _focusInput(ev: HASSDomCurrentTargetEvent<HaInput>) {
if (!this.readonly) {
return;
}
const inputElement = ev.currentTarget as HaInput;
inputElement.select();
}
-2
View File
@@ -914,8 +914,6 @@ export class HaMap extends ReactiveElement {
#map.clickable:active,
#map:active {
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
/* Only the raster fallback is inverted for dark mode, the vector style
ships its own dark cartography. */
@@ -564,75 +564,73 @@ export class HaTargetPickerItemRow extends LitElement {
this.primaryEntitiesOnly
);
let referencedAreas = entries.referenced_areas;
const hiddenAreaIds: string[] = [];
if (this.type === "floor" || this.type === "label") {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
referencedAreas = referencedAreas.filter((area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
);
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
});
}
let referencedDevices = entries.referenced_devices;
const hiddenDeviceIds: string[] = [];
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
referencedDevices = referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
);
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
});
}
entries.referenced_entities = entries.referenced_entities.filter(
const referencedEntities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
@@ -647,9 +645,9 @@ export class HaTargetPickerItemRow extends LitElement {
(this.type === "area" && entity.area_id === this.itemId) ||
(this.type === "floor" &&
entity.area_id &&
entries.referenced_areas.includes(entity.area_id)) ||
referencedAreas.includes(entity.area_id)) ||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
entries.referenced_devices.includes(entity.device_id || "")
referencedDevices.includes(entity.device_id || "")
) {
return entityRegMeetsFilter(
entity,
@@ -664,7 +662,12 @@ export class HaTargetPickerItemRow extends LitElement {
}
);
this._entries = entries;
this._entries = {
...entries,
referenced_areas: referencedAreas,
referenced_devices: referencedDevices,
referenced_entities: referencedEntities,
};
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to extract target", e);
+1 -1
View File
@@ -520,7 +520,7 @@ export class HatScriptGraph extends LitElement {
slot="head"
nofocus
></hat-graph-node>
${ensureArray(node.sequence).map((action, i) =>
${ensureArray(node.sequence ?? []).map((action, i) =>
this._renderActionNode(
action,
`${path}/sequence/${i}`,
+1 -2
View File
@@ -15,7 +15,6 @@ import { ifDefined } from "lit/directives/if-defined";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { relativeTime } from "../../common/datetime/relative_time";
import { fireEvent } from "../../common/dom/fire_event";
import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
@@ -870,7 +869,7 @@ export class HaAutomationTracer extends LitElement {
this.shadowRoot!.querySelectorAll<HaTimeline>(
"ha-timeline[data-path]"
).forEach((el) => {
toggleAttribute(el, "selected", this.selectedPath === el.dataset.path);
el.toggleAttribute("selected", this.selectedPath === el.dataset.path);
if (!this.allowPick || el.tabIndex === 0) {
return;
}
+28 -9
View File
@@ -1,8 +1,10 @@
import {
computeCssColor,
isValidColorString,
resolveThemeColor,
} from "../common/color/compute-color";
import { getColorByIndex } from "../common/color/colors";
import { getContrastedColorHex, isOpaqueColor } from "../common/color/rgb";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import type { HomeAssistant } from "../types";
@@ -13,6 +15,7 @@ export interface Calendar {
entity_id: string;
name?: string;
backgroundColor?: string;
textColor?: string;
}
/** Object used to render a calendar event in fullcalendar. */
@@ -22,6 +25,7 @@ export interface CalendarEvent {
end?: string;
backgroundColor?: string;
borderColor?: string;
textColor?: string;
calendar: string;
eventData: CalendarEventData;
[key: string]: any;
@@ -107,6 +111,28 @@ export const fetchCalendarEvents = async (
return { events: calEvents, errors };
};
export const getCalendarColors = (
color: string | null | undefined,
index: number,
computedStyles: CSSStyleDeclaration
): { backgroundColor: string; textColor?: string } => {
// Fall back to a color by index when the entity has none set
const resolved =
color && isValidColorString(color)
? color
: getColorByIndex(index, computedStyles);
// A theme color stays a CSS variable in the background, so the text color
// comes from what that variable holds for this element.
const background = resolveThemeColor(resolved, computedStyles);
return {
backgroundColor: computeCssColor(resolved),
// A background we cannot measure keeps the color fullcalendar picks itself
textColor: isOpaqueColor(background)
? getContrastedColorHex(background)
: undefined,
};
};
export const getCalendars = (
hass: HomeAssistant,
element: Element,
@@ -127,18 +153,10 @@ export const getCalendars = (
.map((eid, idx) => {
const stateObj = hass.states[eid];
const entityColor = entityOptionsMap.get(eid)?.calendar?.color;
let backgroundColor: string;
// Validate and use the color from entity registry if valid
if (entityColor && isValidColorString(entityColor)) {
backgroundColor = computeCssColor(entityColor);
} else {
// Fall back to default color by index
backgroundColor = getColorByIndex(idx, computedStyles);
}
return {
...stateObj,
name: computeStateName(stateObj),
backgroundColor,
...getCalendarColors(entityColor, idx, computedStyles),
};
});
};
@@ -268,6 +286,7 @@ export const normalizeSubscriptionEventData = (
title: eventData.summary,
backgroundColor: calendar.backgroundColor,
borderColor: calendar.backgroundColor,
textColor: calendar.textColor,
calendar: calendar.entity_id,
eventData: normalizedEventData,
};
+50 -33
View File
@@ -1553,7 +1553,8 @@ export const formatConsumptionShort = (
hass: HomeAssistant,
consumption: number | null,
unit: string,
targetUnit?: string
targetUnit?: string,
displayPrecision?: number
): string => {
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
let pickedUnit = unit;
@@ -1583,10 +1584,19 @@ export const formatConsumptionShort = (
pickedUnit = units[unitIndex];
}
return (
formatNumber(val, hass.locale, {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}) +
formatNumber(
val,
hass.locale,
displayPrecision !== undefined && pickedUnit === unit
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}
) +
" " +
pickedUnit
);
@@ -1909,20 +1919,13 @@ export const downloadEnergyData = (
const device_consumption_water = energyData.prefs.device_consumption_water;
const stats = energyData.state.stats;
const timeSet = new Set<number>();
Object.values(stats).forEach((stat) => {
stat.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const headers =
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n";
const csv: string[] = [];
csv[0] = headers;
interface CsvRow {
id: string;
type: string;
unit: string;
data: StatisticValue[];
}
const rows: CsvRow[] = [];
const processCsvRow = function (
id: string,
@@ -1930,20 +1933,7 @@ export const downloadEnergyData = (
unit: string,
data: StatisticValue[]
) {
let n = 0;
const row: string[] = [];
row.push(id);
row.push(type);
row.push(unit.normalize("NFKD"));
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
rows.push({ id, type, unit, data });
};
const processStat = function (stat: string, type: string, unit: string) {
@@ -2190,6 +2180,33 @@ export const downloadEnergyData = (
);
}
const timeSet = new Set<number>();
rows.forEach((row) => {
row.data.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const csv: string[] = [
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n",
];
rows.forEach(({ id, type, unit, data }) => {
let n = 0;
const row: string[] = [id, type, unit.normalize("NFKD")];
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
});
const blob = new Blob(csv, {
type: "text/csv",
});
+13 -3
View File
@@ -96,10 +96,17 @@ export interface ValveEntityOptions {
favorite_positions?: number[];
}
export type FavoriteOption =
"favorite_colors" | "favorite_positions" | "favorite_tilt_positions";
export interface TimerEntityOptions {
presets?: number[];
}
export type FavoritesDomain = "light" | "cover" | "valve";
export type FavoriteOption =
| "favorite_colors"
| "favorite_positions"
| "favorite_tilt_positions"
| "presets";
export type FavoritesDomain = "light" | "cover" | "valve" | "timer";
export type FavoriteOptionValue = LightColor[] | number[];
@@ -107,6 +114,7 @@ export const DOMAINS_WITH_FAVORITES: FavoritesDomain[] = [
"light",
"cover",
"valve",
"timer",
];
export const isFavoritesDomain = (domain: string): domain is FavoritesDomain =>
@@ -173,6 +181,7 @@ export interface EntityRegistryOptions {
light?: LightEntityOptions;
cover?: CoverEntityOptions;
valve?: ValveEntityOptions;
timer?: TimerEntityOptions;
vacuum?: VacuumEntityOptions;
device_tracker?: DeviceTrackerEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
@@ -200,6 +209,7 @@ export interface EntityRegistryEntryUpdateParams {
| LightEntityOptions
| CoverEntityOptions
| ValveEntityOptions
| TimerEntityOptions
| VacuumEntityOptions
| DeviceTrackerEntityOptions;
aliases?: (string | null)[];
+11
View File
@@ -0,0 +1,11 @@
import type { LocalizeFunc } from "../../common/translations/localize";
export const NO_STATE_CLASS = "none";
export const computeStateClassName = (
localize: LocalizeFunc,
stateClass: string
): string =>
localize(
`component.sensor.entity_component._.state_attributes.state_class.state.${stateClass}`
) || stateClass;

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