Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld a306cf4687 Edit zones and locations on the map engine instead of leaflet-draw
ha-locations-editor (zone config, the location selector, onboarding)
used raw Leaflet layers and leaflet-draw, which pinned it to the Leaflet
engine. Its interaction surface is three gestures - drag a marker, drag
a zone center, drag a zone radius handle - so no editing library is
needed.

MapEngine gains an optional editing capability (draggable markers and an
addEditableCircle primitive) plus panTo and containsLocation. Only the
MapLibre engine implements editing: the circle geometry plus a draggable
center marker and a draggable radius handle on the east edge, the radius
derived from the great-circle distance between them, redrawn per frame
while dragging. The handle has a touch-sized hit target and is a
keyboard-operable slider with an accessible name. The Leaflet engine
stays a viewing fallback: without WebGL2 an editor shows its locations
as static markers and a notice that this browser cannot edit on the map.

ha-map exposes editableLocations and fires
editable-location-moved/resized/clicked and editing-available-changed.
Locations are reconciled by id: position and radius changes move the
existing handles, while a changed icon, name, color, or editability
rebuilds the marker, so edits made in the form fields show up on the
map on either engine. ha-locations-editor becomes a thin layer over
that with its public contract unchanged, so its consumers need no
changes; it reads localize from the internationalization context instead
of taking hass.

The zone panel no longer zooms the map when a selection comes from the
map itself. Clicking or dragging a marker selects its list item, and
mwc-list reports that programmatic selection with the same "property"
request-selected event a click ends with. The two are told apart by
timing: a selection from the map has already set the active entry when
its event arrives, while a click's event arrives before the handler
sets it. Skipping the programmatic event also keeps the item selected,
which the shared request-selected helper would otherwise reset.

The layers property and the engine selector are removed along with the
leaflet-draw dependency and its patch: Leaflet is now purely the viewing
fallback, selected by WebGL2 support alone. The Leaflet engine keeps a
leafletMap field for the ha-map fit tests, which run on it in jsdom.
2026-09-07 12:30:58 +02:00
Maarten Lakerveld 6ad4fb6161 Test engine selection and the Leaflet fallback
jsdom has no WebGL2, so the existing ha-map tests only ever run Leaflet.
With the WebGL2 probe stubbed and the MapLibre engine replaced by a fake,
this covers the runtime choice: MapLibre with WebGL2, Leaflet without it,
Leaflet after MapLibre fails to start, and a rebuild on Leaflet with the
entities redrawn after a fatal engine event.
2026-09-07 12:30:58 +02:00
03b01e3b05 Fix dark mode style application logic
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 12:26:43 +02:00
510ba20b59 Enhance keyboard accessibility for icon click
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-07 12:26:43 +02:00
Maarten Lakerveld 50b80585f3 Run ha-map on a runtime-selected engine: MapLibre GL native or Leaflet
ha-map no longer drives Leaflet directly. All primitive operations -
camera, HTML element markers, meter-radius circles, history paths,
clustering with a pluggable icon builder, scale ruler, dark mode - go
through a MapEngine interface, and the engine is selected at runtime:

- MapLibreMapEngine renders the vector base map natively where WebGL2 is
  available, without Leaflet in the loop: continuous fractional zoom,
  DOM markers synced to the basemap every frame, GeoJSON zone and
  accuracy circles below the labels, GeoJSON history trails with hover
  popups, and a pixel-distance cluster grid recomputed when the camera
  settles. Dark mode swaps the style while carrying the custom layers
  over, and rotation and pitch stay disabled for north-up dashboards.
- LeafletMapEngine wraps the existing behavior unchanged (vector tiles
  through the maplibre adapter with the raster fallback, markercluster)
  for browsers without WebGL2 - the legacy floor includes iOS 12-14 and
  kiosk browsers without hardware acceleration - and for
  ha-locations-editor, which manages raw Leaflet layers with
  leaflet-draw and declares engine=leaflet.

A permanently lost WebGL context rebuilds the map on the Leaflet engine
instead of leaving a dead canvas. Zoom levels keep Leaflet semantics
across engines. MapLibre's stylesheet is shipped to /static/map for the
native engine's controls and popups.
2026-09-07 12:25:37 +02:00
9bffae3ee4 Exit scene live mode when leaving the editor (#53837)
* Exit scene live mode when leaving the editor

Leaving the page previously only unsubscribed, so live-activated
device states stayed behind. Restore stored states unless the tab
is hidden for panel suspend.

* Drop extra unit tests for scene live-mode disconnect.

* Restore scene live states only on editor disconnect.

Back and delete already unmount the editor, which now exits live
mode, so a second applyScene was redundant.

* Resubscribe to scene live edits after hidden-tab suspend.

Existing scenes stayed in live mode when the panel was reattached,
but the state_changed subscription was not restored.

* Keep the scene live subscription across hidden-tab suspend.

Unsubscribing and resubscribing on reattach races the closed
websocket; leaving the subscription in place lets the library
restore it after reconnect.

* Tear down scene live mode on hidden-tab route changes.

document.hidden is not enough: saving a new scene remounts a
new editor. Skip teardown only when an ancestor was detached
(panel suspend). Reuse an existing live subscription on
reattach so new scenes do not get duplicate listeners.

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

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-09-07 12:08:46 +02:00
renovate[bot]andGitHub a287aacd98 Update dependency @codemirror/state to v6.7.3 (#54020) 2026-09-07 11:05:14 +01: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
144 changed files with 9504 additions and 1431 deletions
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
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@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:javascript-typescript"
File diff suppressed because one or more lines are too long
+2
View File
@@ -81,6 +81,8 @@ async function copyMapPanel(staticDir) {
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
function copyZXingWasm(staticDir) {
@@ -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 ||
+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(
+2
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);
+2
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",
@@ -90,6 +91,7 @@ export const demoConfigEntries: {
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))
)
);
};
+12 -14
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.3",
"@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,8 +85,8 @@
"@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",
@@ -104,13 +104,12 @@
"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.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",
"lit": "3.3.3",
"lit-html": "3.3.3",
@@ -148,13 +147,13 @@
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.65.0",
"@lokalise/node-api": "16.4.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.2.1",
"@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",
@@ -163,7 +162,6 @@
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.22",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.5",
@@ -190,7 +188,7 @@
"fs-extra": "11.4.0",
"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",
@@ -215,7 +213,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.68.0",
"typescript-eslint": "8.69.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
+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";
};
+11 -9
View File
@@ -12,14 +12,14 @@ import {
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
const VECTOR_STYLES = {
export const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
@@ -33,8 +33,8 @@ const OSM_ATTRIBUTION =
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
export const CONTEXT_RESTORE_GRACE = 2000;
export const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
@@ -55,7 +55,7 @@ export interface MapBaseLayer {
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
const supportsWebGL2 = (): boolean => {
export const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
@@ -91,7 +91,7 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
const loadStyle = async (url: string): Promise<StyleSpecification> => {
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
@@ -111,7 +111,7 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
@@ -303,9 +303,11 @@ export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined
token: string | undefined,
// Skip the vector layer, e.g. after a permanent WebGL context loss
rasterOnly = false
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
if (!rasterOnly && supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
+58
View File
@@ -0,0 +1,58 @@
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
/** Hit target for the radius handle; comfortably above touch minimums */
export const RESIZE_HANDLE_SIZE = 24;
/** The visible dot inside the hit target */
export const RESIZE_HANDLE_DOT_SIZE = 12;
/** Relative radius change per arrow key press on the handle */
export const RESIZE_KEY_STEP = 0.1;
export const createResizeHandleElement = (label?: string): HTMLElement => {
const element = document.createElement("div");
element.className = "editable-circle-resize";
element.tabIndex = 0;
element.setAttribute("role", "slider");
element.setAttribute("aria-valuemin", "1");
// A slider needs a maximum; no zone comes near 100 km
element.setAttribute("aria-valuemax", "100000");
if (label) {
element.setAttribute("aria-label", label);
}
const dot = document.createElement("div");
dot.className = "editable-circle-resize-dot";
element.appendChild(dot);
return element;
};
/** Styles for the handles, included by ha-map for both engines */
export const editableCircleStyles = `
.editable-circle-center {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary-color);
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
cursor: move;
}
.editable-circle-resize {
width: ${RESIZE_HANDLE_SIZE}px;
height: ${RESIZE_HANDLE_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
}
.editable-circle-resize-dot {
width: ${RESIZE_HANDLE_DOT_SIZE}px;
height: ${RESIZE_HANDLE_DOT_SIZE}px;
border-radius: 50%;
background: var(--card-background-color, #fff);
border: 2px solid var(--primary-color);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
`;
@@ -0,0 +1,339 @@
import type {
CircleMarker,
Control,
Map,
MarkerClusterGroup,
Polyline,
} from "leaflet";
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
import type { MapBaseLayer } from "../base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
/** A leaflet marker that knows the engine handle it was created for */
interface HandledMarker extends DecoratedMarker {
engineHandle?: LeafletMarkerHandle;
}
interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
export class LeafletMapEngine implements MapEngine {
/** For the ha-map jsdom tests only */
public leafletMap?: Map;
public Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _clusterable: HandledMarker[] = [];
private _cluster?: MarkerClusterGroup;
private _clusterOptions: MapClusterOptions | null = null;
private _scaleControl?: Control.Scale;
public async init(
container: HTMLElement,
options: MapEngineOptions
): Promise<void> {
const root = container.parentNode;
if (!root) {
throw new Error("Cannot set up a Leaflet map on a detached element");
}
// eslint-disable-next-line
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
await import("leaflet.markercluster");
const map = Leaflet.map(container, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
for (const href of [
"/static/images/leaflet/leaflet.css",
"/static/images/leaflet/MarkerCluster.css",
]) {
const style = document.createElement("link");
style.setAttribute("href", href);
style.setAttribute("rel", "stylesheet");
root.appendChild(style);
}
map.setView(options.center, options.zoom);
// The base layer adds itself; a vector layer may still fall back to raster
this._baseLayer = await createBaseLayer(
Leaflet,
map,
options.darkMode,
options.token,
options.rasterOnly ?? false
);
this.leafletMap = map;
this.Leaflet = Leaflet;
map.zoomControl?.setPosition(options.zoomControlPosition);
const { events } = options;
if (events.click) {
map.on("click", (ev) => {
events.click!([ev.latlng.lat, ev.latlng.lng]);
});
}
if (events.zoomStart) {
map.on("zoomstart", () => events.zoomStart!());
}
if (events.moveStart) {
map.on("movestart", () => events.moveStart!());
}
}
public destroy(): void {
this.leafletMap?.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
this._cluster = undefined;
this._clusterable = [];
this._scaleControl = undefined;
}
public invalidateSize(): void {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
}
public hasUsableSize(): boolean {
if (!this.leafletMap) {
return false;
}
const size = this.leafletMap.getSize();
if (size.x > 0 && size.y > 0) {
return true;
}
const container = this.leafletMap.getContainer();
if (container.clientWidth > 0 && container.clientHeight > 0) {
// The container was laid out since Leaflet last measured it
this.leafletMap.invalidateSize(false);
return true;
}
return false;
}
public setDarkMode(darkMode: boolean): void {
this._baseLayer?.setDarkMode(darkMode);
}
public setZoomControlPosition(position: MapControlPosition): void {
this.leafletMap?.zoomControl?.setPosition(position);
}
public setScaleRuler(options: { metric: boolean } | null): void {
if (this._scaleControl) {
this.leafletMap?.removeControl(this._scaleControl);
this._scaleControl = undefined;
}
if (!options || !this.leafletMap || !this.Leaflet) {
return;
}
this._scaleControl = this.Leaflet.control.scale({
position: "bottomleft",
metric: options.metric,
imperial: !options.metric,
});
this._scaleControl.addTo(this.leafletMap!);
}
public setView(center: MapLatLng, zoom?: number): void {
this.leafletMap?.setView(center, zoom);
}
public setZoom(zoom: number): void {
this.leafletMap?.setZoom(zoom);
}
private _getZoom(): number {
return this.leafletMap?.getZoom() ?? 0;
}
private _project(location: MapLatLng): { x: number; y: number } {
const point = this.leafletMap!.project(location, this._getZoom());
return { x: point.x, y: point.y };
}
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
if (!this.leafletMap || !this.Leaflet || !points.length) {
return;
}
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
this.leafletMap.fitBounds(bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
});
}
public panTo(location: MapLatLng): void {
this.leafletMap?.panTo(location);
}
public containsLocation(location: MapLatLng): boolean {
return this.leafletMap?.getBounds().contains(location) ?? false;
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
const decoration = options.decoration
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
radius: options.decoration.radius,
})
: undefined;
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive: options.interactive ?? true,
keyboard: options.interactive ?? true,
title: options.title,
});
const handle: LeafletMarkerHandle = {
marker,
location,
clusterData: options.clusterData,
remove: () => {
this._cluster?.removeLayer(marker);
marker.remove();
const index = this._clusterable.indexOf(marker);
if (index !== -1) {
this._clusterable.splice(index, 1);
}
},
};
marker.engineHandle = handle;
if (options.cluster) {
// Placed on the map by the next setClustering call
this._clusterable.push(marker);
} else {
marker.addTo(this.leafletMap!);
}
return handle;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
}
public addPath(path: MapPath): MapItemHandle {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
this.Leaflet!.polyline(segment.points, {
color: path.color,
opacity: segment.opacity,
interactive: false,
})
);
}
for (const pathMarker of path.markers) {
items.push(
this.Leaflet!.circleMarker(pathMarker.location, {
radius: isTouch ? 8 : 3,
color: path.color,
opacity: pathMarker.opacity,
fillOpacity: pathMarker.opacity,
interactive: true,
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
}
public setClustering(options: MapClusterOptions | null): void {
if (this._cluster) {
this._cluster.remove();
this._cluster = undefined;
}
this._clusterOptions = options;
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (!options) {
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
return;
}
// markercluster groups by proximity only; groupKey is not supported here
this._cluster = this.Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: options.radius,
iconCreateFunction: (cluster) => {
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
(marker) => marker.engineHandle!
);
const latLng = cluster.getLatLng();
const icon = this._clusterOptions!.iconBuilder(members, [
latLng.lat,
latLng.lng,
]);
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
let anchor = icon.anchor;
if (icon.location) {
const clusterPoint = this._project([latLng.lat, latLng.lng]);
const targetPoint = this._project(icon.location);
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
anchor = [
base[0] - (targetPoint.x - clusterPoint.x),
base[1] - (targetPoint.y - clusterPoint.y),
];
}
return this.Leaflet!.divIcon({
html: icon.element,
iconSize: icon.size,
iconAnchor: anchor,
className: "",
});
},
});
this._cluster.addLayers(this._clusterable);
this.leafletMap!.addLayer(this._cluster!);
}
public refreshClusters(): void {
this._cluster?.refreshClusters();
}
}
File diff suppressed because it is too large Load Diff
+267
View File
@@ -0,0 +1,267 @@
/**
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
* contract; new capabilities go on MapLibre only, as optional members like
* `editing`.
*
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
*/
export type MapLatLng = [latitude: number, longitude: number];
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
export interface MapEngineEvents {
/** Click on the map surface, not on a marker */
click(location: MapLatLng): void;
/** Zoom is starting, programmatic or not */
zoomStart(): void;
/** The map starts moving, programmatic or not */
moveStart(): void;
/** The engine can no longer render; the host switches to the fallback */
fatal(): void;
}
export interface MapEngineOptions {
center: MapLatLng;
zoom: number;
darkMode: boolean;
/** Token for core's tile proxy */
token?: string;
zoomControlPosition: MapControlPosition;
/** Render without WebGL after a permanent context loss; WebGL engines reject init */
rasterOnly?: boolean;
events: Partial<MapEngineEvents>;
}
export interface MapFitOptions {
/** Do not zoom in beyond this level even if the bounds would allow it */
maxZoom?: number;
/** Relative padding around the bounds, e.g. 0.5 grows them by 50% */
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
}
export interface MapMarkerOptions {
/** Rendered size of the element in pixels */
size: [width: number, height: number];
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
anchor?: [x: number, y: number];
/** Takes pointer input and keyboard focus; defaults to true */
interactive?: boolean;
/** Accessible name */
title?: string;
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
decoration?: MapCircleOptions;
/** Cluster this marker; it appears once setClustering is called */
cluster?: boolean;
/** Caller data handed back to the cluster icon builder */
clusterData?: unknown;
}
export interface MapCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
}
export interface MapPathSegment {
points: MapLatLng[];
opacity?: number;
}
export interface MapPathMarker {
location: MapLatLng;
opacity?: number;
/** Tooltip/popup HTML shown on hover; caller is responsible for escaping */
tooltipHtml: string;
}
export interface MapPath {
color: string;
segments: MapPathSegment[];
markers: MapPathMarker[];
}
/** Handle to anything placed on the map; remove() must be idempotent */
export interface MapItemHandle {
remove(): void;
}
export interface MapMarkerHandle extends MapItemHandle {
readonly location: MapLatLng;
readonly clusterData?: unknown;
}
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
onDragEnd?(location: MapLatLng): void;
}
export interface MapEditableCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
centerElement?: HTMLElement;
centerSize?: [width: number, height: number];
title?: string;
/** The center can be dragged */
moveable?: boolean;
/** A handle on the edge can be dragged to change the radius */
resizable?: boolean;
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
resizeLabel?: string;
onMove?(center: MapLatLng): void;
onResize?(radius: number): void;
onClick?(): void;
}
export interface MapEditingSupport {
/** Place a draggable HTML element marker */
addDraggableMarker(
element: HTMLElement,
location: MapLatLng,
options: MapDraggableMarkerOptions
): MapEditableMarkerHandle;
/** Draw a circle whose center and radius can be dragged */
addEditableCircle(
center: MapLatLng,
options: MapEditableCircleOptions
): MapEditableCircleHandle;
}
/** A circle with drag handles for its center and radius */
export interface MapEditableCircleHandle extends MapItemHandle {
readonly center: MapLatLng;
readonly radius: number;
/** Move and resize without recreating (no-op mid-drag) */
update(center: MapLatLng, radius: number): void;
}
export interface MapEditableMarkerHandle extends MapMarkerHandle {
/** Move without recreating (no-op mid-drag) */
setLocation(location: MapLatLng): void;
}
export interface MapClusterIcon {
element: HTMLElement;
size: [width: number, height: number];
/** Like MapMarkerOptions.anchor; defaults to the element's center */
anchor?: [x: number, y: number];
/** Show the icon here instead of at the cluster, e.g. attached to a zone */
location?: MapLatLng;
}
export interface MapClusterOptions {
/** Cluster markers closer than this many screen pixels */
radius: number;
/**
* Markers sharing a key (e.g. their zone) form one group while they span
* at most groupRadius pixels; beyond that, and without a key, they cluster
* by proximity.
*/
groupKey?(marker: MapMarkerHandle): string | undefined;
groupRadius?: number;
/** Builds a cluster's element; called when its members change and on refreshClusters() */
iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon;
}
export interface MapEngine {
/** Create the map in the container; call once */
init(container: HTMLElement, options: MapEngineOptions): Promise<void>;
/** Tear down the map and release its resources (DOM, workers, WebGL) */
destroy(): void;
/** Re-measure the container after a size change */
invalidateSize(): void;
/** Whether the map has a non-zero size, re-measuring if needed */
hasUsableSize(): boolean;
setDarkMode(darkMode: boolean): void;
setZoomControlPosition(position: MapControlPosition): void;
/** Show a scale ruler (bottom start); null hides it */
setScaleRuler(options: { metric: boolean } | null): void;
setView(center: MapLatLng, zoom?: number): void;
setZoom(zoom: number): void;
/** Fit the given points into view; a single point centers on it */
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
/** Pan to the location, keeping the zoom */
panTo(location: MapLatLng): void;
/** Whether the location is inside the current viewport */
containsLocation(location: MapLatLng): boolean;
/** Place a caller-owned element on the map */
addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle;
/** Draw a meter-radius circle (zone radius) */
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
editing?: MapEditingSupport;
/** Draw one history trail (points with tooltips, connecting segments) */
addPath(path: MapPath): MapItemHandle;
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
setClustering(options: MapClusterOptions | null): void;
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
refreshClusters(): void;
}
const EARTH_RADIUS = 6371008.8;
/** Great-circle distance in meters */
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(b[0] - a[0]);
const dLng = toRad(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
};
/** The point the given distance due east of center, e.g. for a resize handle */
export const pointEastOf = (
center: MapLatLng,
distanceInMeters: number
): MapLatLng => {
const lngOffset =
(distanceInMeters /
(EARTH_RADIUS * Math.cos((center[0] * Math.PI) / 180))) *
(180 / Math.PI);
return [center[0], center[1] + lngOffset];
};
/** Bounding box corners of a circle, for fitting a radius into view */
export const circleBoundsPoints = (
center: MapLatLng,
radiusMeters: number
): MapLatLng[] => {
const latOffset = radiusMeters / 111320;
const lngOffset =
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
return [
[center[0] - latOffset, center[1] - lngOffset],
[center[0] + latOffset, center[1] + lngOffset],
];
};
+111 -5
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();
};
/**
@@ -100,6 +101,9 @@ export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.delete(guard);
};
const dirtyGuards = (): UnsavedChangesGuard[] =>
[...unsavedChangesGuards].filter((guard) => guard.isDirty());
let pendingUnsavedPrompt: Promise<boolean> | undefined;
/**
@@ -109,6 +113,43 @@ let pendingUnsavedPrompt: Promise<boolean> | undefined;
*/
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
@@ -117,16 +158,14 @@ let committedNavigations = 0;
* its save action) cannot deadlock on its own promise.
*/
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
guard.isDirty()
);
if (!dirtyGuards.length) {
const guards = dirtyGuards();
if (!guards.length) {
return Promise.resolve(true);
}
if (!pendingUnsavedPrompt) {
pendingUnsavedPrompt = (async () => {
try {
for (const guard of dirtyGuards) {
for (const guard of guards) {
// eslint-disable-next-line no-await-in-loop
if (!(await guard.prompt())) {
return false;
@@ -185,6 +224,7 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
);
}
rememberCurrentEntry();
fireEvent(mainWindow, "location-changed", {
replace,
});
@@ -235,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 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;
}
);
};
+5 -33
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) {
+12 -3
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,
@@ -48,6 +48,7 @@ const SELECTOR_FALLBACK_VALUES = {
selector: undefined,
serial_port: undefined,
state: undefined,
state_class: undefined,
backup_location: undefined,
stt: undefined,
target: undefined,
@@ -92,6 +92,7 @@ const SELECTOR_INITIAL_VALUES = {
selector: undefined,
serial_port: () => "",
state: (selector) => (selector.state?.multiple ? [] : ""),
state_class: (selector) => (selector.state_class?.multiple ? [] : undefined),
backup_location: undefined,
stt: undefined,
target: () => ({}),
+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);
}
@@ -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;
}
}
@@ -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"),
+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;
}
}
+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();
}
+132 -227
View File
@@ -1,22 +1,18 @@
import type {
Circle,
DivIcon,
DragEndEvent,
LatLng,
LatLngExpression,
Marker,
MarkerOptions,
} from "leaflet";
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { ThemeMode } from "../../types";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap } from "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaIcon } from "../ha-icon";
import type { HaSvgIcon } from "../ha-svg-icon";
@@ -43,6 +39,12 @@ export interface MarkerLocation {
radius_editable?: boolean;
}
const ICON_SIZE = 24;
/**
* A map with draggable markers and zone circles, drawn by ha-map. Without
* editing support (the Leaflet fallback) they are static, with a notice.
*/
@customElement("ha-locations-editor")
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public locations?: MarkerLocation[];
@@ -59,35 +61,19 @@ export class HaLocationsEditor extends LitElement {
@property({ type: Boolean, attribute: "pin-on-click" })
public pinOnClick = false;
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@state() private _circles: Record<string, Circle> = {};
@query("ha-map", true) private map!: HaMap;
private Leaflet?: LeafletModuleType;
@state() private _editingAvailable = true;
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
private _loadPromise: Promise<boolean | undefined | void>;
constructor() {
super();
this._loadPromise = import("leaflet").then((module) =>
import("leaflet-draw").then(() => {
this.Leaflet = module.default as LeafletModuleType;
this._updateMarkers();
return this.updateComplete.then(() => this.fitMap());
})
);
}
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
public fitMap(options?: { zoom?: number; pad?: number }): void {
this.map.fitMap(options);
}
public fitBounds(
boundingbox: LatLngExpression[],
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number }
) {
this.map.fitBounds(boundingbox, options);
@@ -97,42 +83,46 @@ export class HaLocationsEditor extends LitElement {
id: string,
options?: { zoom?: number }
): Promise<void> {
if (!this.Leaflet) {
await this._loadPromise;
}
if (!this.map.leafletMap || !this._locationMarkers) {
await this.updateComplete;
const location = this.locations?.find((loc) => loc.id === id);
if (!location) {
return;
}
const marker = this._locationMarkers[id];
if (!marker) {
return;
}
if ("getBounds" in marker) {
this.map.leafletMap.fitBounds(marker.getBounds());
(marker as Circle).bringToFront();
const center: MapLatLng = [location.latitude, location.longitude];
if (location.radius) {
// Only the map's maximum caps the zoom, however small the zone
this.map.fitBounds(circleBoundsPoints(center, location.radius), {
pad: 0,
zoom: MAP_MAX_ZOOM,
});
} else {
const circle = this._circles[id];
if (circle) {
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this.map.leafletMap.setView(
marker.getLatLng(),
options?.zoom || this.zoom
);
}
this.map.setView(center, options?.zoom || this.zoom);
}
}
protected render(): TemplateResult {
return html`
<ha-map
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.editableLocations=${this._editableLocations(this.locations)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
@editable-location-moved=${this._locationMoved}
@editable-location-resized=${this._radiusChanged}
@editable-location-clicked=${this._markerClicked}
@editing-available-changed=${this._editingAvailableChanged}
></ha-map>
${
!this._editingAvailable && this._i18n
? html`<ha-input-helper-text
>${this._i18n.localize(
"ui.components.map.editing_unavailable"
)}</ha-input-helper-text
>`
: ""
}
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
@@ -141,63 +131,101 @@ export class HaLocationsEditor extends LitElement {
`;
}
private _getLayers = memoizeOne(
(
circles: Record<string, Circle>,
markers?: Record<string, Marker | Circle>
): (Marker | Circle)[] => {
const layers: (Marker | Circle)[] = [];
Array.prototype.push.apply(layers, Object.values(circles));
if (markers) {
Array.prototype.push.apply(layers, Object.values(markers));
private _editableLocations = memoizeOne(
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
this._elements.delete(id);
}
}
return layers;
return (locations ?? []).map((location) => ({
id: location.id,
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
radiusEditable: location.radius_editable,
}));
}
);
public willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
// Still loading.
if (!this.Leaflet) {
return;
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const key = JSON.stringify([
location.icon,
location.iconPath,
location.name,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
const element = this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
if (changedProps.has("locations")) {
this._updateMarkers();
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
}
const el = document.createElement("div");
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
return el;
}
public updated(changedProps: PropertyValues): void {
// Still loading.
if (!this.Leaflet) {
return;
}
if (changedProps.has("locations")) {
const oldLocations = changedProps.get("locations");
fireEvent(this, "markers-updated");
// Follow a location that was edited out of view
const oldLocations = changedProps.get("locations") as
MarkerLocation[] | undefined;
const movedLocations = this.locations?.filter(
(loc, idx) =>
!oldLocations[idx] ||
!oldLocations?.[idx] ||
((loc.latitude !== oldLocations[idx].latitude ||
loc.longitude !== oldLocations[idx].longitude) &&
this.map.leafletMap?.getBounds().contains({
lat: oldLocations[idx].latitude,
lng: oldLocations[idx].longitude,
}) &&
!this.map.leafletMap
?.getBounds()
.contains({ lat: loc.latitude, lng: loc.longitude }))
this.map.containsLocation([
oldLocations[idx].latitude,
oldLocations[idx].longitude,
]) &&
!this.map.containsLocation([loc.latitude, loc.longitude]))
);
if (movedLocations?.length === 1) {
this.map.leafletMap?.panTo({
lat: movedLocations[0].latitude,
lng: movedLocations[0].longitude,
});
this.map.panTo([
movedLocations[0].latitude,
movedLocations[0].longitude,
]);
}
}
}
private _editingAvailableChanged(ev: HASSDomEvent<{ available: boolean }>) {
this._editingAvailable = ev.detail.available;
}
private _normalizeLongitude(longitude: number): number {
if (Math.abs(longitude) > 180.0) {
// Normalize longitude if map provides values beyond -180 to +180 degrees.
@@ -206,40 +234,37 @@ export class HaLocationsEditor extends LitElement {
return longitude;
}
private _updateLocation(ev: DragEndEvent) {
const marker = ev.target;
const latlng: LatLng = marker.getLatLng();
const location: [number, number] = [
latlng.lat,
this._normalizeLongitude(latlng.lng),
];
private _locationMoved(
ev: HASSDomEvent<{ id: string; location: MapLatLng }>
) {
const [latitude, longitude] = ev.detail.location;
fireEvent(
this,
"location-updated",
{ id: marker.id, location },
{
id: ev.detail.id,
location: [latitude, this._normalizeLongitude(longitude)],
},
{ bubbles: false }
);
}
private _updateRadius(ev: DragEndEvent) {
const marker = ev.target;
const circle = this._locationMarkers![marker.id] as Circle;
private _radiusChanged(ev: HASSDomEvent<{ id: string; radius: number }>) {
fireEvent(
this,
"radius-updated",
{ id: marker.id, radius: circle.getRadius() },
{ id: ev.detail.id, radius: ev.detail.radius },
{ bubbles: false }
);
}
private _markerClicked(ev: DragEndEvent) {
const marker = ev.target;
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
private _markerClicked(ev: HASSDomEvent<{ id: string }>) {
fireEvent(this, "marker-clicked", { id: ev.detail.id }, { bubbles: false });
}
private _mapClicked(ev) {
if (this.pinOnClick && this._locationMarkers) {
const id = Object.keys(this._locationMarkers)[0];
private _mapClicked(ev: HASSDomEvent<{ location: [number, number] }>) {
if (this.pinOnClick && this.locations?.length) {
const id = this.locations[0].id;
const location: [number, number] = [
ev.detail.location[0],
this._normalizeLongitude(ev.detail.location[1]),
@@ -248,131 +273,11 @@ export class HaLocationsEditor extends LitElement {
// If the normalized longitude wraps around the globe, pan to the new location.
if (location[1] !== ev.detail.location[1]) {
this.map.leafletMap?.panTo({ lat: location[0], lng: location[1] });
this.map.panTo(location);
}
}
}
private _updateMarkers(): void {
if (!this.locations || !this.locations.length) {
this._circles = {};
this._locationMarkers = undefined;
return;
}
const locationMarkers = {};
const circles = {};
const defaultZoneRadiusColor =
getComputedStyle(this).getPropertyValue("--accent-color");
this.locations.forEach((location: MarkerLocation) => {
let icon: DivIcon | undefined;
if (location.icon || location.iconPath) {
// create icon
const el = document.createElement("div");
el.className = "named-icon";
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
icon = this.Leaflet!.divIcon({
html: el.outerHTML,
iconSize: [24, 24],
className: "light",
});
}
if (location.radius) {
const circle = this.Leaflet!.circle(
[location.latitude, location.longitude],
{
color: location.radius_color || defaultZoneRadiusColor,
radius: location.radius,
}
);
if (location.radius_editable || location.location_editable) {
// @ts-ignore
circle.editing.enable();
circle.addEventListener("add", () => {
// @ts-ignore
const moveMarker = circle.editing._moveMarker;
// @ts-ignore
const resizeMarker = circle.editing._resizeMarkers[0];
if (icon) {
moveMarker.setIcon(icon);
}
resizeMarker.id = moveMarker.id = location.id;
moveMarker
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
});
locationMarkers[location.id] = circle;
} else {
circles[location.id] = circle;
}
}
if (
!location.radius ||
(!location.radius_editable && !location.location_editable)
) {
const options: MarkerOptions = {
title: location.name,
draggable: location.location_editable,
};
if (icon) {
options.icon = icon;
}
const marker = this.Leaflet!.marker(
[location.latitude, location.longitude],
options
)
.addEventListener("dragend", (ev: DragEndEvent) =>
this._updateLocation(ev)
)
.addEventListener(
// @ts-ignore
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
(marker as any).id = location.id;
locationMarkers[location.id] = marker;
}
});
this._circles = circles;
this._locationMarkers = locationMarkers;
fireEvent(this, "markers-updated");
}
static styles = css`
ha-map {
display: block;
File diff suppressed because it is too large Load Diff
+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}`,
+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,
};
+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;
+2 -2
View File
@@ -280,14 +280,14 @@ export const restartHassioAddon = async (
export const uninstallHassioAddon = async (
callWS: CallWS,
slug: string,
removeData: boolean
removeConfig: boolean
): Promise<void> => {
await callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/uninstall`,
method: "post",
timeout: null,
data: { remove_config: removeData },
data: { remove_config: removeConfig },
});
};
+13
View File
@@ -18,6 +18,19 @@ export const integrationsWithPanel = {
zwave_js: "config/zwave_js/dashboard",
};
/**
* The panel an integration is configured in, if it has one of its own.
*
* An integration can register a panel at runtime; the built-in ones above are
* the fallback for those that do not.
*/
export const getConfigPanelPath = (
domain: string,
panels: HomeAssistant["panels"]
): string | undefined =>
Object.values(panels).find((panel) => panel.config_panel_domain === domain)
?.url_path || integrationsWithPanel[domain];
export type IntegrationType =
"device" | "helper" | "hub" | "service" | "hardware" | "entity" | "system";
+4
View File
@@ -104,3 +104,7 @@ export const removeLocalMedia = async (
type: "media_source/local_source/remove",
media_content_id,
});
export const isStreamingMedia = (url: string): boolean =>
url.includes("/api/image_proxy_stream") ||
url.includes("/api/camera_proxy_stream");
+4 -1
View File
@@ -15,7 +15,10 @@ export interface MQTTTopicDebugInfo {
export interface MQTTDiscoveryDebugInfo {
topic: string;
payload: string;
// The discovery config, which `mqtt-discovery-payload` renders as YAML/JSON.
// An entity with debug traffic but nothing discovered has no config, and the
// backend sends an empty string for it.
payload: Record<string, unknown> | string;
}
export interface MQTTEntityDebugInfo {
+2 -2
View File
@@ -229,7 +229,7 @@ export interface StopAction extends BaseAction {
}
export interface SequenceAction extends BaseAction {
sequence: (ManualScriptConfig | Action)[];
sequence: (ManualScriptConfig | Action)[] | null;
metadata?: {};
}
@@ -507,7 +507,7 @@ export const migrateAutomationAction = (
if (typeof action === "object" && action !== null && "sequence" in action) {
delete (action as SequenceAction).metadata;
for (const sequenceAction of (action as SequenceAction).sequence) {
for (const sequenceAction of (action as SequenceAction).sequence ?? []) {
migrateAutomationAction(sequenceAction, report);
}
}
+1 -1
View File
@@ -359,7 +359,7 @@ const tryDescribeAction = <T extends ActionType>(
if (actionType === "sequence") {
const config = action as SequenceAction;
const numActions = ensureArray(config.sequence).length;
const numActions = ensureArray(config.sequence ?? []).length;
return hass.localize(
`${actionTranslationBaseKey}.sequence.description.full`,
{ number: numActions }
+8
View File
@@ -68,6 +68,7 @@ export type Selector =
| QRCodeSelector
| SelectSelector
| SelectorSelector
| StateClassSelector
| StateSelector
| StatisticSelector
| StringSelector
@@ -516,6 +517,13 @@ export interface SerialPortSelector {
} | null;
}
export interface StateClassSelector {
state_class: {
multiple?: boolean;
state_classes?: string[];
} | null;
}
export interface StateSelector {
state: {
extra_options?: { label: string; value: any }[];
+2 -2
View File
@@ -4,7 +4,7 @@ export interface ThreadRouter {
instance_name: string;
addresses: [string];
border_agent_id: string | null;
brand: "google" | "apple" | "homeassistant";
brand: string | null;
extended_address: string;
extended_pan_id: string;
model_name: string | null;
@@ -20,7 +20,7 @@ export interface ThreadDataSet {
created: string;
dataset_id: string;
extended_pan_id: string;
network_name: string;
network_name: string | null;
pan_id: string | null;
preferred_border_agent_id: string | null;
preferred_extended_address: string | null;
+3
View File
@@ -2,6 +2,8 @@ import type { TemplateResult } from "lit";
import { fireEvent } from "../../common/dom/fire_event";
interface BaseDialogBoxParams {
/** Whether back closes the dialog. Off while a navigation is in flight. */
addHistory?: boolean;
confirmText?: string;
text?: string | TemplateResult;
title?: string;
@@ -61,6 +63,7 @@ const showDialogHelper = (
fireEvent(element, "show-dialog", {
dialogTag: "dialog-box",
dialogImport: loadGenericDialog,
addHistory: dialogParams.addHistory,
dialogParams: {
...dialogParams,
...extra,
+38 -35
View File
@@ -155,43 +155,46 @@ class HaMoreInfoRelated extends LitElement {
/>`,
});
}
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.labels",
value:
labels.map(({ id, entry }) => entry?.name ?? id).join(", ") ||
this.hass.localize("ui.dialogs.more_info_control.no_labels"),
displayValue: labels.length
? html`<div class="labels">
${labels.map(
({ id, entry }) => html`
<ha-label
class="text-ellipsis"
.color=${entry?.color ?? undefined}
.description=${entry?.description ?? undefined}
>
${
entry?.icon
? html`<ha-icon
slot="icon"
.icon=${entry.icon}
></ha-icon>`
: nothing
}
${entry?.name ?? id}
</ha-label>
`
)}
</div>`
: undefined,
});
if (labels.length) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.labels",
value: labels.map(({ id, entry }) => entry?.name ?? id).join(", "),
displayValue: html`<div class="labels">
${labels.map(
({ id, entry }) => html`
<ha-label
class="text-ellipsis"
.color=${entry?.color ?? undefined}
.description=${entry?.description ?? undefined}
>
${
entry?.icon
? html`<ha-icon slot="icon" .icon=${entry.icon}></ha-icon>`
: nothing
}
${entry?.name ?? id}
</ha-label>
`
)}
</div>`,
});
}
return html`
<div class="content">
<ha-grouped-list
.header=${this.hass.localize("ui.dialogs.more_info_control.context")}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
${
contextEntries.length
? html`
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
`
: nothing
}
<ha-related-items
.hass=${this.hass}
.itemId=${this.params.entityId}
@@ -239,7 +242,7 @@ class HaMoreInfoRelated extends LitElement {
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
ha-related-items {
ha-grouped-list + ha-related-items {
margin-top: var(--ha-space-6);
}
+6
View File
@@ -29,6 +29,10 @@ export class MockBaseEntity {
public areaId?: string;
public deviceId?: string;
public platform?: string;
public baseAttributes: EntityAttributes;
public attributes: EntityAttributes;
@@ -50,6 +54,8 @@ export class MockBaseEntity {
this.objectId = objectId;
this.state = input.state;
this.areaId = input.area_id;
this.deviceId = input.device_id;
this.platform = input.platform;
this.lastChanged = randomTime();
this.lastUpdated = randomTime();
+4
View File
@@ -13,6 +13,10 @@ export type EntityInput = Pick<
> & {
/** Area the entity is assigned to in the mocked entity registry */
area_id?: string;
/** Device the entity belongs to in the mocked entity registry */
device_id?: string;
/** Integration that provides the entity, defaults to "demo" */
platform?: string;
};
/**
+35 -7
View File
@@ -50,6 +50,12 @@ import type { EntityInput } from "./entities/types";
const ensureArray = <T>(val: T | T[]): T[] =>
Array.isArray(val) ? val : [val];
type MockServiceCallback = (
data: Record<string, any> | undefined,
target: Record<string, any> | undefined,
hass: MockHomeAssistant
) => any;
type MockRestCallback = (
hass: MockHomeAssistant,
method: string,
@@ -79,6 +85,8 @@ export interface MockHomeAssistant extends HomeAssistant {
) => Awaited<ReturnType<T>>
);
mockAPI(path: string | RegExp, callback: MockRestCallback);
// Registers a service that answers with data, for `callService(..., true)`.
mockService(domain: string, service: string, callback: MockServiceCallback);
// Register a loader that is run (once) the first time an unmocked WS command
// or REST path matching `shouldLoad` is received, allowing mocks to be
// code-split into a dynamically imported chunk. The loader registers the
@@ -191,6 +199,12 @@ export const provideHass = (
const wsCommands = {};
const restResponses: [string | RegExp, MockRestCallback][] = [];
// Services that answer with data, keyed by "<domain>.<service>". Without one
// registered, a call that asks for a response gets none and callers that read
// it straight back throw.
const serviceMocks: Record<string, MockServiceCallback> = {};
const context = { id: "mock-context", user_id: null, parent_id: null };
// Optional loader to lazily register mocks on first matching unmocked command.
let lazyMatcher: ((commandOrPath: string) => boolean) | undefined;
let lazyLoader: (() => Promise<unknown>) | undefined;
@@ -297,9 +311,10 @@ export const provideHass = (
entity_id: ent.entityId,
name: ent.attributes.friendly_name || undefined,
icon: undefined,
platform: "demo",
platform: ent.platform ?? "demo",
labels: [],
area_id: ent.areaId,
device_id: ent.deviceId,
} satisfies EntityRegistryDisplayEntry;
});
if (replace) {
@@ -451,19 +466,29 @@ export const provideHass = (
kioskMode: false,
suspendWhenHidden: false,
// @ts-ignore
async callService(domain, service, data) {
if (data && "entity_id" in data) {
async callService(domain, service, data, target, _notify, returnResponse) {
const mock = serviceMocks[`${domain}.${service}`];
if (mock) {
const response = await mock(data, target, hass());
return returnResponse ? { context, response } : { context };
}
const entityIds =
data && "entity_id" in data ? data.entity_id : target?.entity_id;
// The service data is optional, but the entities read straight from it.
const serviceData = data ?? {};
if (entityIds) {
// eslint-disable-next-line
console.log("Entity service call", domain, service, data);
console.log("Entity service call", domain, service, serviceData);
await Promise.all(
ensureArray(data.entity_id).map((ent) =>
entities[ent].handleService(domain, service, data)
ensureArray(entityIds).map((ent) =>
entities[ent]?.handleService(domain, service, serviceData)
)
);
} else {
// eslint-disable-next-line
console.log("unmocked callService", domain, service, data);
console.log("unmocked callService", domain, service, serviceData);
}
return { context };
},
async callApi(method, path, parameters) {
const findResponse = () =>
@@ -522,6 +547,9 @@ export const provideHass = (
lazyLoader = loader;
},
mockAPI,
mockService(domain, service, callback) {
serviceMocks[`${domain}.${service}`] = callback;
},
mockEvent(event) {
(eventListeners[event] || []).forEach((fn) => {
fn(event);
+171 -41
View File
@@ -9,23 +9,46 @@
* recover this, because the bundle itself failed to load, so this guard has
* to live inline in the HTML document.
*
* On a failed entry load we navigate once to the same URL with a
* cache-busting query param (and, on https, after dropping the service
* worker + its caches) so the browser fetches a fresh index.html that points
* at the current hashes. The param doubles as a one-shot loop guard: if the
* freshly fetched index still fails we stop and show a message instead of
* reloading forever. core.ts strips the param again after a successful boot.
* Only a file the server no longer has means a stale index (see probeGone).
* On a 404 we navigate once to the same URL with a cache-busting query param
* (and, on https, after dropping the service worker + its caches) so the
* browser fetches a fresh index.html that points at the current hashes. One
* reload per minute, tracked in sessionStorage: if it did not help, stop and
* show a message instead of reloading forever.
*
* Never transpiled, so it stays within the [legacy] browserslist floor
* (Chrome 59 / Safari 12): no object spread, Promise.finally, optional
* chaining or AbortController.
*/
(function () {
var BUST_PARAM = "ha_cache_bust";
const BUST_PARAM = "ha_cache_bust";
// Only production entry bundles carry a content hash. Requiring the hash
// keeps the guard inert in development (unhashed core.js / app.js) and
// scoped to the entry chunks whose 404 is fatal to boot - not translations
// or lazily loaded panels, which fail non-fatally and are handled elsewhere.
// Injected from stale-build-patterns.json (the single source shared with
// the bundled util/recover-stale-build.ts) so the two never drift.
var HASHED_ENTRY = new RegExp(<%= JSON.stringify(staleBuildPatterns.hashedEntry) %>, "i");
var MODULE_ERROR = new RegExp(<%= JSON.stringify(staleBuildPatterns.moduleError) %>, "i");
const HASHED_ENTRY = new RegExp(<%= JSON.stringify(staleBuildPatterns.hashedEntry) %>, "i");
const MODULE_ERROR = new RegExp(<%= JSON.stringify(staleBuildPatterns.moduleError) %>, "i");
// The same pattern anchored: a URL is ours only when it *is* an entry path,
// not when it merely contains one (/local/frontend_latest/x.abc12345.js).
const HASHED_PATH = new RegExp("^(?:" + <%= JSON.stringify(staleBuildPatterns.hashedEntry) %> + ")$", "i");
// Any file reference, to tell "a file we do not ship failed" apart from
// "the browser did not say which file failed".
const ANY_URL = new RegExp(<%= JSON.stringify(staleBuildPatterns.anyUrl) %>, "i");
const PROBE_TIMEOUT = 5000;
// Grace period before admitting defeat, so a modulepreload failure that
// the browser retries successfully does not flash the message.
const FATAL_DELAY = 10000;
// This build's own entry bundles, to probe when the browser did not name
// the file that failed (Safari's message names none).
const LATEST_ENTRY_JS = <%= JSON.stringify(latestEntryJS) %>;
const ES5_ENTRY_JS = <%= JSON.stringify(es5EntryJS) %>;
// Shared with the bundled recovery: one reload per minute between them.
const RELOAD_STORAGE_KEY = "haStaleBuildReload";
const RELOAD_COOLDOWN = 60000;
const probing = {};
let recovering = false;
function booted() {
// The launch screen is only removed once the app has taken over the
@@ -36,21 +59,62 @@
}
function showFatal() {
var box = document.getElementById("ha-launch-screen-info-box");
const box = document.getElementById("ha-launch-screen-info-box");
if (box) {
box.textContent =
"Could not load Home Assistant. Please refresh the page, and clear your browser cache if the problem persists.";
}
}
/*
* Claim a recovery reload: one per cooldown window, timed rather than
* counted. The bust param alone is not a
* durable marker: core.ts strips it on every successful connect, so a page
* that boots and then fails on the next load would reload again and again.
* When storage is unavailable we keep the old param-only behaviour rather
* than lose the recovery entirely.
*/
function claimReload() {
const now = Date.now();
let last = 0;
try {
// A missing or unreadable marker reads as 0, i.e. no recent reload; it
// is replaced below rather than handing out a reload on every boot.
last = Number(sessionStorage.getItem(RELOAD_STORAGE_KEY)) || 0;
} catch (_err) {
return true;
}
if (now - last <= RELOAD_COOLDOWN) {
return false;
}
try {
sessionStorage.setItem(RELOAD_STORAGE_KEY, String(now));
} catch (_err) {
// ignore
}
return true;
}
function recover() {
if (recovering) {
// core.js and app.js are confirmed gone moments apart; the first
// verdict is already navigating.
return;
}
if (location.search.indexOf(BUST_PARAM + "=") !== -1) {
// We already reloaded once with a fresh index and it still failed;
// stop to avoid a reload loop.
showFatal();
return;
}
var target =
if (!claimReload()) {
// Reloaded recently (possibly on the previous page load, before the
// param was stripped) and it did not help: stop asking.
showFatal();
return;
}
recovering = true;
const target =
location.pathname +
location.search +
(location.search ? "&" : "?") +
@@ -63,31 +127,21 @@
// stale cached index. Drop the worker and its caches first so the reload
// is served fresh from the network.
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
var go = function () {
const go = () => {
location.replace(target);
};
Promise.all([
navigator.serviceWorker
.getRegistrations()
.then(function (regs) {
return Promise.all(
regs.map(function (r) {
return r.unregister();
})
);
})
.catch(function () {}),
.then((regs) => Promise.all(regs.map((reg) => reg.unregister())))
.catch(() => {}),
self.caches
? caches
.keys()
.then(function (keys) {
return Promise.all(
keys.map(function (k) {
return caches.delete(k);
})
);
})
.catch(function () {})
.then((keys) =>
Promise.all(keys.map((key) => caches.delete(key)))
)
.catch(() => {})
: null,
]).then(go, go);
} else {
@@ -95,26 +149,102 @@
}
}
function maybeRecover(url, message) {
if (booted()) {
/*
* The file a failure is about: its absolute URL when this build ships it,
* "foreign" for anything else (a dashboard resource, an extra_module_url, a
* custom panel - those say nothing about the index being stale, and
* reloading over them never ends), or null when no file was named.
*/
function failedFile(text) {
const match = ANY_URL.exec(text);
if (!match) {
return null;
}
try {
const url = new URL(match[0], location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
} catch (_err) {
// Not a URL after all.
}
return "foreign";
}
function referenceUrl() {
const entries = window.latestJS ? LATEST_ENTRY_JS : ES5_ENTRY_JS;
for (const entry of entries) {
const url = new URL(entry, location.href);
if (url.origin === location.origin && HASHED_PATH.test(url.pathname)) {
return url.href;
}
}
return null;
}
/*
* A failed load looks identical whether the file was deleted by an upgrade
* or the request never reached the server, and only the first is fixed by
* reloading. HEAD is never intercepted by the service worker (workbox
* routes GET only), so the answer comes from the server itself. A server
* that refuses HEAD outright leaves us with no answer, and no answer means
* no recovery: a GET cannot substitute, because the frontend_latest route
* is CacheFirst with `ignoreSearch`, so a controlling worker can answer it
* from its own cache.
*/
function probeGone(url) {
// Status 0 stands for "no usable answer": unreachable, or too slow.
return Promise.race([
fetch(url, { method: "HEAD", cache: "no-store" }).then(
(response) => response.status,
() => 0
),
new Promise((resolve) => {
setTimeout(() => resolve(0), PROBE_TIMEOUT);
}),
]).then((status) => status === 404 || status === 410);
}
function maybeRecover(text) {
if (booted() || (!HASHED_ENTRY.test(text) && !MODULE_ERROR.test(text))) {
return;
}
if (
(url && HASHED_ENTRY.test(url)) ||
(message && (HASHED_ENTRY.test(message) || MODULE_ERROR.test(message)))
) {
recover();
const failed = failedFile(text);
if (failed === "foreign") {
return;
}
// Probe per URL: core.js and app.js fail as separate events, and one of
// them being served says nothing about the other.
const target = failed || referenceUrl();
if (!target || probing[target]) {
return;
}
probing[target] = true;
probeGone(target).then((gone) => {
delete probing[target];
if (gone) {
recover();
return;
}
// Nothing was deleted, so a reload cannot help - but the file that
// failed is not coming back either. Say so if the app never takes over,
// rather than leaving an empty launch screen up for good.
setTimeout(() => {
if (!booted() && !recovering) {
showFatal();
}
}, FATAL_DELAY);
});
}
// Resource-load errors (<script>, modulepreload <link>) do not bubble, so
// they are only observable in the capture phase at the window.
window.addEventListener(
"error",
function (ev) {
var el = ev.target;
(ev) => {
const el = ev.target;
if (el && (el.tagName === "SCRIPT" || el.tagName === "LINK")) {
maybeRecover(el.href || el.src, null);
maybeRecover(el.href || el.src || "");
}
},
true
@@ -122,9 +252,9 @@
// A failed dynamic import() rejects; the inline entry imports do not catch
// it, so the rejection surfaces here.
window.addEventListener("unhandledrejection", function (ev) {
var reason = ev && ev.reason;
maybeRecover(null, reason && (reason.message || String(reason)));
window.addEventListener("unhandledrejection", (ev) => {
const reason = ev && ev.reason;
maybeRecover((reason && (reason.message || String(reason))) || "");
});
})();
</script>
+4 -1
View File
@@ -155,7 +155,10 @@
<% if (obj.hassUrl === "") { %>
<script>
{%- for extra_module in extra_modules -%}
import("{{ extra_module }}");
// Caught, or the boot recovery guard reads it as a stale build.
import("{{ extra_module }}").catch(function (err) {
console.error("Failed to load extra module {{ extra_module }}", err);
});
{%- endfor -%}
</script>
<% } %>
+6 -2
View File
@@ -202,7 +202,7 @@ export class HassRouterPage extends ReactiveElement {
// the app stayed open) is recoverable: reload onto the current build
// (or prompt when there are unsaved edits) instead of dead-ending.
const message = err instanceof Error ? err.message : String(err ?? "");
const stale = recoverFromStaleBuild(message, this);
const recovery = recoverFromStaleBuild(message, this);
// Show error screen, offering a reload action for a stale build. Set
// `showReload` on the returned element rather than through
@@ -211,8 +211,12 @@ export class HassRouterPage extends ReactiveElement {
const errorScreen = this.createErrorScreen(
`Error while loading page ${newPage}.`
);
errorScreen.showReload = stale;
this.appendChild(errorScreen);
// That action drops the caches, so only offer it once the probe has
// confirmed the chunk is really gone.
void Promise.resolve(recovery).then((stale) => {
errorScreen.showReload = stale;
});
});
// If we don't show loading screen, just show the panel.
+5 -3
View File
@@ -4,7 +4,7 @@ import { html } from "lit";
import { customElement, state } from "lit/decorators";
import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import { handleHistoryPop, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
import { extractSearchParamsObject } from "../common/url/search-params";
@@ -180,8 +180,10 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
window.addEventListener("location-changed", () => updateRoute());
// Handle history changes
window.addEventListener("popstate", () => updateRoute());
// A pop away from a page with unsaved changes is held until the user agrees.
window.addEventListener("popstate", () =>
handleHistoryPop(() => updateRoute())
);
// Restore a more-info dialog deep-linked in the current URL, if any.
window.addEventListener("location-changed", () =>
@@ -1462,29 +1462,34 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
return;
}
let removeData = false;
const _removeDataToggled = (e: Event) => {
removeData = (e.target as HaSwitch).checked;
let removeConfig = false;
const _removeConfigToggled = (e: Event) => {
removeConfig = (e.target as HaSwitch).checked;
};
const confirmed = await showConfirmationDialog(this, {
title: this.i18n.localize(
"ui.panel.config.apps.dashboard.uninstall_dialog.title",
{
name: getAppDisplayName(addon.name, addon.stage),
}
"ui.panel.config.apps.dashboard.uninstall_dialog.title"
),
text: html`
<p>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.uninstall_dialog.text",
{
name: getAppDisplayName(addon.name, addon.stage),
}
)}
</p>
<ha-formfield
.label=${html`<p>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.uninstall_dialog.remove_data"
"ui.panel.config.apps.dashboard.uninstall_dialog.remove_config"
)}
</p>`}
>
<ha-switch
@change=${_removeDataToggled}
.checked=${removeData}
@change=${_removeConfigToggled}
.checked=${removeConfig}
haptic
></ha-switch>
</ha-formfield>
@@ -1503,7 +1508,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
this._uninstalling = true;
this._error = undefined;
try {
await uninstallHassioAddon(this.api.callWS, addon.slug, removeData);
await uninstallHassioAddon(this.api.callWS, addon.slug, removeConfig);
const eventdata = {
success: true,
response: undefined,
@@ -35,7 +35,7 @@ export class HaSequenceAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
.actions=${action.sequence}
.actions=${action.sequence ?? []}
.narrow=${this.narrow}
.disabled=${this.disabled}
@value-changed=${this._actionsChanged}
@@ -15,6 +15,7 @@ import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../common/array/ensure-array";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { mainWindow } from "../../../common/dom/get_main_window";
import { computeAreaName } from "../../../common/entity/compute_area_name";
@@ -2166,8 +2167,8 @@ class DialogAddAutomationElement
}
}
private _handleFilterInput = (ev: InputEvent) => {
this._debounceFilterChanged((ev.target as HaInputSearch).value ?? "");
private _handleFilterInput = (ev: HASSDomTargetEvent<HaInputSearch>) => {
this._debounceFilterChanged(ev.target.value ?? "");
};
private _debounceFilterChanged = debounce((value: string) => {
@@ -7,6 +7,8 @@ export const loadAutomationSaveDialog = () =>
import("./dialog-automation-save");
interface BaseRenameDialogParams {
/** Whether back closes the dialog. Off while a navigation is in flight. */
addHistory?: boolean;
entityRegistryUpdate?: EntityRegistryUpdate;
entityRegistryEntry?: EntityRegistryEntry;
onClose: () => void;
@@ -52,5 +54,6 @@ export const showAutomationSaveDialog = (
dialogTag: "ha-dialog-automation-save",
dialogImport: loadAutomationSaveDialog,
dialogParams,
addHistory: dialogParams.addHistory,
});
};
@@ -34,6 +34,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
protected render() {
return html`
<slot name="alerts"></slot>
${
this.stateObj?.state === "off"
? html`
@@ -10,6 +10,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import { stringCompare } from "../../../common/string/compare";
@@ -304,8 +305,8 @@ class DialogNewAutomation extends LitElement {
`;
}
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
}
private async _blueprint(ev) {
@@ -478,7 +478,18 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.saving=${this.saving}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
></blueprint-automation-editor>
>
${
this.errors
? html`<ha-alert
alert-type="error"
slot="alerts"
>
${this.errors}
</ha-alert>`
: nothing
}
</blueprint-automation-editor>
`
: html`
<manual-automation-editor
@@ -869,13 +880,14 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this.errors = undefined;
}
protected async confirmUnsavedChanged(): Promise<boolean> {
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
if (!this.isDirtyState) {
return true;
}
return new Promise<boolean>((resolve) => {
showAutomationSaveDialog(this, {
addHistory,
config: this.config!,
domain: "automation",
updateConfig: async (config, entityRegistryUpdate) => {
@@ -261,15 +261,16 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
};
protected async promptDiscardChanges() {
return this.confirmUnsavedChanged();
return this.confirmUnsavedChanged(false);
}
/**
* Asks whether unsaved changes should be discarded.
* Subclasses must override this to show a confirmation dialog.
* @param addHistory false while a navigation is already in flight.
* @returns true to proceed (discard/save changes), false to cancel.
*/
protected confirmUnsavedChanged(): Promise<boolean> {
protected confirmUnsavedChanged(_addHistory = true): Promise<boolean> {
return Promise.resolve(true);
}
+2 -2
View File
@@ -540,8 +540,8 @@ export const configSections: Record<string, PageNavigation[]> = {
adminOnly: true,
},
{
path: "/config/ai-tasks",
translationKey: "ai_tasks",
path: "/config/ai",
translationKey: "ai",
iconPath: mdiStarFourPoints,
iconColor: "#8B69E3",
core: true,
@@ -5,8 +5,8 @@ import "./ai-task-pref";
import "./mcp-pref";
import type { HomeAssistant } from "../../../types";
@customElement("ha-config-section-ai-tasks")
class HaConfigSectionAITasks extends LitElement {
@customElement("ha-config-section-ai")
class HaConfigSectionAI extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@@ -17,7 +17,7 @@ class HaConfigSectionAITasks extends LitElement {
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.ai_tasks.caption")}
.header=${this.hass.localize("ui.panel.config.ai.caption")}
>
<div class="content">
<ai-task-pref
@@ -50,6 +50,6 @@ class HaConfigSectionAITasks extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-ai-tasks": HaConfigSectionAITasks;
"ha-config-section-ai": HaConfigSectionAI;
}
}
@@ -4,6 +4,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import type { WindowWithPreloads } from "../../../data/preloads";
import type {
@@ -268,8 +269,8 @@ class DialogNewDashboard extends LitElement implements HassDialog {
`;
}
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
}
private _filterStrategies = memoizeOne(
@@ -49,6 +49,10 @@ import "../repairs/ha-config-repairs";
import "./ha-config-navigation";
import "./ha-config-updates";
type DashboardSummary<Key extends string, Item> = Record<Key, Item[]> & {
total: number;
};
const randomTip = (openFn: any, hass: HomeAssistant, narrow: boolean) => {
const weighted: string[] = [];
let tips = [
@@ -153,7 +157,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
@state() private _tip?: string;
@state() private _repairsIssues: { issues: RepairsIssue[]; total: number } = {
@state() private _repairsIssues: DashboardSummary<"issues", RepairsIssue> = {
issues: [],
total: 0,
};
@@ -383,7 +387,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
(
entities: HomeAssistant["states"],
entityRegistry: HomeAssistant["entities"]
): { updates: UpdateEntity[]; total: number } => {
): DashboardSummary<"updates", UpdateEntity> => {
const updates = filterUpdateEntitiesParameterized(
entities,
false,
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
@@ -11,6 +12,7 @@ import "../../../components/entity/state-badge";
import "../../../components/ha-alert";
import "../../../components/ha-icon-next";
import "../../../components/ha-spinner";
import type { HaListItemButton } from "../../../components/item/ha-list-item-button";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import "../../../components/progress/ha-progress-ring";
@@ -152,9 +154,13 @@ class HaConfigUpdates extends LitElement {
`;
}
private _openMoreInfo(ev: MouseEvent): void {
private _openMoreInfo(
ev: HASSDomCurrentTargetEvent<
HaListItemButton & { entity_id: UpdateEntity["entity_id"] }
>
): void {
fireEvent(this, "hass-more-info", {
entityId: (ev.currentTarget as any).entity_id,
entityId: ev.currentTarget.entity_id,
});
}
@@ -5,6 +5,7 @@ import {
mdiChatQuestion,
mdiExportVariant,
} from "@mdi/js";
import { isComponentLoaded } from "../../../../../../common/config/is_component_loaded";
import { navigate } from "../../../../../../common/navigate";
import type { DeviceRegistryEntry } from "../../../../../../data/device/device_registry";
import {
@@ -91,7 +92,10 @@ export const getMatterDeviceActions = async (
});
}
if (nodeDiagnostics.network_type === NetworkType.THREAD) {
if (
nodeDiagnostics.network_type === NetworkType.THREAD &&
isComponentLoaded(hass.config, "thread")
) {
actions.push({
label: hass.localize(
"ui.panel.config.matter.device_actions.view_thread_network"
@@ -6,7 +6,8 @@ import { classMap } from "lit/directives/class-map";
@customElement("mqtt-discovery-payload")
class MQTTDiscoveryPayload extends LitElement {
@property({ attribute: false }) public payload!: Record<string, unknown>;
@property({ attribute: false }) public payload!:
Record<string, unknown> | string;
@property({ attribute: "show-as-yaml", type: Boolean })
public showAsYaml = false;
+3 -3
View File
@@ -183,9 +183,9 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-labs",
load: () => import("./labs/ha-config-labs"),
},
"ai-tasks": {
tag: "ha-config-section-ai-tasks",
load: () => import("./core/ha-config-section-ai-tasks"),
ai: {
tag: "ha-config-section-ai",
load: () => import("./core/ha-config-section-ai"),
},
"entity-id-format": {
tag: "ha-config-section-entity-id-format",
@@ -5,6 +5,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { stringCompare } from "../../../common/string/compare";
@@ -368,9 +369,8 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
}
);
private async _filterChanged(e: InputEvent) {
const target = e.target as HaInputSearch;
this._filter = target.value;
private async _filterChanged(e: HASSDomTargetEvent<HaInputSearch>) {
this._filter = e.target.value;
}
private _valueChanged(ev: CustomEvent): void {
@@ -7,6 +7,10 @@ import { customElement, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import {
PROTOCOL_INTEGRATIONS,
@@ -661,16 +665,17 @@ class AddIntegrationDialog extends LitElement {
);
}
private async _filterChanged(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
private async _filterChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
}
private _integrationPicked = (ev: Event) => {
const listItem = ev.currentTarget as HaIntegrationListItem;
if (!listItem?.integration) {
private _integrationPicked = (
ev: HASSDomCurrentTargetEvent<HaIntegrationListItem>
) => {
if (!ev.currentTarget.integration) {
return;
}
this._handleIntegrationPicked(listItem.integration);
this._handleIntegrationPicked(ev.currentTarget.integration);
};
private async _handleIntegrationPicked(integration: IntegrationListItem) {
@@ -51,7 +51,7 @@ import type { IntegrationManifest } from "../../../data/integration";
import {
domainToName,
fetchIntegrationManifest,
integrationsWithPanel,
getConfigPanelPath,
} from "../../../data/integration";
import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
@@ -530,12 +530,7 @@ export class HaConfigEntryRow extends LitElement {
</ha-md-list>`;
}
private _configPanel = memoizeOne(
(domain: string, panels: HomeAssistant["panels"]): string | undefined =>
Object.values(panels).find(
(panel) => panel.config_panel_domain === domain
)?.url_path || integrationsWithPanel[domain]
);
private _configPanel = memoizeOne(getConfigPanelPath);
private _getEntities = (): EntityRegistryEntry[] =>
this.entities.filter(
@@ -18,6 +18,7 @@ import { customElement, property, queryAll, state } from "lit/decorators";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import {
PROTOCOL_INTEGRATIONS,
@@ -1231,8 +1232,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this._filterTree(data, filter, areas)
);
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
}
private async _handleEnableDebugLogging() {
@@ -10,6 +10,7 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { deepActiveElement } from "../../../common/dom/deep-active-element";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
@@ -884,8 +885,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
this._showDisabled = !this._showDisabled;
}
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
updateHistoryState({ filter: this._filter });
}
@@ -8,6 +8,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import type { HASSDomTargetEvent } from "../../../../../common/dom/fire_event";
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import { throttle } from "../../../../../common/util/throttle";
@@ -174,8 +175,8 @@ export class BluetoothNetworkVisualization extends LitElement {
return attributes;
};
private _handleSearchChange(ev: InputEvent): void {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>): void {
this._searchFilter = ev.target.value ?? "";
}
private _getRssiColorVar = memoizeOne((rssi: number): string => {
@@ -21,6 +21,7 @@ import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import { fetchMatterNetworkTopology } from "../../../../../data/matter";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@@ -37,6 +38,10 @@ export class MatterConfigDashboard extends LitElement {
@state() private _configEntry?: ConfigEntry;
@state() private _offlineDevices = 0;
@state() private _asyncDataLoaded = false;
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
if (this.hass) {
@@ -119,6 +124,17 @@ export class MatterConfigDashboard extends LitElement {
count: deviceCount,
})}
</small>
<small class="offline">
${
this._asyncDataLoaded && this._offlineDevices > 0
? html`(${this.hass.localize(
"ui.panel.config.matter.panel.devices_offline",
{ count: this._offlineDevices }
)})`
: nothing
}
</small>
</div>
<img
class="logo"
@@ -237,6 +253,30 @@ export class MatterConfigDashboard extends LitElement {
this._configEntry = configEntries.find(
(entry) => entry.disabled_by === null && entry.source !== "ignore"
);
if (!this._configEntry) {
return;
}
const deviceIds = this._matterDeviceIds(
this.hass.devices,
this._configEntry.entry_id
);
try {
const topology = await fetchMatterNetworkTopology(this.hass);
this._offlineDevices = topology.nodes.filter(
(node) =>
node.ha_device_id &&
deviceIds.has(node.ha_device_id) &&
node.available === false
).length;
} catch {
this._offlineDevices = 0;
} finally {
this._asyncDataLoaded = true;
}
}
static get styles(): CSSResultGroup {
@@ -340,6 +380,10 @@ export class MatterConfigDashboard extends LitElement {
color: var(--secondary-text-color);
}
.network-status small.offline {
animation: fade-in var(--ha-animation-duration-slow) ease-in;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
@@ -9,6 +9,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import type { HASSDomTargetEvent } from "../../../../../common/dom/fire_event";
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
@@ -189,8 +190,8 @@ export class MatterNetworkVisualization extends LitElement {
></ha-input-search>`;
}
private _handleSearchChange(ev: InputEvent): void {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>): void {
this._searchFilter = ev.target.value ?? "";
}
private async _refreshTopology(): Promise<void> {

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