Compare commits

...
Author SHA1 Message Date
Daan VervackeandCopilot Autofix powered by AI 1801441779 Add selection mode with bulk delete to the labels page (#54987)
* Add selection mode with bulk remove to the labels page

* Seed demo labels and align the registry stub with tags

* Align the label overflow menu with Remove wording

* Trigger event on label removal

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

* Use Delete wording for label destruction

* Clear deleted labels from entity assignments in the demo

* Clear deleted labels from retired entity entries in the demo

* Copy the seed array in the label registry stub

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-18 19:49:12 +00:00
renovate[bot] 2cdb57b00f Update vitest monorepo to v5.0.5 (#55033)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 15:18:33 +00:00
Aidan Timson a6fe937d45 Add visual editor for the alert card and add clear color (#54880)
* Stop alert card pulse when reduced motion is on

* Add visual editor for the alert card

* Suggest the alert card when adding a card by entity

* Share the alert active states with the alert card

* Only alert while the alert card entity is in an alert state

* Show the alert card colors side by side

* Default the alert card color to red

* Only offer alert entities in the security alerts picker

* Rename the alert card colors to alert color and clear color
2026-10-18 17:10:30 +02:00
ChrisandClaude Opus 5.5 ffb78bd21f Show the local apps folder toggle in backup settings (#54983)
* Show the local apps folder toggle in backup settings

Local apps have the slug local_<slug>, never local, so the toggle for
the addons/local folder was never shown.

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

* Tests for this change

* Remove rendering tests for the local apps toggle

The frontend testing guidelines don't allow rendering tests.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 14:49:19 +00:00
Petar Petrov bb436667d5 Set up Music Assistant through its config flow in the ESPHome wizard (#54973)
* Set up Music Assistant through its config flow in the ESPHome wizard

The Music Assistant config flow now installs, starts and onboards the app
itself, so open it instead of installing the app and waiting for its
discovery flow.

* Don't open the Music Assistant flow after the offer was skipped
2026-10-18 16:44:24 +02:00
Daan Vervacke aca2ba2745 Add selection mode with bulk delete to the blueprints page (#54984)
* Add selection mode with bulk delete to the blueprints page

* Tidy bulk delete state handling and add demo delete stub

* Distinguish failed usage checks from in-use blueprints

* Let fresh usage checks decide which blueprints can be deleted
2026-10-18 16:41:44 +02:00
Daan VervackeandCopilot Autofix powered by AI 807b037650 Add selection mode with bulk uninstall to the Marketplace installed tab (#54996)
* Add selection mode with bulk uninstall to the Marketplace installed tab

* Refactor mock imports in dashboard test

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-18 16:35:12 +02:00
Daan Vervacke 277ae199e5 Add selection mode with bulk delete to the dashboards page (#54993) 2026-10-18 16:30:02 +02:00
renovate[bot] e0aece68aa Update pnpm to v12.14.0 (#55029)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 16:24:04 +02:00
Petar Petrov d6449e5707 Fix chart legend squeezed when legend items arrive after setup (#55019)
Resize chart when its legend item count changes
2026-10-18 16:06:12 +02:00
Petar Petrov 54e123c469 Fix Sankey label sizes after zooming (#55023)
The label layout callback gets the node rect in zoomed screen pixels,
so only the slack around the node scales with the zoom. Report the new
zoom before the setOption calls and actions that lay the labels out
again, so a reset or double-click doesn't size them for the old zoom.
2026-10-18 15:35:41 +02:00
Petar Petrov 54f674fd47 Fix Sankey labels on thin nodes showing at full size in Safari (#55020) 2026-10-18 15:34:06 +02:00
renovate[bot] 8a3e7e8e62 Update dependency jsdom to v30.1.3 (#55028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 15:28:43 +02:00
karwosts b8dbf2e419 Improve climate control accessibility (#55024) 2026-10-18 15:28:25 +02:00
Petar Petrov a23759bf63 Handle null cast device capabilities (#55025)
@types/chromecast-caf-receiver 6.0.27 types getDeviceCapabilities() as
nullable, since it returns null until the cast system is ready. This
broke tsc on dev.
2026-10-18 15:10:45 +02:00
karwosts 6f2f62a602 Localize control-number-button aria label (#55026)
* Localize control-number-button aria label

* keep increment

* fix test
2026-10-18 15:10:05 +02:00
renovate[bot] dd1b392b1f Update dependency @types/chromecast-caf-receiver to v6.0.27 (#55015)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 10:20:33 +02:00
Elias LecomteandSeoFood b399dac788 Use server timezone for demo statistics calendar buckets (#54893)
* Use server timezone for demo statistics calendar buckets

* Preserve server timezone in derived energy statistic boundaries

* Clamp demo production day boundaries to the requested end

* Use supported array indexing in demo statistics test

* Align demo sub-day statistics with the UTC bucket grid

* Use server timezone for demo statistics calendar buckets

* Preserve server timezone in derived energy statistic boundaries

* Clamp demo production day boundaries to the requested end

* Use supported array indexing in demo statistics test

---------

Co-authored-by: SeoFood <[email protected]>
2026-10-18 08:30:10 +03:00
Paulus Schoutsen b0f498b5d3 Add LoRaWAN connectivity panel (#54998)
* Add LoRaWAN device overview and connection details

* Add LoRaWAN overview, connections, and topology views

* Match Bluetooth connection list navigation

* Simplify LoRaWAN add-device dialog navigation

* Group LoRaWAN devices by connection and simplify mobile rows

* Fix LoRa icon wave paths

* Use floating Add device action on LoRaWAN overview

* Widen LoRaWAN connection picker and simplify demo connections

* Use public console destinations in LoRaWAN demo

* Show two TTN gateways and one ChirpStack gateway in demo

* Simplify LoRaWAN connection picker heading

* Keep LoRaWAN Add device dialog compact on mobile

* Simplify LoRaWAN device details and widen unsupported-device alert

* Use the brand server for LoRaWAN demo icons

* Revert demo loading screen layout change

* Move LoRaWAN topology helper into the panel

* Select LoRaWAN device in subscription callback

* Only show LoRaWAN info for matching device integrations

* Name LoRaWAN pages by their purpose

* Identify LoRaWAN integrations by their dependency

* Fix LoRaWAN table sorting and column updates

* Refresh LoRaWAN details when navigating between devices

* Fix LoRaWAN device search, grouping, and demo entity IDs

* Treat listed LoRaWAN connections as active

* Use sensecap_lorawan domain in LoRaWAN demo
2026-10-17 14:46:23 -04:00
renovate[bot] 2d71337e12 Update Playwright (#55009)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 19:51:54 +02:00
renovate[bot] c7064d6dd3 Update dependency @html-eslint/eslint-plugin to v0.67.0 (#55010)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 19:41:24 +02:00
Raman GuptaandClaude Opus 5.5 7daae8a8cb Fix Z-Wave config parameter status always showing as error (#55002)
The zwave_js/set_config_parameter and set_raw_config_parameter commands
return the zwave-js-server CommandStatus string ("accepted" or
"queued"), not the numeric zwave-js SetValueStatus. #54287 retyped the
status as a number and mapped it through computeSetConfigParamStatus,
which turned every successful update into "error".

Type the status as the string union the backend sends and use it
directly again. The raw variant only returns status, so give it its own
result type without value_id.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 19:55:27 +03:00
renovate[bot] cf2e49aaad Update Node.js to v24.22.0 (#55001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 07:34:23 +02:00
Daan Vervacke 545f518a02 Stop numeric data table headers from jumping on hover (#54999)
* Stop numeric data table headers from jumping on hover

* Match the hover pseudo class order in the numeric header rules
2026-10-16 17:12:45 -04:00
Franck Nijhof 9e98e6cdb9 Add Debug console to the Tools panel (#54937) 2026-10-16 19:32:37 +02:00
renovate[bot] dba7a5f238 Update dependency eslint-plugin-lit-a11y to v5.1.3 (#54997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 17:00:17 +00:00
renovate[bot] b453b05e6a Update dependency @rspack/core to v2.2.9 (#54982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 18:55:52 +02:00
Franck Nijhof 73d41a7c6a Update links to the new contributors website (#54995) 2026-10-16 18:55:08 +02:00
renovate[bot] 26a796e308 Update pnpm to v12.13.0 (#54992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 18:53:41 +02:00
renovate[bot] 52a12f08ce Update dependency @versatiles/style to v6.4.1 (#54994) 2026-10-16 17:26:23 +02:00
karwosts a83511ec2f Fix tabs toolbar-icon slot width (#54991) 2026-10-16 06:43:02 -07:00
Øyvind Matheson Wergeland 03447fb4a1 Use separate translation keys for device and service info headings (#54981)
* Use separate translation keys for device and service info headings

The "{type} info" heading cannot be translated correctly in languages
that form compound words.

* Use new translation key for device info heading
2026-10-16 05:42:05 -07:00
JochenandClaude Opus 5.5 f2210e9081 Fix energy setup wizard skipping the individual devices step (#54977)
The Next button condition was not updated when water was added to the
wizard, so the water step showed Done and the individual devices step
was unreachable. Derive all step checks from one step count.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 09:24:07 +03:00
G JohanssonandClaude Opus 5.5 0fdbac3703 Show translated overview labels in ObjectSelector (#54864)
* Show translated overview labels in ObjectSelector

Use the optional `<translation_key>.fields.<field>.overview_label`
translation, with a `{value}` placeholder, to render field values in the
object selector overview. Falls back to the plain value when no
translation exists. Placeholder values are forwarded in config, options
and subentry flows.

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

* Forward placeholder values in repair flow selectors

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

* Forward placeholder values in action, trigger and condition selectors

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 08:17:49 +03:00
Bascht74 23d622958d Show the back button when the statistics graph card opens the history (#54976) 2026-10-16 08:10:50 +03:00
Ruslan Sayfutdinov d7e9877c4e Only list the backup's own locations in the upload progress (#54970)
* Only list the backup's own locations in the upload progress

* Show a spinner until a location reports upload progress
2026-10-16 06:43:23 +02:00
renovate[bot] 5f13af579d Update dependency typescript-eslint to v8.71.2 (#54979)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 06:39:43 +02:00
Aidan Timson a83d32f7c9 Explain blocked updates in the update dialog during a backup (#54955)
* Explain blocked updates in the update dialog during a backup

* Pause the updates page group buttons during a backup

* Only pause Supervisor updates during a backup

* Show the backup alert for System-only updates
2026-10-15 18:41:51 +02:00
Petar Petrov 7009352285 Show the punctuation warning when opening a conversation trigger (#54950)
Validate conversation trigger sentences on load
2026-10-15 15:58:48 +01:00
renovate[bot] 1c6135efc3 Update dependency marked to v18.1.1 (#54967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-15 14:32:48 +00:00
renovate[bot] 9b9900ef05 Update dependency eslint-plugin-lit-a11y to v5.1.2 (#54966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-15 14:31:14 +00:00
Ruslan Sayfutdinov e401e69d09 Show upload progress in the backup progress bar (#54956)
* Show upload progress in the backup progress bar

* Leave finished locations out of the upload progress fill

* Narrow upload percentages to numbers
2026-10-15 15:30:59 +01:00
Aidan TimsonandClaude Opus 5.5 7c74f45e5e Explain when HTTPS or the microphone blocks a feature (#54948)
* Fix Assist microphone not supported message showing [object Object]

The not supported microphone message was built as a Lit TemplateResult,
but chat messages are rendered through ha-markdown which expects a
string, so it was displayed as "[object Object]". Build the message as
markdown instead and narrow the message text type to string.

Fixes #29769

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

* Load Assist voice responses from Home Assistant's URL

* Explain when HTTPS or the microphone blocks a feature

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 14:42:26 +02:00
Ruslan Sayfutdinov 05848f0df4 Add gap between backup upload percentage and spinner (#54951) 2026-10-15 14:35:57 +03:00
Marcin BauerandClaude Opus 5.5 bfa14296d7 Show a toast instead of a dialog after reloading an integration entry (#54954)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 14:28:36 +03:00
renovate[bot] ba3722b983 Update vitest monorepo to v5.0.4 (#54953)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-15 14:27:46 +03:00
129 changed files with 7570 additions and 1035 deletions

No files matched your search

+1 -1
View File
@@ -58,7 +58,7 @@ Readiness and invalidation:
Repository-owned contracts:
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
- Prefer canonical repository helpers and test setup over duplicate local implementations.
+1 -1
View File
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
@@ -192,8 +192,8 @@ export default async function checkPullRequestStandards({
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
`and see the [contributor docs](https://contributors.home-assistant.io/start/pull-requests/#fill-out-the-template) ` +
`for more on creating a great pull request.`;
if (existing) {
await withRetry("comment update", () =>
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
defaults:
run:
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.22.0
+1 -1
View File
@@ -41,5 +41,5 @@ Contributions that do not follow this policy will be closed. Repeated violations
---
The canonical version of this policy is published at
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
the published version applies.
+2 -2
View File
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- [View demo of Home Assistant](https://demo.home-assistant.io/)
- [More information about Home Assistant](https://home-assistant.io)
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
## Development
- Initial setup: `script/setup`
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
- Production build: `pnpm build`
- Gallery: `cd gallery && script/develop_gallery`
+1 -1
View File
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
export const castDemoLovelace: () => LovelaceConfig = () => {
const touchSupported =
castContext.getDeviceCapabilities().touch_input_supported;
castContext.getDeviceCapabilities()?.touch_input_supported;
return {
views: [
{
+2 -2
View File
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
let controls: HTMLElement | null;
const setTouchControlsVisibility = (visible: boolean) => {
if (!castContext.getDeviceCapabilities().touch_input_supported) {
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
return;
}
controls =
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
clearTimeout(timeOut);
timeOut = undefined;
}
if (castContext.getDeviceCapabilities().touch_input_supported) {
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
}
};
+68 -2
View File
@@ -142,7 +142,45 @@ export class HaDemo extends HomeAssistantAppEl {
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
);
mockLovelace(hass, localizePromise);
mockLovelace(hass, localizePromise, [
{
id: "music",
url_path: "music",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:music",
title: "Music",
mode: "storage",
},
{
id: "cameras",
url_path: "cameras",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:cctv",
title: "Cameras",
mode: "storage",
},
{
id: "garden",
url_path: "garden",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:flower",
title: "Garden",
mode: "storage",
},
{
id: "lovelace-advanced",
url_path: "lovelace-advanced",
require_admin: false,
show_in_sidebar: false,
icon: "mdi:cog",
title: "Advanced",
mode: "yaml",
filename: "lovelace-advanced.yaml",
},
]);
mockAuth(hass);
mockTranslations(hass, localizePromise);
mockHistory(hass);
@@ -172,7 +210,35 @@ export class HaDemo extends HomeAssistantAppEl {
mockAreaRegistry(hass);
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockLabelRegistry(hass, [
{
label_id: "kitchen",
name: "Kitchen",
icon: null,
color: "red",
description: "Everything in the kitchen",
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "garden",
name: "Garden",
icon: "mdi:flower",
color: "green",
description: null,
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "battery-powered",
name: "Battery powered",
icon: null,
color: "orange",
description: "Runs on batteries",
created_at: 1735689600,
modified_at: 1735689600,
},
]);
mockUsagePrediction(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, [
+8
View File
@@ -42,4 +42,12 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
msg.domain === "script" ? scriptBlueprints : automationBlueprints
);
hass.mockWS(
"blueprint/delete",
(msg: { domain: BlueprintDomain; path: string }) => {
const blueprints =
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
delete blueprints[msg.path];
}
);
};
+2
View File
@@ -1,3 +1,4 @@
import { lorawanFixtures } from "./lorawan/fixtures";
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
@@ -15,6 +16,7 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
// data to mock. Each owns its own fixtures, so they can be added and removed
// one at a time.
const INTEGRATIONS: ConnectivityFixtures[] = [
lorawanFixtures,
bluetoothFixtures,
serialFixtures,
modbusFixtures,
+2
View File
@@ -1,3 +1,4 @@
import { mockLoRaWAN } from "./lorawan/mock";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
@@ -11,6 +12,7 @@ import { mockZwaveJs } from "./zwave_js/mock";
// The WebSocket mocks, code-split into the config panel chunk.
const MOCKS = [
mockLoRaWAN,
mockBluetooth,
mockSerial,
mockModbus,
@@ -0,0 +1,135 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const CHIRPSTACK_ENTRY = "mock-lorawan-chirpstack";
export const TTN_ENTRY = "mock-lorawan-ttn";
export const SENSECAP_ENTRY = "mock-lorawan-sensecap";
const REGISTRY_ENTRIES = [
registryEntry(
"sensor.lorawan_greenhouse_temperature",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Temperature"
),
registryEntry(
"sensor.lorawan_greenhouse_humidity",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Humidity"
),
registryEntry(
"sensor.lorawan_study_carbon_dioxide",
"lorawan-study",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Carbon dioxide"
),
];
export const lorawanFixtures: ConnectivityFixtures = {
components: [
"lorawan",
"sensecap_lorawan",
"chirpstack",
"thethingsnetwork_lorawan",
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
}),
],
configEntries: [
{
type: "hub",
entry: configEntry(CHIRPSTACK_ENTRY, "chirpstack", "Home ChirpStack"),
},
{
type: "hub",
entry: configEntry(
TTN_ENTRY,
"thethingsnetwork_lorawan",
"The Things Network"
),
},
{
type: "device",
entry: configEntry(SENSECAP_ENTRY, "sensecap_lorawan", "SenseCAP"),
},
],
devices: [
device(
"lorawan-greenhouse",
"Greenhouse",
"Seeed Studio",
"S2101",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, CHIRPSTACK_ENTRY],
identifiers: [
["sensecap_lorawan", `${CHIRPSTACK_ENTRY}:0000000000000001`],
],
}
),
device(
"lorawan-study",
"Study air quality",
"Seeed Studio",
"S2103",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, TTN_ENTRY],
identifiers: [["sensecap_lorawan", `${TTN_ENTRY}:0000000000000002`]],
}
),
],
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"sensor.lorawan_greenhouse_temperature": {
entity_id: "sensor.lorawan_greenhouse_temperature",
state: "24.3",
attributes: {
friendly_name: "Greenhouse temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"sensor.lorawan_greenhouse_humidity": {
entity_id: "sensor.lorawan_greenhouse_humidity",
state: "58",
attributes: {
friendly_name: "Greenhouse humidity",
device_class: "humidity",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"sensor.lorawan_study_carbon_dioxide": {
entity_id: "sensor.lorawan_study_carbon_dioxide",
state: "652",
attributes: {
friendly_name: "Study carbon dioxide",
device_class: "carbon_dioxide",
state_class: "measurement",
unit_of_measurement: "ppm",
},
},
}),
};
+124
View File
@@ -0,0 +1,124 @@
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
import { emitInitial } from "../subscription";
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
const devices = (): LoRaWANDevice[] => {
const base: LoRaWANDevice = {
dev_eui: "0000000000000001",
name: "Greenhouse",
manufacturer: "Seeed Studio",
model: "S2101",
application_id: "home-sensors",
device_id: "lorawan-greenhouse",
connection: {
entry_id: CHIRPSTACK_ENTRY,
domain: "chirpstack",
title: "Home ChirpStack",
configuration_url: "https://www.chirpstack.io/",
},
support: "supported",
last_uplink: minutesAgo(2),
last_join: minutesAgo(2400),
last_status: minutesAgo(64),
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
battery_level: 87,
external_power_source: false,
downlink_margin: 12,
};
return [
base,
{
...base,
dev_eui: "0000000000000002",
name: "Study air quality",
model: "S2103",
device_id: "lorawan-study",
application_id: "demo-home",
connection: {
entry_id: TTN_ENTRY,
domain: "thethingsnetwork_lorawan",
title: "The Things Network",
configuration_url: "https://eu1.cloud.thethings.network/console",
},
receptions: [
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
],
battery_level: 64,
last_uplink: minutesAgo(4),
last_status: minutesAgo(140),
},
{
...base,
dev_eui: "24E124128E123456",
name: "Irrigation controller",
manufacturer: "Milesight",
model: "UC511",
device_id: null,
support: "integration_not_configured",
battery_level: 93,
last_uplink: minutesAgo(7),
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
},
{
...base,
dev_eui: "A84041FFFF123456",
name: "Garage power meter",
manufacturer: "",
model: "",
device_id: null,
support: "missing_identity",
battery_level: null,
external_power_source: true,
last_uplink: minutesAgo(1),
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
},
{
...base,
dev_eui: "2CF7F1C071500199",
name: "New soil sensor",
model: "S2105",
device_id: null,
support: "unsupported_model",
last_uplink: null,
last_join: null,
last_status: null,
receptions: [],
battery_level: null,
external_power_source: null,
downlink_margin: null,
},
{
...base,
dev_eui: "2CF7F1C071500166",
name: "Garden shed",
device_id: null,
support: "integration_not_configured",
battery_level: 18,
last_uplink: minutesAgo(180),
last_status: minutesAgo(600),
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
},
];
};
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
const deviceList = devices();
const snapshot = {
devices: deviceList,
connections: [
...new Map(
deviceList.map((device) => [
device.connection.entry_id,
device.connection,
])
).values(),
],
};
hass.mockWS("lorawan/devices/list", () => snapshot);
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.(snapshot))
);
};
+33
View File
@@ -190,6 +190,39 @@ export const removeEntityRegistryEntry = (
hass.mockEvent("entity_registry_updated");
};
export const removeLabelFromEntityRegistry = (
hass: MockHomeAssistant,
labelId: string
) => {
const withoutLabel = (labels: string[]) =>
labels.filter((id) => id !== labelId);
for (const [key, entry] of retiredEntries) {
if (entry.labels.includes(labelId)) {
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
}
}
if (!entries.some((entry) => entry.labels.includes(labelId))) {
return;
}
entries = entries.map((entry) =>
entry.labels.includes(labelId)
? { ...entry, labels: withoutLabel(entry.labels) }
: entry
);
const entities = { ...hass.entities };
for (const entry of entries) {
const entity = entities[entry.entity_id];
if (entity?.labels.includes(labelId)) {
entities[entry.entity_id] = {
...entity,
labels: withoutLabel(entity.labels),
};
}
}
hass.updateHass({ entities });
hass.mockEvent("entity_registry_updated");
};
// Adds an entry like core does when an integration creates an entity
export const addEntityRegistryEntry = (
hass: MockHomeAssistant,
+19 -2
View File
@@ -1,5 +1,6 @@
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { removeLabelFromEntityRegistry } from "./entity_registry";
let labels: LabelRegistryEntry[] = [];
@@ -9,6 +10,22 @@ export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => {
labels = data;
hass.mockWS("config/label_registry/list", () => data);
labels = [...data];
hass.mockWS("config/label_registry/list", () =>
labels.map((label) => ({ ...label }))
);
hass.mockWS(
"config/label_registry/delete",
(msg: { label_id: string }, currentHass: MockHomeAssistant) => {
const index = labels.findIndex(
(label) => label.label_id === msg.label_id
);
if (index === -1) {
throw new Error(`Label ${msg.label_id} not found`);
}
labels.splice(index, 1);
removeLabelFromEntityRegistry(currentHass, msg.label_id);
hass.mockEvent("label_registry_updated");
}
);
};
+16 -2
View File
@@ -1,4 +1,5 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceDashboard } from "../../../src/data/lovelace/dashboard";
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
@@ -11,10 +12,14 @@ import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mockMap } from "./map";
let dashboards: LovelaceDashboard[] = [];
export const mockLovelace = (
hass: MockHomeAssistant,
localizePromise: Promise<LocalizeFunc>
localizePromise: Promise<LocalizeFunc>,
data: LovelaceDashboard[] = []
) => {
dashboards = data;
hass.mockWS("lovelace/config", ({ url_path }) => {
if (url_path === "map") {
mockMap(hass);
@@ -34,7 +39,16 @@ export const mockLovelace = (
);
hass.mockWS("lovelace/config/save", () => Promise.resolve());
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () =>
dashboards.map((dashboard) => ({ ...dashboard }))
);
hass.mockWS("lovelace/dashboards/delete", (msg: { dashboard_id: string }) => {
const index = dashboards.findIndex((d) => d.id === msg.dashboard_id);
if (index === -1) {
throw new Error(`Dashboard ${msg.dashboard_id} not found`);
}
dashboards.splice(index, 1);
});
};
customElements.whenDefined("hui-root").then(() => {
+36 -18
View File
@@ -1,8 +1,10 @@
import { TZDate } from "@date-fns/tz";
import {
addDays,
addHours,
addMinutes,
addMonths,
constructFrom,
differenceInHours,
endOfDay,
} from "date-fns";
@@ -13,6 +15,18 @@ import type {
} from "../../../src/data/recorder";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const getFirstDate = (
start: Date,
period: "5minute" | "hour" | "day" | "month"
): Date => {
if (period === "day" || period === "month") {
return constructFrom(start, start);
}
// Core returns UTC-aligned buckets whose start is at or after the request.
const interval = (period === "hour" ? 60 : 5) * 60 * 1000;
return constructFrom(start, Math.ceil(start.getTime() / interval) * interval);
};
const getNextDate = (
currentDate: Date,
period: "5minute" | "hour" | "day" | "month"
@@ -33,8 +47,7 @@ const generateMeanStatistics = (
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let currentDate = getFirstDate(start, period);
const now = new Date();
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
@@ -63,8 +76,7 @@ const generateSumStatistics = (
maxDiff: number | ((start: Date, end: Date) => number)
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let currentDate = getFirstDate(start, period);
let sum = initValue;
const now = new Date();
while (end > currentDate && currentDate < now) {
@@ -98,8 +110,7 @@ const generateCurvedStatistics = (
metered: boolean
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let currentDate = getFirstDate(start, period);
let sum = initValue;
const hours = differenceInHours(end, start) - 1;
let i = 0;
@@ -162,9 +173,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = new Date(start.getTime() + 10 * 60 * 60 * 1000);
const morningEnd = addHours(start, 10);
const morningLow = generateSumStatistics(start, morningEnd, period, 0, 0.7);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const eveningStart = addHours(start, 20);
const morningFinalVal = morningLow.length
? morningLow[morningLow.length - 1].sum!
: 0;
@@ -199,8 +210,8 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const morningEnd = addHours(start, 9);
const eveningStart = addHours(start, 20);
const highTarif = generateSumStatistics(
morningEnd,
eveningStart,
@@ -239,9 +250,12 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const productionStart = addHours(start, 9);
const productionEnd = addHours(start, 21);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -280,9 +294,12 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const productionStart = addHours(start, 7);
const productionEnd = addHours(start, 23);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -329,8 +346,9 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/statistics_during_period",
({ statistic_ids, start_time, end_time, period }, hass): Statistics => {
const start = new Date(start_time);
const end = end_time ? new Date(end_time) : new Date();
// Calendar buckets follow the server timezone, independent of the browser.
const start = new TZDate(start_time, hass.config.time_zone);
const end = new TZDate(end_time ?? Date.now(), hass.config.time_zone);
const statistics: Record<string, StatisticValue[]> = {};
const cache: Record<string, StatisticValue[]> = {};
+1 -1
View File
@@ -33,4 +33,4 @@ We want to make it as easy for designers to contribute as it is for developers.
## Developers
Everything you need to get started developing can be found in our <a href="https://developers.home-assistant.io" rel="noopener noreferrer" target="_blank">Home Assistant Developer Docs</a>.
Everything you need to get started developing can be found in our <a href="https://contributors.home-assistant.io/frontend/" rel="noopener noreferrer" target="_blank">Home Assistant contributor docs</a>.
+1 -1
View File
@@ -271,7 +271,7 @@ const CONFIGS = [
### ha-alert
You can use our [\`ha-alert\`](https://design.home-assistant.io/#components/ha-alert) component in markdown content rendered in the Home Assistant Frontend.
You can use our [\`ha-alert\`](https://contributors.home-assistant.io/design/components/alert/) component in markdown content rendered in the Home Assistant Frontend.
<ha-alert alert-type="error">This is an error alert — check it out!</ha-alert>
<ha-alert alert-type="warning">This is a warning alert — check it out!</ha-alert>
+1 -1
View File
@@ -32,7 +32,7 @@ It is similar to the core frontend dev.
- `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]`
- use the dev container
- start the dev server with following optional env vars:
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://developers.home-assistant.io/docs/supervisor/development/#supervisor-api-access) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://contributors.home-assistant.io/os/supervisor/core-connection/#get-access-to-the-supervisor-api) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs
- `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend`
- example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go`
+1 -1
View File
@@ -6,7 +6,7 @@ export default {
],
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
"translations/*/*.json": (files) =>
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://contributors.home-assistant.io/translate/ for details." ' +
files.join(" ") +
" >&2 && exit 1",
"pnpm-lock.yaml": () => "pnpm dedupe",
+14 -13
View File
@@ -45,6 +45,7 @@
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.13.1",
"@codemirror/lint": "6.9.7",
@@ -85,7 +86,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.4.0",
"@versatiles/style": "6.4.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -114,7 +115,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "6.14.0",
"marked": "18.1.0",
"marked": "18.1.1",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -149,16 +150,16 @@
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.66.1",
"@html-eslint/eslint-plugin": "0.67.0",
"@lokalise/node-api": "16.6.0",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.0",
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.8",
"@rspack/core": "2.2.9",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-receiver": "6.0.27",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
@@ -173,7 +174,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.3",
"@vitest/coverage-v8": "5.0.5",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.3",
@@ -185,7 +186,7 @@
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
@@ -198,7 +199,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.1.2",
"jsdom": "30.1.3",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
@@ -217,16 +218,16 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.71.1",
"typescript-eslint": "8.71.2",
"vite": "8.3.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.3",
"vitest": "5.0.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected]1.2",
"packageManager": "[email protected]4.0",
"volta": {
"node": "24.21.0"
"node": "24.22.0"
}
}
+407 -390
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "./compute_domain";
export const ALERT_DOMAINS = [
"alarm_control_panel",
"binary_sensor",
"cover",
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
return ["triggered"];
case "cover":
return ["open"];
case "lock":
return ["jammed", "unlocked", "open"];
default:
return ["on"];
}
};
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
+29 -47
View File
@@ -280,7 +280,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _pendingUpdate?: Set<PropertyKey>;
private _pendingOptions?: HaECOption;
private _legendItemCount?: number;
private _pendingZoom?: [number, number, boolean];
@@ -292,7 +292,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._tooltipHiddenWhilePanning = false;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._pendingZoom = undefined;
// The observers are about to be torn down, so nothing would correct a stale
// value if this element is reattached inside a hidden container.
@@ -445,11 +444,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this._pendingUpdate) {
this._pendingUpdate = new Set();
}
if (prop === "options" && !this._pendingUpdate.has(prop)) {
// The one previous value a replay reads, and it has to stay the options
// the chart currently renders, not those of a later deferred change.
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
}
this._pendingUpdate.add(prop);
}
}
@@ -466,10 +460,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
return;
}
const pending = this._pendingUpdate!;
const previousOptions = this._pendingOptions;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._applyChartUpdate(pending, previousOptions);
this._applyChartUpdate(pending);
}
public willUpdate(changedProps: PropertyValues): void {
@@ -485,7 +477,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (invisible) {
this._pendingSetup = true;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
} else {
this._setupChart();
}
@@ -503,16 +494,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
return;
}
this._applyChartUpdate(
changedProps,
changedProps.get("options") as HaECOption | undefined
);
this._applyChartUpdate(changedProps);
}
private _applyChartUpdate(
changedProps: { has: (prop: string) => boolean },
previousOptions: HaECOption | undefined
) {
private _applyChartUpdate(changedProps: { has: (prop: string) => boolean }) {
let chartOptions: ECOption = {};
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
@@ -532,20 +517,28 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
this._disposeSonification();
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
}
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
}
let legendChanged = false;
if (changedProps.has("options") || changedProps.has("data")) {
const legendItemCount = this._getLegendItems()?.length;
legendChanged = legendItemCount !== this._legendItemCount;
this._legendItemCount = legendItemCount;
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series || changedProps.has("_isZoomed")) {
this._updateSankeyRoam();
}
}
if (legendChanged) {
// Within a fixed height the host does not resize when the legend grows.
// Without animation, setOption has already fired "finished" by now.
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
this.updateComplete.then(() => this._resizeChartIfNeeded());
}
}
protected render() {
@@ -832,7 +825,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._loading = true;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
@@ -883,13 +875,14 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = Math.abs(sankeySeries.zoom - 1) > 1e-6;
this._isZoomed = zoomed;
// Before setOption, which lays the labels out again
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
if (!zoomed) {
// Reset center when fully zoomed out
this.chart!.setOption({
series: [{ id: sankeySeries.id, center: null }],
});
}
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
this.chart!.dispatchAction({ type: "downplay" });
});
@@ -1002,6 +995,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
...this._createOptions(),
series: this._getSeries(),
});
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
if (this._pendingZoom) {
const [start, end, silent] = this._pendingZoom;
@@ -1458,6 +1452,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (this._isZoomed) {
this._handleZoomReset();
} else {
fireEvent(this, "chart-sankeyroam", { zoom: 2 });
this.chart.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
@@ -1502,15 +1497,19 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
private _handleZoomReset() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
// Before the dataZoom action and setOption, which lay the labels out again
if (sankeySeries?.length) {
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
// A dataZoom action reruns the layout of every series, graphs included
if (this._isZoomed) {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
}
// Reset sankey roam zoom
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
@@ -1520,7 +1519,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
})),
});
this._isZoomed = false;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
this._getGraphRoams().forEach(({ series, zoom, x, y }) => {
this.chart!.dispatchAction({
@@ -1872,22 +1870,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._resizeAnimationDuration = undefined;
}
private _compareCustomLegendOptions(
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
) as LegendComponentOption[];
const newLegends = ensureArray(
newOptions?.legend || []
) as LegendComponentOption[];
return (
oldLegends.some((l) => l.show && l.type === "custom") !==
newLegends.some((l) => l.show && l.type === "custom")
);
}
static styles = css`
:host {
display: block;
+2
View File
@@ -32,6 +32,7 @@ export interface NetworkNode {
borderColor?: string;
borderWidth?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -315,6 +316,7 @@ export class HaNetworkGraph extends LitElement {
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
label: node.label,
fixed: node.fixed,
x: node.x,
y: node.y,
+8 -3
View File
@@ -237,8 +237,10 @@ export class HaSankeyChart extends LitElement {
distance: LABEL_DISTANCE,
minMargin: LABEL_MIN_MARGIN,
overflow: "break",
lineOverflow: this.vertical ? "truncate" : "none",
},
labelLayout: (params) => {
// params.rect is already zoomed, only the gap slack needs scaling
if (this.vertical) {
// reduce the label font size so the longest word fits on one line
const longestWord = params.text
@@ -257,7 +259,7 @@ export class HaSankeyChart extends LitElement {
: longest;
}, "");
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
const availableWidth = params.rect.width + 6 * this._currentZoom;
// minMargin is applied as padding on the label box, so words must
// fit in the inner wrap width or overflow:break splits them.
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
@@ -266,14 +268,17 @@ export class HaSankeyChart extends LitElement {
(wrapWidth / wordWidth) * FONT_SIZE
);
return {
fontSize: fontSize > 1 ? fontSize : 0,
fontSize,
// Safari draws a 0px canvas font at its default size, so hide
// unreadable labels by dropping all of their lines instead.
height: fontSize > 1 ? undefined : 0,
width: availableWidth,
align: "center",
dy: -2, // shift up or the lowest row labels may be cut off
};
}
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
const availableHeight = params.rect.height + 8 * this._currentZoom; // account for the margin
const fontSize = Math.min(
(availableHeight / params.labelRect.height) * FONT_SIZE,
FONT_SIZE
+21 -6
View File
@@ -1762,10 +1762,6 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--numeric {
text-align: var(--float-end);
}
.mdc-data-table__header-cell--numeric.sortable:hover,
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) {
text-align: var(--float-start);
}
/* custom from here */
@@ -1839,8 +1835,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell > * {
transition: var(--float-start) 0.2s ease;
}
.mdc-data-table__header-cell--numeric > span {
transition: none;
.mdc-data-table__header-cell--numeric ha-svg-icon {
transition: var(--float-end) 0.2s ease;
}
.mdc-data-table__header-cell ha-svg-icon {
top: -3px;
@@ -1863,6 +1859,25 @@ export class HaDataTable extends LitElement {
inset-inline-start: 12px;
inset-inline-end: initial;
}
.mdc-data-table__header-cell--numeric.not-sorted ha-svg-icon {
right: -20px;
inset-inline-end: -20px;
inset-inline-start: initial;
}
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted)
ha-svg-icon,
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted
ha-svg-icon {
right: 12px;
inset-inline-end: 12px;
inset-inline-start: initial;
}
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) span,
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted span {
left: -24px;
inset-inline-start: -24px;
inset-inline-end: initial;
}
.table-header {
border-bottom: 1px solid var(--divider-color);
}
@@ -188,8 +188,8 @@ const sortData = (
: b[column.valueColumn || sortColumn];
if (column.type === "numeric") {
valA = isNaN(valA) ? undefined : Number(valA);
valB = isNaN(valB) ? undefined : Number(valB);
valA = valA == null || isNaN(valA) ? undefined : Number(valA);
valB = valB == null || isNaN(valB) ? undefined : Number(valB);
} else if (column.type === "ip") {
return sort * ipCompare(valA, valB);
} else if (typeof valA === "string" && typeof valB === "string") {
+45 -29
View File
@@ -43,7 +43,10 @@ import {
findAvailableLanguage,
getTranslation,
} from "../util/common-translation";
import { documentationUrl } from "../util/documentation-url";
import {
documentationUrl,
SECURE_CONNECTION_DOCUMENTATION_PATH,
} from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
import "./input/ha-input";
@@ -51,7 +54,7 @@ import type { HaInput } from "./input/ha-input";
interface AssistMessage {
who: string;
text: string | TemplateResult;
text: string;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
@@ -539,31 +542,18 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
this._conversation = [...this._conversation!, message];
}
private async _showNotSupportedMessage() {
private _showNotSupportedMessage() {
const documentationLink = `[${this._localize(
"ui.components.secure_connection_required.documentation_link"
)}](${documentationUrl(this._config, SECURE_CONNECTION_DOCUMENTATION_PATH)})`;
this._addMessage({
who: "hass",
text:
// New lines matter for messages
// prettier-ignore
html`${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_browser"
)}
${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation",
{
documentation_link: html`<a
target="_blank"
rel="noopener noreferrer"
href=${documentationUrl(
this._config,
"/docs/configuration/securing/#remote-access"
)}
>${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
)}`,
text: `${this._localize(
"ui.components.secure_connection_required.microphone"
)}\n\n${this._localize(
"ui.components.secure_connection_required.learn_more",
{ documentation_link: documentationLink }
)}`,
thinking: "",
tool_calls: {},
});
@@ -583,13 +573,31 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
}
this._stt_binary_handler_id = undefined;
this._audioBuffer = [];
const microphoneError = await this._audioRecorder.start();
if (microphoneError) {
this._audioBuffer = undefined;
this._processing = false;
this._addMessage({
who: "hass",
text: this._localize(
`ui.dialogs.voice_command.microphone_${microphoneError}`
),
thinking: "",
tool_calls: {},
error: true,
});
return;
}
const userMessage: AssistMessage = {
who: "user",
text: "…",
thinking: "",
tool_calls: {},
};
await this._audioRecorder.start();
this._addMessage(userMessage);
@@ -602,7 +610,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
if (event.type === "run-start") {
this._stt_binary_handler_id =
event.data.runner_data.stt_binary_handler_id;
this._audio = new Audio(event.data.tts_output!.url);
this._audio = new Audio(
this._connection.hassUrl(event.data.tts_output!.url)
);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
@@ -616,7 +626,11 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
);
this._audio.addEventListener("error", () => {
this._unloadAudio();
showAlertDialog(this, { title: "Error playing audio." });
showAlertDialog(this, {
title: this._localize(
"ui.dialogs.voice_command.error_playing_audio"
),
});
});
}
@@ -666,7 +680,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
);
} catch (err: any) {
await showAlertDialog(this, {
title: "Error starting pipeline",
title: this._localize(
"ui.dialogs.voice_command.error_starting_pipeline"
),
text: err.message || err,
});
this._stopListening();
+16 -4
View File
@@ -80,6 +80,9 @@ export class HaControlCircularSlider extends LitElement {
@property({ type: String, attribute: "high-label" })
public highLabel?: string;
@property({ attribute: false })
public formatValue?: (value: number) => string;
@property({ type: Number }) public value?: number;
@property({ type: Number }) public low?: number;
@@ -562,13 +565,22 @@ export class HaControlCircularSlider extends LitElement {
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${
this._localValue != null
? this._steppedValue(this._localValue)
: undefined
value != null ? this._steppedValue(value) : undefined
}
aria-valuetext=${ifDefined(
value != null && this.formatValue
? this.formatValue(this._steppedValue(value))
: undefined
)}
aria-disabled=${this.disabled}
aria-readonly=${this.readonly}
aria-label=${ifDefined(this.lowLabel ?? this.label)}
aria-label=${ifDefined(
id === "low"
? (this.lowLabel ?? this.label)
: id === "high"
? (this.highLabel ?? this.label)
: this.label
)}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
+14 -3
View File
@@ -1,13 +1,16 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { ContextType } from "@lit/context";
import { mdiMinus, mdiPlus } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import { internationalizationContext } from "../data/context";
import type { FrontendLocaleData } from "../data/translation";
import "./ha-svg-icon";
@@ -45,6 +48,10 @@ export class HaControlNumberButton extends LitElement {
@query("#input") _input!: HTMLDivElement;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
private _hideUnit = new ResizeController(this, {
callback: (entries) => {
const width = entries[0]?.contentRect.width;
@@ -160,7 +167,9 @@ export class HaControlNumberButton extends LitElement {
class="button minus"
type="button"
tabindex="-1"
aria-label="decrement"
aria-label=${ifDefined(
this._i18n?.localize("ui.components.number-buttons.decrement")
)}
@click=${this._handleMinusButton}
.disabled=${
this.disabled || (this.min != null && this._value <= this.min)
@@ -172,7 +181,9 @@ export class HaControlNumberButton extends LitElement {
class="button plus"
type="button"
tabindex="-1"
aria-label="increment"
aria-label=${ifDefined(
this._i18n?.localize("ui.components.number-buttons.increment")
)}
@click=${this._handlePlusButton}
.disabled=${
this.disabled || (this.max != null && this._value >= this.max)
+2 -1
View File
@@ -106,7 +106,8 @@ export class HaForm extends LitElement implements HaFormElement {
) => string | undefined;
@property({ attribute: false }) public localizeValue?: (
key: string
key: string,
values?: Record<string, string>
) => string;
@property({ attribute: false }) public context?: Record<string, any>;
+10 -9
View File
@@ -20,6 +20,7 @@ import "./ha-button";
import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-secure-connection-alert";
import "./ha-spinner";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
@@ -163,15 +164,15 @@ class HaQrScanner extends LitElement {
: nothing
}
</div>`
: html`<ha-alert alert-type="warning">
${
!window.isSecureContext
? this._localize(
"ui.components.qr-scanner.only_https_supported"
)
: this._localize("ui.components.qr-scanner.not_supported")
}
</ha-alert>
: html`${
!window.isSecureContext
? html`<ha-secure-connection-alert
feature="camera"
></ha-secure-connection-alert>`
: html`<ha-alert alert-type="warning">
${this._localize("ui.components.qr-scanner.not_supported")}
</ha-alert>`
}
<p>${this._localize("ui.components.qr-scanner.manual_input")}</p>
<div class="row">
<ha-input
@@ -0,0 +1,74 @@
import type { ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { configContext } from "../data/context";
import {
documentationUrl,
SECURE_CONNECTION_DOCUMENTATION_PATH,
} from "../util/documentation-url";
import "./ha-alert";
export type SecureConnectionFeature =
"microphone" | "camera" | "push_notifications";
@customElement("ha-secure-connection-alert")
class HaSecureConnectionAlert extends LitElement {
@property() public feature!: SecureConnectionFeature;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
protected render() {
if (!this._localize || !this._config) {
return nothing;
}
return html`
<ha-alert alert-type="warning">
${this._localize(
`ui.components.secure_connection_required.${this.feature}`
)}
${this._localize(
"ui.components.secure_connection_required.learn_more",
{
documentation_link: html`<a
href=${documentationUrl(
this._config,
SECURE_CONNECTION_DOCUMENTATION_PATH
)}
target="_blank"
rel="noopener noreferrer"
>${this._localize(
"ui.components.secure_connection_required.documentation_link"
)}</a
>`,
}
)}
</ha-alert>
`;
}
static styles = css`
:host {
display: block;
}
a {
color: var(--primary-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-secure-connection-alert": HaSecureConnectionAlert;
}
}
@@ -43,7 +43,8 @@ export class HaObjectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public localizeValue?: (
key: string
key: string,
values?: Record<string, string>
) => string;
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
@@ -79,37 +80,45 @@ export class HaObjectSelector extends LitElement {
return this.selector.object?.fields?.[schema.name]?.description || "";
};
private _formatField(item: any, field: string): string {
const fields = this.selector.object!.fields!;
const value = formatSelectorValue(
this.hass,
item[field],
fields[field]?.selector
);
const translationKey = this.selector.object!.translation_key;
if (!value || !this.localizeValue || !translationKey) {
return value;
}
return (
this.localizeValue(`${translationKey}.fields.${field}.overview_label`, {
value,
}) || value
);
}
private _renderItem(item: any, index: number) {
const fields = this.selector.object!.fields!;
const preferredLabel = this.selector.object!.label_field;
const hasValidLabelField = preferredLabel && preferredLabel in fields;
const label = hasValidLabelField
? formatSelectorValue(
this.hass,
item[preferredLabel!],
fields[preferredLabel!]?.selector
)
: Object.entries(fields)
.map(([key, field]) =>
formatSelectorValue(this.hass, item[key], field.selector)
)
? this._formatField(item, preferredLabel!)
: Object.keys(fields)
.map((key) => this._formatField(item, key))
.filter(Boolean)
.join(" · ");
let description = "";
const descriptionField = this.selector.object!.description_field;
if (descriptionField && descriptionField in fields) {
const descriptionSelector = fields[descriptionField]?.selector;
description = descriptionSelector
? formatSelectorValue(
this.hass,
item[descriptionField],
descriptionSelector
)
: "";
if (
descriptionField &&
descriptionField in fields &&
fields[descriptionField]?.selector
) {
description = this._formatField(item, descriptionField);
}
const reorderable = this.selector.object!.multiple || false;
+4 -1
View File
@@ -93,7 +93,10 @@ export class HaSelector extends LitElement {
@property() public helper?: string;
@property({ attribute: false })
public localizeValue?: (key: string) => string;
public localizeValue?: (
key: string,
values?: Record<string, string>
) => string;
@property() public placeholder?: any;
+6 -2
View File
@@ -773,12 +773,16 @@ export class HaServiceControl extends LitElement {
filter_entity: targetEntities || undefined,
}));
private _localizeValueCallback = (key: string) => {
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) => {
if (!this._value?.action) {
return "";
}
return this.hass.localize(
`component.${computeDomain(this._value.action)}.selector.${key}`
`component.${computeDomain(this._value.action)}.selector.${key}`,
values
);
};
+1 -1
View File
@@ -91,7 +91,7 @@ export type ManagerStateEvent =
export type BackupSubscriptionEvent = ManagerStateEvent | UploadBackupEvent;
export const subscribeBackupEvents = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "connection">,
callback: (event: BackupSubscriptionEvent) => void,
preCheck?: () => boolean | Promise<boolean>
) =>
+255
View File
@@ -0,0 +1,255 @@
import { generateUuidV4 } from "../common/util/uuid";
import type { HomeAssistant } from "../types";
const STORAGE_KEY_SESSION = "tools-debug-console-session";
// Tabs tell each other which sessions they use, over this channel.
const SESSION_CHANNEL = "tools-debug-console-sessions";
// How long a tab waits for another tab to say it uses a session already.
const CLAIM_TIMEOUT = 100;
type SessionMessage =
| { type: "claim"; sessionId: string; ticket: number }
| { type: "taken"; sessionId: string };
// Per user, so accounts taking turns in the same browser tab share nothing.
const sessionIds = new Map<string, Promise<string>>();
// The sessions this tab uses, once claimed.
const ownSessionIds = new Set<string>();
const sessionChannel = (() => {
try {
const channel = new BroadcastChannel(SESSION_CHANNEL);
channel.addEventListener("message", (ev: MessageEvent<SessionMessage>) => {
if (ev.data.type === "claim" && ownSessionIds.has(ev.data.sessionId)) {
channel.postMessage({
type: "taken",
sessionId: ev.data.sessionId,
} satisfies SessionMessage);
}
});
return channel;
} catch (_err: unknown) {
// Without it, a tab opened from another one shares its session.
return undefined;
}
})();
/**
* Return if another open tab uses this session already, or claims it at the
* same time. Then the tab with the lowest ticket keeps it; both see the claim
* of the other, so exactly one of them does.
*/
const isSessionTaken = (sessionId: string): Promise<boolean> => {
const channel = sessionChannel;
if (!channel) {
return Promise.resolve(false);
}
const ticket = Math.random();
return new Promise((resolve) => {
const done = (taken: boolean) => {
window.clearTimeout(timeout);
channel.removeEventListener("message", onMessage);
resolve(taken);
};
const onMessage = (ev: MessageEvent<SessionMessage>) => {
const message = ev.data;
if (message.sessionId !== sessionId) {
return;
}
if (
message.type === "taken" ||
(message.type === "claim" && message.ticket < ticket)
) {
done(true);
}
};
const timeout = window.setTimeout(() => done(false), CLAIM_TIMEOUT);
channel.addEventListener("message", onMessage);
channel.postMessage({
type: "claim",
sessionId,
ticket,
} satisfies SessionMessage);
});
};
const claimSessionId = async (userId: string): Promise<string> => {
const storageKey = `${STORAGE_KEY_SESSION}-${userId}`;
let sessionId: string | undefined;
try {
sessionId = window.sessionStorage.getItem(storageKey) ?? undefined;
} catch (_err: unknown) {
// Without storage, the session lasts until the page reloads.
}
// A tab opened from another one gets a copy of its session storage. When
// that tab is still open, this one starts a session of its own.
if (!sessionId || (await isSessionTaken(sessionId))) {
sessionId = generateUuidV4();
try {
window.sessionStorage.setItem(storageKey, sessionId);
} catch (_err: unknown) {
// Kept for this page load only.
}
}
ownSessionIds.add(sessionId);
return sessionId;
};
/**
* The console session of this browser tab. The backend keeps the session for
* a while after disconnecting, so a reload continues where it left off.
*/
const getSessionId = (userId: string): Promise<string> => {
let sessionId = sessionIds.get(userId);
if (!sessionId) {
sessionId = claimSessionId(userId);
sessionIds.set(userId, sessionId);
}
return sessionId;
};
/** Claim the session of this tab ahead of time, so commands do not wait. */
export const prepareDebugConsoleSession = (userId: string) => {
getSessionId(userId);
};
const callDebugConsole = async <T>(
hass: HomeAssistant,
message: { type: string; [key: string]: unknown }
) =>
hass.callWS<T>({
...message,
session_id: await getSessionId(hass.user!.id),
});
/** Return if the user has to accept the warning before using the console. */
export const getDebugConsoleInfo = (hass: Pick<HomeAssistant, "callWS">) =>
hass.callWS<{ warning_required: boolean }>({ type: "debug_console/info" });
export const acceptDebugConsoleWarning = (
hass: Pick<HomeAssistant, "callWS">
) => hass.callWS({ type: "debug_console/warning/accept" });
export type DebugConsoleValue =
| { type: "none" }
| { type: "boolean"; value: boolean }
| { type: "number"; value: string }
| { type: "string"; value: string; truncated: boolean }
| { type: "text"; value: string; truncated: boolean }
| {
type: "object";
class: string;
preview: string;
/** Unique per backend session, so stale IDs never find another object. */
object_id: string;
length?: number;
};
export interface DebugConsoleExecuteResult {
logs?: DebugConsoleValue[][];
/** Set when the code printed more lines than the console keeps. */
logs_truncated?: boolean;
result?: DebugConsoleValue | null;
error?: string;
}
export type DebugConsoleChild =
{ key: string; value: DebugConsoleValue } | { key: string; getter: true };
/**
* Looks into objects of the console, for components that do not hold the
* hass object.
*/
export interface DebugConsoleObjects {
inspect(objectId: string): Promise<DebugConsoleInspectResult>;
getAttribute(
objectId: string,
attribute: string
): Promise<DebugConsoleGetAttributeResult>;
}
export interface DebugConsoleInspectResult {
children: DebugConsoleChild[];
truncated: boolean;
}
export type DebugConsoleGetAttributeResult =
{ value: DebugConsoleValue } | { error: string };
export const executeDebugConsole = (hass: HomeAssistant, code: string) =>
callDebugConsole<DebugConsoleExecuteResult>(hass, {
type: "debug_console/execute",
code,
});
/** Return if code needs more input before it runs, like in the Python REPL. */
export const checkDebugConsoleInput = (
hass: Pick<HomeAssistant, "callWS">,
code: string
) =>
hass.callWS<{ incomplete: boolean }>({
type: "debug_console/check_input",
code,
});
/** Stop the code this tab is running, which then answers it stopped. */
export const stopDebugConsole = (hass: HomeAssistant) =>
callDebugConsole(hass, { type: "debug_console/stop" });
export const inspectDebugConsole = (hass: HomeAssistant, objectId: string) =>
callDebugConsole<DebugConsoleInspectResult>(hass, {
type: "debug_console/inspect",
object_id: objectId,
});
export const getDebugConsoleAttribute = (
hass: HomeAssistant,
objectId: string,
attribute: string
) =>
callDebugConsole<DebugConsoleGetAttributeResult>(hass, {
type: "debug_console/get_attribute",
object_id: objectId,
attribute,
});
export interface DebugConsoleCompleteResult {
/** The part of the line the matches complete. */
token: string;
matches: string[];
}
/** Complete the code being typed, which is the text up to the cursor. */
export const completeDebugConsole = (hass: HomeAssistant, code: string) =>
callDebugConsole<DebugConsoleCompleteResult>(hass, {
type: "debug_console/complete",
code,
});
export interface DebugConsoleSignature {
name: string;
parameters: string[];
/** Index of the parameter the argument being typed goes to. */
active: number | null;
returns: string | null;
doc: string;
}
/** Return the signature of the call the code up to the cursor is in. */
export const getDebugConsoleSignature = (hass: HomeAssistant, code: string) =>
callDebugConsole<{ signature: DebugConsoleSignature | null }>(hass, {
type: "debug_console/signature",
code,
});
export const clearDebugConsole = (hass: HomeAssistant) =>
callDebugConsole(hass, { type: "debug_console/clear" });
-2
View File
@@ -12,8 +12,6 @@ import type { ESPHomeDeviceCapabilities, ESPHomeSerialProxy } from "./esphome";
import type { ESPHomeFrontendUserData } from "./frontend";
import type { SerialPortUsage } from "./usb";
export const MUSIC_ASSISTANT_ADDON_SLUG = "d5369777_music_assistant";
export const MUSIC_ASSISTANT_DOCS_URL = "https://music-assistant.io/";
export type ESPHomeCapabilityId =
+63
View File
@@ -0,0 +1,63 @@
import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
export interface LoRaWANReception {
gateway_id: string;
rssi: number | null;
snr: number | null;
}
export interface LoRaWANConnection {
entry_id: string;
domain: string;
title: string;
configuration_url: string | null;
}
export interface LoRaWANDevice {
dev_eui: string;
name: string;
manufacturer: string;
model: string;
application_id: string;
device_id: string | null;
connection: LoRaWANConnection;
support:
| "supported"
| "missing_identity"
| "integration_not_configured"
| "unsupported_model";
last_uplink: string | null;
last_join: string | null;
last_status: string | null;
receptions: LoRaWANReception[];
battery_level: number | null;
external_power_source: boolean | null;
downlink_margin: number | null;
}
export interface LoRaWANDevices {
devices: LoRaWANDevice[];
connections: LoRaWANConnection[];
}
export const fetchLoRaWANDevices = (hass: HomeAssistant) =>
hass.callWS<LoRaWANDevices>({ type: "lorawan/devices/list" });
export const subscribeLoRaWANDevices = (
connection: Connection,
listener: (snapshot: LoRaWANDevices) => void
) =>
connection.subscribeMessage<LoRaWANDevices>(listener, {
type: "lorawan/devices/subscribe",
});
export const strongestLoRaWANReception = (receptions: LoRaWANReception[]) =>
receptions.reduce<LoRaWANReception | undefined>(
(strongest, reception) =>
reception.rssi !== null &&
(strongest?.rssi == null || reception.rssi > strongest.rssi)
? reception
: strongest,
undefined
);
+8 -15
View File
@@ -310,22 +310,15 @@ export interface ZWaveJSSetRawConfigParamData {
value_format: number;
}
export interface ZWaveJSSetConfigParamResult {
value_id: string;
status: number;
export type ZWaveJSSetConfigParamStatus = "accepted" | "queued";
export interface ZWaveJSSetRawConfigParamResult {
status: ZWaveJSSetConfigParamStatus;
}
export type ZWaveJSSetConfigParamStatus = "accepted" | "queued" | "error";
// Maps the numeric SetValueStatus from zwave-js.
export const computeSetConfigParamStatus = (
status: number
): ZWaveJSSetConfigParamStatus => {
if (status === 254 || status === 255) {
return "accepted";
}
return status === 1 ? "queued" : "error";
};
export interface ZWaveJSSetConfigParamResult extends ZWaveJSSetRawConfigParamResult {
value_id: string;
}
export interface ZwaveJSNodeConfigParameterUpdate {
id: string;
@@ -816,7 +809,7 @@ export const setZwaveNodeRawConfigParameter = (
value: number,
value_size: number,
value_format: number
): Promise<ZWaveJSSetConfigParamResult> => {
): Promise<ZWaveJSSetRawConfigParamResult> => {
const data: ZWaveJSSetRawConfigParamData = {
type: "zwave_js/set_raw_config_parameter",
device_id,
@@ -145,8 +145,8 @@ export const showConfigFlowDialog = (
);
},
renderShowFormStepFieldLocalizeValue(hass, step, key) {
return hass.localize(`component.${step.handler}.selector.${key}`);
renderShowFormStepFieldLocalizeValue(hass, step, key, values) {
return hass.localize(`component.${step.handler}.selector.${key}`, values);
},
renderShowFormStepSubmitButton(hass, step) {
@@ -81,7 +81,8 @@ export interface FlowConfig {
renderShowFormStepFieldLocalizeValue(
hass: HomeAssistant,
step: DataEntryFlowStepForm,
key: string
key: string,
values?: Record<string, string>
): string;
renderShowFormStepSubmitButton(
@@ -141,8 +141,11 @@ export const showOptionsFlowDialog = (
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
return hass.localize(
`component.${configEntry.domain}.selector.${key}`,
values
);
},
renderShowFormStepSubmitButton(hass, step) {
@@ -144,8 +144,11 @@ export const showSubConfigFlowDialog = (
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
return hass.localize(
`component.${configEntry.domain}.selector.${key}`,
values
);
},
renderShowFormStepSubmitButton(hass, step) {
+6 -2
View File
@@ -348,11 +348,15 @@ class StepFlowForm extends LitElement {
private _errorCallback = (error: string) =>
this.flowConfig.renderShowFormStepFieldError(this.hass, this.step, error);
private _localizeValueCallback = (key: string) =>
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) =>
this.flowConfig.renderShowFormStepFieldLocalizeValue(
this.hass,
this.step,
key
key,
values
);
static get styles(): CSSResultGroup {
@@ -1,6 +1,5 @@
import type { ContextType } from "@lit/context";
import { mdiCheck, mdiChevronDown, mdiOpenInNew } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
@@ -20,11 +19,7 @@ import "../../components/ha-spinner";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-nav";
import {
fetchConfigFlowInProgress,
subscribeConfigFlowInProgress,
type ConfigFlowInProgressMessage,
} from "../../data/config_flow";
import { fetchConfigFlowInProgress } from "../../data/config_flow";
import {
apiContext,
configContext,
@@ -51,19 +46,12 @@ import {
getESPHomeAudioControls,
getESPHomeSetupCapabilityIds,
isESPHomeSerialConfigured,
MUSIC_ASSISTANT_ADDON_SLUG,
MUSIC_ASSISTANT_DOCS_URL,
type ESPHomeAudioControls,
type ESPHomeCapabilityId,
type ESPHomeCapabilityStatus,
type ESPHomeSetupStatus,
} from "../../data/esphome_setup";
import {
fetchHassioAddonsInfo,
installHassioAddon,
startHassioAddon,
} from "../../data/hassio/addon";
import { extractApiErrorMessage } from "../../data/hassio/common";
import {
listSerialIntegrations,
listSerialPortsWithUsage,
@@ -74,9 +62,11 @@ import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { getWsErrorMessage } from "../../util/ws-error";
import { showConfigFlowDialog } from "../config-flow/show-dialog-config-flow";
import {
loadConfigFlowDialog,
showConfigFlowDialog,
} from "../config-flow/show-dialog-config-flow";
import { DialogMixin } from "../dialog-mixin";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { ESPHomeDeviceSetupDialogParams } from "./show-dialog-esphome-device-setup";
type SetupView =
@@ -89,9 +79,6 @@ const SERIAL_PORT_TYPE_LABELS: Record<ESPHomeSerialPortType, LocalizeKeys> = {
USB_SERIAL: "ui.panel.config.devices.esphome.setup_serial_port_usb_serial",
};
/** Add-on discovery on slow hardware can take longer than a few seconds. */
const MUSIC_ASSISTANT_DISCOVERY_TIMEOUT_MS = 60_000;
/** Same delay ha-entity-toggle uses to resync a switch the state did not follow. */
const SWITCH_RESYNC_MS = 2000;
@@ -112,21 +99,6 @@ const CAPABILITY_DESCRIPTION_KEYS: Record<ESPHomeCapabilityId, LocalizeKeys> = {
serial: "ui.panel.config.devices.esphome.setup_capability_serial_description",
};
const hassioMusicAssistantFlow = (
messages: ConfigFlowInProgressMessage[]
): DataEntryFlowProgress | undefined => {
for (const message of messages) {
if (
message.type !== "removed" &&
message.flow.handler === "music_assistant" &&
message.flow.context.source === "hassio"
) {
return message.flow;
}
}
return undefined;
};
@customElement("dialog-esphome-device-setup")
class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParams>(
LitElement
@@ -208,14 +180,9 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
private _loadStarted = false;
private _musicAssistantDiscovery?: {
unsub?: UnsubscribeFunc;
timer?: number;
resolve?: (flow: DataEntryFlowProgress | undefined) => void;
};
private _openingMusicAssistantFlow = false;
public disconnectedCallback() {
this._finishMusicAssistantDiscovery(undefined);
this.params?.dialogClosedCallback?.();
super.disconnectedCallback();
}
@@ -516,7 +483,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
? html`
<ha-button
slot="primaryAction"
@click=${this._installMusicAssistant}
@click=${this._setupMusicAssistant}
>
${localize(
"ui.panel.config.devices.esphome.setup_install_music_assistant"
@@ -865,7 +832,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
private _showChecklist = () => {
this._audioFlowId += 1;
this._finishMusicAssistantDiscovery(undefined);
this._view = "checklist";
this._installingAudio = false;
this._installStatus = undefined;
@@ -985,143 +951,32 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
}
}
private _installMusicAssistant = async (ev: Event) => {
/** The integration's config flow installs and starts the Music Assistant app. */
private _setupMusicAssistant = async (ev: Event) => {
ev.stopPropagation();
if (!this._api || !this._i18n || !this._connection) {
if (this._openingMusicAssistantFlow) {
return;
}
const flowId = this._audioFlowId;
this._view = "audio-working";
this._installingAudio = true;
this._error = undefined;
this._installStatus = this._i18n.localize(
"ui.panel.config.devices.esphome.setup_installing_music_assistant"
);
// The flow dialog only covers this button once its chunk has loaded
this._openingMusicAssistantFlow = true;
try {
const { addons } = await fetchHassioAddonsInfo(this._api);
if (!this._audioFlowCurrent(flowId)) {
return;
}
const addon = addons.find(
(item) => item.slug === MUSIC_ASSISTANT_ADDON_SLUG
);
if (!addon) {
await installHassioAddon(this._api.callWS, MUSIC_ASSISTANT_ADDON_SLUG);
}
if (!this._audioFlowCurrent(flowId)) {
return;
}
if (!addon || addon.state !== "started") {
this._installStatus = this._i18n.localize(
"ui.panel.config.devices.esphome.setup_starting_music_assistant"
);
await startHassioAddon(this._api.callWS, MUSIC_ASSISTANT_ADDON_SLUG);
}
if (!this._audioFlowCurrent(flowId)) {
return;
}
this._installStatus = this._i18n.localize(
"ui.panel.config.devices.esphome.setup_discovering_music_assistant"
);
await this._openMusicAssistantFlow(flowId);
} catch (err: unknown) {
if (!this._audioFlowCurrent(flowId) || !this._i18n) {
return;
}
this._view = "audio-offer";
await showAlertDialog(this, {
title: this._i18n.localize(
"ui.panel.config.devices.esphome.setup_error_music_assistant"
),
text: extractApiErrorMessage(err),
});
await loadConfigFlowDialog();
} finally {
if (this._audioFlowCurrent(flowId)) {
this._installingAudio = false;
}
this._openingMusicAssistantFlow = false;
}
};
private async _openMusicAssistantFlow(flowId: number) {
if (!this._connection) {
return;
}
const flow = await this._waitForHassioMusicAssistantFlow();
if (!this._audioFlowCurrent(flowId)) {
return;
}
const continueSetup = ({ flowFinished }: { flowFinished: boolean }) => {
if (!this._audioFlowCurrent(flowId)) {
return;
}
if (!flowFinished) {
this._installingAudio = false;
this._installStatus = undefined;
this._view = "audio-offer";
return;
}
void this._continueAfterMusicAssistant(flowId);
};
if (flow) {
showConfigFlowDialog(this, {
continueFlowId: flow.flow_id,
dialogClosedCallback: continueSetup,
});
if (!this._audioFlowCurrent(flowId) || this._view !== "audio-offer") {
return;
}
showConfigFlowDialog(this, {
startFlowHandler: "music_assistant",
dialogClosedCallback: continueSetup,
});
}
private _finishMusicAssistantDiscovery(
flow: DataEntryFlowProgress | undefined
) {
const pending = this._musicAssistantDiscovery;
if (!pending) {
return;
}
this._musicAssistantDiscovery = undefined;
if (pending.timer !== undefined) {
window.clearTimeout(pending.timer);
}
pending.unsub?.();
pending.resolve?.(flow);
}
private _waitForHassioMusicAssistantFlow(): Promise<
DataEntryFlowProgress | undefined
> {
const connection = this._connection;
if (!connection) {
return Promise.resolve(undefined);
}
return new Promise((resolve) => {
const pending: NonNullable<typeof this._musicAssistantDiscovery> = {
resolve,
};
this._musicAssistantDiscovery = pending;
pending.timer = window.setTimeout(
() => this._finishMusicAssistantDiscovery(undefined),
MUSIC_ASSISTANT_DISCOVERY_TIMEOUT_MS
);
void subscribeConfigFlowInProgress(connection, (messages) => {
const flow = hassioMusicAssistantFlow(messages);
if (flow) {
this._finishMusicAssistantDiscovery(flow);
dialogClosedCallback: ({ entryId }) => {
if (entryId) {
void this._continueAfterMusicAssistant(flowId);
}
})
.then((unsub) => {
if (this._musicAssistantDiscovery !== pending) {
unsub();
return;
}
pending.unsub = unsub;
})
.catch(() => this._finishMusicAssistantDiscovery(undefined));
},
});
}
};
private async _refreshZWaveFlows() {
if (!this._connection) {
@@ -42,6 +42,8 @@ import type {
export class HaMoreInfoUpdateBackup extends LitElement {
@property({ attribute: false }) public stateObj?: UpdateEntity;
@property({ type: Boolean }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@@ -126,7 +128,7 @@ export class HaMoreInfoUpdateBackup extends LitElement {
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
.disabled=${this.disabled || updateIsInstalling(this.stateObj)}
></ha-switch>`
}
</ha-row-item>
@@ -1,18 +1,35 @@
import type { ContextType } from "@lit/context";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { consume } from "../../../common/decorators/consume";
import { BINARY_STATE_OFF } from "../../../common/const";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import {
consumeEntityRegistryEntry,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { closestWithProperty } from "../../../common/dom/ancestors-with-property";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import "../../../components/ha-spinner";
import "../../../components/progress/ha-progress-bar";
import { apiContext, formattersContext } from "../../../data/context";
import type { ManagerState } from "../../../data/backup_manager";
import { subscribeBackupEvents } from "../../../data/backup_manager";
import {
apiContext,
configContext,
connectionContext,
formattersContext,
internationalizationContext,
} from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../../data/entity/entity_registry";
import type { UpdateEntity } from "../../../data/update";
import {
latestVersionIsSkipped,
@@ -21,11 +38,19 @@ import {
updateIsInstalling,
updateReleaseNotes,
} from "../../../data/update";
import type { HomeAssistantApi, HomeAssistantFormatters } from "../../../types";
import type {
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantFormatters,
} from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
import { showToast } from "../../../util/toast";
import "../components/update/ha-more-info-update-backup";
import type { HaMoreInfoUpdateBackup } from "../components/update/ha-more-info-update-backup";
// Dialogs that reflect their open state to the `open` attribute.
const DIALOG_ELEMENTS = new Set(["ha-adaptive-dialog", "ha-dialog"]);
@customElement("more-info-update")
class MoreInfoUpdate extends LitElement {
@property({ attribute: false }) public stateObj?: UpdateEntity;
@@ -41,15 +66,102 @@ class MoreInfoUpdate extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consumeEntityRegistryEntry({ entityIdPath: ["stateObj", "entity_id"] })
private _entityEntry?: EntityRegistryDisplayEntry;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
@state() private _releaseNotes?: string | null;
@state() private _error?: string;
@state() private _installError?: string;
@state() private _backupState: ManagerState = "idle";
@state() private _markdownLoading = true;
@query("ha-more-info-update-backup")
private _backupElement?: HaMoreInfoUpdateBackup;
private _backupEventsUnsub?: Promise<UnsubscribeFunc>;
public connectedCallback(): void {
super.connectedCallback();
this._subscribeBackupEvents();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._backupEventsUnsub?.then(
(unsub) => unsub(),
() => undefined
);
this._backupEventsUnsub = undefined;
this._backupState = "idle";
}
private _subscribeBackupEvents(): void {
if (
this._backupEventsUnsub ||
!isComponentLoaded(this._config, "backup") ||
!isComponentLoaded(this._config, "hassio")
) {
return;
}
const backupEventsUnsub = subscribeBackupEvents(
this._connection,
(event) => {
this._backupState = event.manager_state;
}
);
backupEventsUnsub.catch(() => {
// The update can still be attempted; a failure is shown inline.
if (this._backupEventsUnsub === backupEventsUnsub) {
this._backupEventsUnsub = undefined;
}
});
this._backupEventsUnsub = backupEventsUnsub;
}
private _backupInProgressMessage(): string | undefined {
// The Supervisor only holds back the updates it manages.
if (this._entityEntry?.platform !== "hassio") {
return undefined;
}
switch (this._backupState) {
case "create_backup":
case "receive_backup":
case "restore_backup":
return this._localize(
`ui.dialogs.more_info_control.update.backup_in_progress.${this._backupState}`,
{
backups_link: html`<a href="/config/backup"
>${this._localize(
"ui.dialogs.more_info_control.update.backup_in_progress.backups_link"
)}</a
>`,
}
);
default:
return undefined;
}
}
protected render() {
if (
!this._localize ||
@@ -61,6 +173,7 @@ class MoreInfoUpdate extends LitElement {
}
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
const backupInProgressMessage = this._backupInProgressMessage();
return html`
<div class="content">
@@ -160,7 +273,22 @@ class MoreInfoUpdate extends LitElement {
<div class="footer">
<ha-more-info-update-backup
.stateObj=${this.stateObj}
.disabled=${!!backupInProgressMessage}
></ha-more-info-update-backup>
${
backupInProgressMessage
? html`<ha-alert alert-type="info">
${backupInProgressMessage}
</ha-alert>`
: this._installError
? html`<ha-alert alert-type="error">
${this._localize(
"ui.dialogs.more_info_control.update.install_failed",
{ error: this._installError }
)}
</ha-alert>`
: nothing
}
<div class="actions">
${
this.stateObj.state === BINARY_STATE_OFF &&
@@ -195,7 +323,10 @@ class MoreInfoUpdate extends LitElement {
<ha-button
@click=${this._handleInstall}
.loading=${updateIsInstalling(this.stateObj)}
.disabled=${updateButtonIsDisabled(this.stateObj)}
.disabled=${
updateButtonIsDisabled(this.stateObj) ||
!!backupInProgressMessage
}
>
${this._localize(
"ui.dialogs.more_info_control.update.update"
@@ -240,7 +371,7 @@ class MoreInfoUpdate extends LitElement {
}
}
private _handleInstall(): void {
private async _handleInstall(): Promise<void> {
const installData: Record<string, any> = {
entity_id: this.stateObj!.entity_id,
};
@@ -256,7 +387,77 @@ class MoreInfoUpdate extends LitElement {
installData.version = this.stateObj!.attributes.latest_version;
}
this._api.callService("update", "install", installData);
this._installError = undefined;
try {
await this._api.callService(
"update",
"install",
installData,
undefined,
false
);
} catch (err: any) {
const message = await this._localizeInstallError(err);
if (this._isShown()) {
this._installError = message;
return;
}
// The dialog was closed, or is closing, while the update was installing.
const toastHost = document.querySelector("home-assistant");
if (toastHost) {
showToast(toastHost, {
message: this._localize(
"ui.dialogs.more_info_control.update.install_failed",
{ error: message }
),
duration: 10000,
});
}
}
}
private _isShown(): boolean {
if (!this.isConnected) {
return false;
}
// The dialog keeps its content mounted while its close animation runs.
let element = closestWithProperty(this, "open", false);
while (element) {
if (DIALOG_ELEMENTS.has(element.localName)) {
return element.hasAttribute("open");
}
element = closestWithProperty(element, "open", false);
}
return true;
}
private async _localizeInstallError(err: any): Promise<string> {
if (err?.translation_domain && err?.translation_key) {
const localize = await this._i18n.loadBackendTranslation(
"exceptions",
err.translation_domain
);
const localizedMessage = localize(
`component.${err.translation_domain}.exceptions.${err.translation_key}.message`,
err.translation_placeholders
);
if (localizedMessage) {
return localizedMessage;
}
}
return err?.message ?? String(err);
}
private _handleSkip(): void {
@@ -329,6 +530,13 @@ class MoreInfoUpdate extends LitElement {
z-index: 10;
}
.footer ha-alert {
display: block;
width: 100%;
box-sizing: border-box;
padding: var(--ha-space-2) var(--ha-space-4) 0;
}
.actions {
width: 100%;
display: flex;
+1 -6
View File
@@ -811,12 +811,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.device_or_service_info",
{
type: this.hass.localize(
`ui.dialogs.more_info_control.device_type.${deviceType}`
),
}
`ui.dialogs.more_info_control.${deviceType}_info`
)}
</ha-dropdown-item>
`
@@ -179,8 +179,11 @@ export const showRepairsFlowDialog = (
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${issue.domain}.selector.${key}`);
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
return hass.localize(
`component.${issue.domain}.selector.${key}`,
values
);
},
renderShowFormStepSubmitButton(hass, step) {
+1 -1
View File
@@ -396,7 +396,7 @@ export class HassTabsSubpage extends LitElement {
}
:host(:not([narrow])) #toolbar-icon {
min-width: 40px;
min-width: 48px;
}
ha-menu-button,
@@ -458,12 +458,16 @@ export class HaPlatformCondition extends LitElement {
this.requestUpdate("_checkedKeys");
}
private _localizeValueCallback = (key: string) => {
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) => {
if (!this.condition?.condition) {
return "";
}
return this.hass.localize(
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`,
values
);
};
@@ -1,7 +1,8 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiClose, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, queryAll } from "lit/decorators";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-icon-button";
@@ -27,10 +28,25 @@ export class HaConversationTrigger
@query("#option_input", true) private _optionInput?: HaInput;
@queryAll("ha-input.option") private _options!: NodeListOf<HaInput>;
public static get defaultConfig(): ConversationTrigger {
return { trigger: "conversation", command: "" };
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("trigger")) {
this._validateOptions();
}
}
private async _validateOptions() {
const options = [...this._options];
await Promise.all(options.map((option) => option.updateComplete));
options.forEach((option) => option.reportValidity());
}
protected render() {
const { command } = this.trigger;
const commands = command ? ensureArray(command) : [];
@@ -41,14 +57,12 @@ export class HaConversationTrigger
(option, index) => html`
<ha-input
class="option"
iconTrailing
.index=${index}
.value=${option}
.validationMessage=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.conversation.no_punctuation"
)}
auto-validate
validateOnInitialRender
pattern=${PATTERN}
@change=${this._updateOption}
>
@@ -448,12 +448,16 @@ export class HaPlatformTrigger extends LitElement {
this.requestUpdate("_checkedKeys");
}
private _localizeValueCallback = (key: string) => {
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) => {
if (!this.trigger?.trigger) {
return "";
}
return this.hass.localize(
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`,
values
);
};
@@ -131,7 +131,7 @@ class HaBackupConfigData extends LitElement {
}
private _hasLocalAddons(addons: BackupAddonItem[]): boolean {
return addons.some((addon) => addon.slug === "local");
return addons.some((addon) => addon.slug.startsWith("local_"));
}
private _estimateBackupSize = memoizeOne(
@@ -29,6 +29,7 @@ interface ProgressSegment {
label: string;
state: SegmentState;
flex: number;
progress?: number;
}
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
@@ -139,12 +140,29 @@ export class HaBackupOverviewProgress extends LitElement {
private _computeAgentPercent(agentId: string): number | undefined {
const progress = this.uploadProgress[agentId];
if (!progress || progress.total_bytes === 0) {
// 0 bytes only means the upload started; the agent may never report more.
if (
!progress ||
progress.total_bytes === 0 ||
progress.uploaded_bytes === 0
) {
return undefined;
}
return Math.round((progress.uploaded_bytes / progress.total_bytes) * 100);
}
private _computeUploadPercent(): number | undefined {
// Follow the slowest location still uploading. Finished ones are left out:
// already-written locations report 100% at once, and a location that
// doesn't report progress is only known once it has finished.
const percents = Object.keys(this.uploadProgress)
.map((agentId) => this._computeAgentPercent(agentId))
.filter(
(percent): percent is number => percent !== undefined && percent < 100
);
return percents.length ? Math.min(...percents) : undefined;
}
private _getStageGroupIndex(stage: CreateBackupStage): number {
return STAGE_ORDER.findIndex((group) => group.includes(stage));
}
@@ -169,6 +187,9 @@ export class HaBackupOverviewProgress extends LitElement {
: null;
const currentGroupIndex = stage ? this._getStageGroupIndex(stage) : -1;
const uploadProgress = this._isUploadStage
? this._computeUploadPercent()
: undefined;
const isHassio = isComponentLoaded(this.hass.config, "hassio");
if (isHassio) {
@@ -201,6 +222,7 @@ export class HaBackupOverviewProgress extends LitElement {
),
state: this._getSegmentState(3, currentGroupIndex),
flex: 5,
progress: uploadProgress,
},
{
label: this.hass.localize(
@@ -234,6 +256,7 @@ export class HaBackupOverviewProgress extends LitElement {
),
state: this._getSegmentState(3, currentGroupIndex),
flex: 5,
progress: uploadProgress,
},
{
label: this.hass.localize(
@@ -312,8 +335,18 @@ export class HaBackupOverviewProgress extends LitElement {
active: segment.state === "active",
completed: segment.state === "completed",
pending: segment.state === "pending",
determinate: segment.progress !== undefined,
})}"
></div>
>
${
segment.progress !== undefined
? html`<div
class="segment-fill"
style="width: ${segment.progress}%"
></div>`
: nothing
}
</div>
<span class="segment-label">${segment.label}</span>
</div>
`
@@ -330,9 +363,12 @@ export class HaBackupOverviewProgress extends LitElement {
return nothing;
}
const hasProgress = Object.keys(this.uploadProgress).length > 0;
// Only upload events tell which locations this backup goes to.
const agents = this.agents.filter(
(agent) => agent.agent_id in this.uploadProgress
);
if (!hasProgress) {
if (agents.length === 0) {
return nothing;
}
@@ -344,7 +380,7 @@ export class HaBackupOverviewProgress extends LitElement {
}
>
<ha-list-base class="agent-list">
${this.agents.map((agent) => {
${agents.map((agent) => {
const name = computeBackupAgentName(
this.hass.localize,
agent.agent_id,
@@ -462,6 +498,18 @@ export class HaBackupOverviewProgress extends LitElement {
.segment-bar.completed {
background-color: var(--primary-color);
}
.segment-bar.determinate {
background-color: var(--divider-color);
animation: none;
overflow: hidden;
}
.segment-fill {
height: 100%;
border-radius: inherit;
background-color: var(--primary-color);
transition: width 0.3s ease;
animation: pulse 1.5s ease-in-out infinite;
}
.segment-label {
font-size: var(--ha-font-size-xs);
color: var(--secondary-text-color);
@@ -482,6 +530,10 @@ export class HaBackupOverviewProgress extends LitElement {
.segment-bar.active {
animation: none;
}
.segment-fill {
animation: none;
transition: none;
}
}
.agent-list-wrapper {
display: grid;
@@ -532,6 +584,9 @@ export class HaBackupOverviewProgress extends LitElement {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
ha-list-item-base::part(end) {
gap: var(--ha-space-2);
}
ha-list-item-base::part(supporting-text) {
display: flex;
align-items: center;
@@ -10,7 +10,7 @@ import {
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type {
@@ -21,9 +21,11 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params";
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
@@ -49,6 +51,7 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
@@ -134,16 +137,30 @@ class HaBlueprintOverview extends LitElement {
private _usageCountRequest = 0;
@state() private _usageCountsLoading = false;
@state() private _selected: string[] = [];
@state() private _deletedFullpaths: string[] = [];
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _processedBlueprints = memoizeOne(
(
blueprints: Record<string, Blueprints>,
localize: LocalizeFunc,
usageCounts: Record<string, number>
usageCounts: Record<string, number>,
deleted: string[]
): BlueprintMetaDataPath[] => {
const deletedFullpaths = new Set(deleted);
const result: any[] = [];
Object.entries(blueprints).forEach(([type, typeBlueprints]) =>
Object.entries(typeBlueprints).forEach(([path, blueprint]) => {
const fullpath = `${type}/${path}`;
if (deletedFullpaths.has(fullpath)) {
return;
}
if ("error" in blueprint) {
result.push({
name: blueprint.error,
@@ -314,6 +331,13 @@ class HaBlueprintOverview extends LitElement {
}
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("blueprints") && this._deletedFullpaths.length) {
this._deletedFullpaths = [];
}
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("blueprints")) {
@@ -333,7 +357,8 @@ class HaBlueprintOverview extends LitElement {
.data=${this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts
this._usageCounts,
this._deletedFullpaths
)}
id="fullpath"
.noDataText=${this.hass.localize(
@@ -342,6 +367,9 @@ class HaBlueprintOverview extends LitElement {
has-fab
clickable
@row-click=${this._handleRowClicked}
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
.appendRow=${html`<div
class="mdc-data-table__cell"
style="width: 100%; text-align: center;"
@@ -378,6 +406,14 @@ class HaBlueprintOverview extends LitElement {
.path=${mdiHelpCircleOutline}
@click=${this._showHelp}
></ha-icon-button>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize(
"ui.panel.config.blueprint.overview.delete_selected"
)}
.disabled=${this._usageCountsLoading || !this._selected.length}
@click=${this._removeSelected}
></ha-assist-chip>
<ha-button slot="fab" size="l" @click=${this._addBlueprintClicked}>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
@@ -432,12 +468,14 @@ class HaBlueprintOverview extends LitElement {
}
const request = ++this._usageCountRequest;
this._usageCountsLoading = true;
const usageCounts: Record<string, number> = {};
const blueprintList = this._processedBlueprints(
this.blueprints,
this.hass.localize,
{}
{},
this._deletedFullpaths
);
await Promise.all(
@@ -463,6 +501,7 @@ class HaBlueprintOverview extends LitElement {
if (request === this._usageCountRequest) {
this._usageCounts = usageCounts;
this._usageCountsLoading = false;
}
}
@@ -470,7 +509,8 @@ class HaBlueprintOverview extends LitElement {
const blueprint = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts
this._usageCounts,
this._deletedFullpaths
).find((b) => b.fullpath === ev.detail.id)!;
if (blueprint.error) {
showAlertDialog(this, {
@@ -495,7 +535,8 @@ class HaBlueprintOverview extends LitElement {
const blueprint = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts
this._usageCounts,
this._deletedFullpaths
).find((item) => item.fullpath === fullpath);
if (!blueprint || blueprint.error) {
return;
@@ -652,6 +693,102 @@ class HaBlueprintOverview extends LitElement {
fireEvent(this, "reload-blueprints");
};
private _handleSelectionChanged = (
ev: HASSDomEvent<SelectionChangedEvent>
): void => {
this._selected = ev.detail.value;
};
private _removeSelected = async () => {
const rows = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts,
this._deletedFullpaths
);
const selectedRows = rows.filter((row) =>
this._selected.includes(row.fullpath)
);
const usageChecks = await Promise.allSettled(
selectedRows.map((row) =>
findRelated(this.hass, `${row.type}_blueprint`, row.path)
)
);
if (usageChecks.some((check) => check.status === "rejected")) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_check_failed_title"
),
text: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_check_failed"
),
});
return;
}
const deletable = selectedRows.filter((_row, index) => {
const check = usageChecks[index];
return (
check.status === "fulfilled" &&
!check.value.automation?.length &&
!check.value.script?.length
);
});
if (!deletable.length) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_none_deletable_title"
),
text: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_none_deletable"
),
});
return;
}
const allDeletable = deletable.length === selectedRows.length;
let deleteFailed = false;
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.confirm_delete_selected_title"
),
text: this.hass.localize(
allDeletable
? "ui.panel.config.blueprint.overview.confirm_delete_selected"
: "ui.panel.config.blueprint.overview.confirm_delete_selected_partly",
allDeletable
? { count: deletable.length }
: { deletable: deletable.length, selected: selectedRows.length }
),
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
action: async () => {
const results = await Promise.allSettled(
deletable.map((row) => deleteBlueprint(this.hass, row.type, row.path))
);
const deleted = new Set(
deletable
.filter((_row, index) => results[index].status === "fulfilled")
.map((row) => row.fullpath)
);
this._deletedFullpaths = [...this._deletedFullpaths, ...deleted];
deleteFailed = hasRejectedItems(results);
this._dataTable.clearSelection();
fireEvent(this, "reload-blueprints");
},
}).then((confirmed) => {
if (confirmed && deleteFailed) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_failed_title"
),
text: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_failed"
),
});
}
});
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
+10
View File
@@ -41,6 +41,7 @@ import {
mdiZWave,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import { haLora } from "../../resources/ha-icons";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../types";
import { mdiMqttLogo } from "../../resources/mqtt-logo-svg";
@@ -167,6 +168,15 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "knx",
adminOnly: true,
},
{
path: "/config/lorawan",
iconPath: haLora,
iconViewBox: "0 0 250 166",
iconColor: "#009fe3",
component: "lorawan",
translationKey: "lorawan",
adminOnly: true,
},
{
path: "/config/mqtt",
iconPath: mdiMqttLogo,
@@ -5,7 +5,10 @@ import {
mdiLocationExit,
mdiRefresh,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type {
HassEntities,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -13,11 +16,16 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-spinner";
import type { ManagerState } from "../../../data/backup_manager";
import { subscribeBackupEvents } from "../../../data/backup_manager";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { extractApiErrorMessage } from "../../../data/hassio/common";
@@ -44,6 +52,7 @@ import {
} from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import "../dashboard/ha-config-updates";
@@ -60,13 +69,15 @@ const SYSTEM_KEY = "__system__";
const APPS_KEY = "__apps__";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
class HaConfigSectionUpdates extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _showSkipped = false;
@state() private _backupState?: ManagerState;
@state() private _supervisorInfo?: HassioSupervisorInfo;
@state() private _entitySources?: EntitySources;
@@ -129,6 +140,62 @@ class HaConfigSectionUpdates extends LitElement {
this.requestUpdate();
}
protected hassSubscribe(): Promise<UnsubscribeFunc>[] {
if (!this._watchesBackups()) {
return [];
}
return [
subscribeBackupEvents(this.hass, (event) => {
this._backupState = event.manager_state;
}).catch(() => {
// Show the page anyway; a blocked update reports its own error.
this._backupState = "idle";
return () => undefined;
}),
];
}
// The Supervisor only holds back the updates it manages.
private _watchesBackups(): boolean {
return (
isComponentLoaded(this.hass.config, "backup") &&
isComponentLoaded(this.hass.config, "hassio")
);
}
private _backupStateLoading(): boolean {
return this._backupState === undefined && this._watchesBackups();
}
private _backupInProgressAlert() {
switch (this._backupState) {
case "create_backup":
case "receive_backup":
case "restore_backup":
return html`<ha-alert
alert-type="info"
.title=${this.hass.localize(
`ui.panel.config.updates.backup_in_progress.title.${this._backupState}`
)}
>
${this.hass.localize(
`ui.panel.config.updates.backup_in_progress.${this._backupState}`,
{
backups_link: html`<a href="/config/backup"
>${this.hass.localize(
"ui.panel.config.updates.backup_in_progress.backups_link"
)}</a
>`,
}
)}
</ha-alert>`;
default:
return undefined;
}
}
protected render(): TemplateResult {
const installableUpdates = this._filterInstallableUpdateEntities(
this.hass.states,
@@ -153,6 +220,8 @@ class HaConfigSectionUpdates extends LitElement {
this.hass.locale.language
);
const backupInProgressAlert = this._backupInProgressAlert();
return html`
<hass-subpage
back-path="/config/system"
@@ -207,108 +276,143 @@ class HaConfigSectionUpdates extends LitElement {
}
</ha-dropdown>
</div>
<div class="content">
${groups.map(
(group) => html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${group.title}
</div>
${
group.showUpdateButton
? html`
<ha-button
appearance="plain"
size="s"
.group=${group}
.disabled=${group.entities.every((entity) =>
updateIsInstalling(entity)
)}
@click=${this._updateGroup}
>
${
this._backupStateLoading()
? html`
<ha-fade-in class="loading" .delay=${1000}>
<ha-spinner></ha-spinner>
</ha-fade-in>
`
: html`
<div class="content">
${
backupInProgressAlert &&
groups.some(
(group) =>
group.key === APPS_KEY || group.key === SYSTEM_KEY
)
? backupInProgressAlert
: nothing
}
${groups.map(
(group) => html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${group.title}
</div>
${
group.showUpdateButton
? html`
<ha-button
appearance="plain"
size="s"
.group=${group}
.disabled=${
(!!backupInProgressAlert &&
group.key === APPS_KEY) ||
group.entities.every((entity) =>
updateIsInstalling(entity)
)
}
@click=${this._updateGroup}
>
${this.hass.localize(
group.entities.length > 1
? "ui.panel.config.updates.update_all"
: "ui.common.update"
)}
</ha-button>
`
: nothing
}
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${group.entities}
showAll
></ha-config-updates>
</div>
</ha-card>
`
)}
${
skippedUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div
class="title"
role="heading"
aria-level="2"
>
${this.hass.localize(
"ui.panel.config.updates.title_skipped",
{
count: skippedUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${skippedUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div
class="title"
role="heading"
aria-level="2"
>
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${notInstallableUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
groups.length +
notInstallableUpdates.length +
skippedUpdates.length
? nothing
: html`
<ha-card outlined>
<div class="no-updates">
${this.hass.localize(
group.entities.length > 1
? "ui.panel.config.updates.update_all"
: "ui.common.update"
"ui.panel.config.updates.no_updates"
)}
</ha-button>
`
: nothing
}
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${group.entities}
showAll
></ha-config-updates>
</div>
</ha-card>
`
}
</div>
</ha-card>
`
)}
${
skippedUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_skipped",
{
count: skippedUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${skippedUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${notInstallableUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
groups.length + notInstallableUpdates.length + skippedUpdates.length
? nothing
: html`
<ha-card outlined>
<div class="no-updates">
${this.hass.localize("ui.panel.config.updates.no_updates")}
</div>
</ha-card>
`
}
</div>
`
}
</hass-subpage>
`;
}
@@ -516,6 +620,16 @@ class HaConfigSectionUpdates extends LitElement {
ha-config-updates {
margin-bottom: 8px;
}
ha-alert {
display: block;
max-width: 600px;
margin: 0 auto var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-8) 0;
}
.card-content {
display: flex;
@@ -62,13 +62,9 @@ export class HaDeviceCard extends LitElement {
return html`
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.device_info", {
type: this.hass.localize(
`ui.panel.config.devices.type.${
this.device.entry_type || "device"
}_heading`
),
})}
.header=${this.hass.localize(
`ui.panel.config.devices.${this.device.entry_type || "device"}_info_heading`
)}
>
<div class="card-content">
${
@@ -0,0 +1,58 @@
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../../components/ha-expansion-panel";
import type { DeviceRegistryEntry } from "../../../../../../data/device/device_registry";
import type { LoRaWANDevice } from "../../../../../../data/lorawan";
import { subscribeLoRaWANDevices } from "../../../../../../data/lorawan";
import { SubscribeMixin } from "../../../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../../../types";
import "../../../../integrations/integration-panels/lorawan/lorawan-device-details";
@customElement("ha-device-info-lorawan")
export class HaDeviceInfoLoRaWAN extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@state() private _device?: LoRaWANDevice;
protected hassSubscribeRequiredHostProps = ["device"];
private _devices: LoRaWANDevice[] = [];
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has("device")) {
this._device = this._devices.find(
(device) => device.device_id === this.device.id
);
}
}
protected hassSubscribe() {
return [
subscribeLoRaWANDevices(this.hass.connection, ({ devices }) => {
this._devices = devices;
this._device = devices.find(
(device) => device.device_id === this.device.id
);
}),
];
}
protected render() {
return this._device
? html`<ha-expansion-panel
.header=${this.hass.localize("ui.panel.config.lorawan.device_info")}
><lorawan-device-details
.device=${this._device}
></lorawan-device-details
></ha-expansion-panel>`
: nothing;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-info-lorawan": HaDeviceInfoLoRaWAN;
}
}
@@ -1511,6 +1511,22 @@ export class HaConfigDevicePage extends LitElement {
deviceInfo: TemplateResult[]
) {
const domains = integrations.map((int) => int.domain);
if (
this.hass.user?.is_admin &&
this.manifests.some(
(manifest) =>
domains.includes(manifest.domain) &&
manifest.dependencies?.includes("lorawan")
)
) {
import("./device-detail/integration-elements/lorawan/ha-device-info-lorawan");
deviceInfo.push(
html`<ha-device-info-lorawan
.hass=${this.hass}
.device=${device}
></ha-device-info-lorawan>`
);
}
if (domains.includes("zha")) {
import("./device-detail/integration-elements/zha/ha-device-info-zha");
deviceInfo.push(html`
+5
View File
@@ -196,6 +196,11 @@ class HaPanelConfig extends HassRouterPage {
load: () =>
import("./integrations/integration-panels/zha/zha-config-dashboard-router"),
},
lorawan: {
tag: "lorawan-router",
load: () =>
import("./integrations/integration-panels/lorawan/lorawan-router"),
},
mqtt: {
tag: "mqtt-config-panel",
load: () =>
@@ -664,8 +664,13 @@ export class HaConfigEntryRow extends LitElement {
const locale_key = result.require_restart
? "reload_restart_confirm"
: "reload_confirm";
showAlertDialog(this, {
text: this.hass.localize(
// The row can move between sections when the entry state changes on reload
const host =
(this.isConnected
? this
: document.querySelector<HTMLElement>("home-assistant")) ?? this;
showToast(host, {
message: this.hass.localize(
`ui.panel.config.integrations.config_entry.${locale_key}`
),
});
@@ -162,7 +162,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@state() private _logInfo?: IntegrationLogInfo;
@state() private _searchParms = new URLSearchParams(
window.location.hash.substring(1)
__DEMO__ ? window.location.search : window.location.hash.substring(1)
);
@state() private _filter = "";
@@ -177,7 +177,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
private _configEntryRows!: NodeListOf<HaConfigEntryRow>;
private _handleHashChange = () => {
this._searchParms = new URLSearchParams(window.location.hash.substring(1));
this._searchParms = new URLSearchParams(
__DEMO__ ? window.location.search : window.location.hash.substring(1)
);
};
private _domainConfigEntries = memoizeOne(
@@ -0,0 +1,33 @@
import type { LoRaWANConnection } from "../../../../../data/lorawan";
export const connectionLabels = (connections: LoRaWANConnection[]) => {
const connectionsByTitle = new Map<string, LoRaWANConnection[]>();
for (const connection of connections) {
const matching = connectionsByTitle.get(connection.title);
if (matching) {
matching.push(connection);
} else {
connectionsByTitle.set(connection.title, [connection]);
}
}
const usedLabels = new Set(connectionsByTitle.keys());
const labels = new Map<string, string>();
for (const [title, matching] of connectionsByTitle) {
if (matching.length === 1) {
labels.set(matching[0].entry_id, title);
continue;
}
let index = 1;
for (const connection of matching.sort((a, b) =>
a.entry_id.localeCompare(b.entry_id)
)) {
let label: string;
do {
label = `${title} (${index++})`;
} while (usedLabels.has(label));
usedLabels.add(label);
labels.set(connection.entry_id, label);
}
}
return labels;
};
@@ -0,0 +1,145 @@
import type { ContextType } from "@lit/context";
import { mdiClose, mdiOpenInNew } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../../../common/decorators/consume";
import type { HASSDomCurrentTargetEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-button-prev";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import {
connectionContext,
internationalizationContext,
uiContext,
} from "../../../../../data/context";
import type { LoRaWANConnection } from "../../../../../data/lorawan";
import { DialogMixin } from "../../../../../dialogs/dialog-mixin";
import { brandsUrl } from "../../../../../util/brands-url";
@customElement("dialog-lorawan-add-device")
class DialogLoRaWANAddDevice extends DialogMixin<{
connections: LoRaWANConnection[];
}>(LitElement) {
@state() private _selected?: LoRaWANConnection;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
private _ui!: ContextType<typeof uiContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
protected render() {
if (!this.params) return nothing;
const selected = this._selected;
return html`<ha-dialog
open
type="alert"
class=${selected ? "" : "connection-picker"}
.headerTitle=${this._i18n.localize(
selected
? "ui.panel.config.lorawan.add_device"
: "ui.panel.config.lorawan.add_device_dialog.choose_connection"
)}
.headerSubtitle=${selected?.title}
>
${selected ? html`<ha-icon-button-prev slot="headerNavigationIcon" .label=${this._i18n.localize("ui.common.back")} @click=${this._back}></ha-icon-button-prev>` : html`<span slot="headerNavigationIcon"></span>`}
<ha-icon-button
slot="headerActionItems"
.path=${mdiClose}
.label=${this._i18n.localize("ui.common.close")}
@click=${this.closeDialog}
></ha-icon-button>
${
selected
? html`<p>
${this._i18n.localize(
selected.configuration_url
? "ui.panel.config.lorawan.add_device_dialog.instructions"
: "ui.panel.config.lorawan.add_device_dialog.no_console"
)}
</p>`
: html`
<ha-list-nav
.ariaLabel=${this._i18n.localize("ui.panel.config.lorawan.connection")}
>
${this.params.connections.map(
(connection) =>
html`<ha-list-item-button
.connection=${connection}
@click=${this._selectConnection}
>
<img
slot="start"
alt=""
width="32"
height="32"
src=${brandsUrl({ domain: connection.domain, type: "icon", darkOptimized: this._ui.themes.darkMode }, this._connection.hassUrl())}
/>
<span slot="headline">${connection.title}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
)}
</ha-list-nav>
`
}
${
selected?.configuration_url
? html`<ha-dialog-footer slot="footer">
<a
slot="primaryAction"
href=${selected.configuration_url}
target="_blank"
rel="noopener noreferrer"
>
${this._i18n.localize("ui.panel.config.lorawan.add_device_dialog.open_console")}
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</ha-dialog-footer>`
: nothing
}
</ha-dialog>`;
}
private _selectConnection(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { connection: LoRaWANConnection }
>
) {
this._selected = ev.currentTarget.connection;
}
private _back() {
this._selected = undefined;
}
static styles = css`
.connection-picker {
--dialog-content-padding: 0;
}
a {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
color: var(--primary-color);
}
a ha-svg-icon {
--mdc-icon-size: 18px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-lorawan-add-device": DialogLoRaWANAddDevice;
}
}
@@ -0,0 +1,92 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { ContextType } from "@lit/context";
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../../../common/decorators/consume";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-button";
import {
internationalizationContext,
connectionContext,
} from "../../../../../data/context";
import { subscribeLoRaWANDevices } from "../../../../../data/lorawan";
import type { LoRaWANDevice } from "../../../../../data/lorawan";
import { DialogMixin } from "../../../../../dialogs/dialog-mixin";
import "./lorawan-device-details";
@customElement("dialog-lorawan-device")
class DialogLoRaWANDevice extends DialogMixin<{ device: LoRaWANDevice }>(
LitElement
) {
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
private _unsubscribe?: Promise<UnsubscribeFunc>;
@state() private _device?: LoRaWANDevice;
protected firstUpdated() {
this._unsubscribe = subscribeLoRaWANDevices(
this._connection.connection,
({ devices }) => {
this._device = devices.find(
(device) =>
device.dev_eui === this.params!.device.dev_eui &&
device.connection.entry_id ===
this.params!.device.connection.entry_id
);
}
);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe?.then((unsubscribe) => unsubscribe());
}
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected render() {
if (!this.params) return nothing;
const device = this._device ?? this.params.device;
return html`<ha-dialog
open
width="medium"
.headerTitle=${device.name || device.dev_eui}
.headerSubtitle=${device.model}
>
${device.support === "supported" ? nothing : html`<ha-alert alert-type="info">${this._i18n.localize(`ui.panel.config.lorawan.support.${device.support}`)}</ha-alert>`}
<lorawan-device-details .device=${device}></lorawan-device-details>
<ha-dialog-footer slot="footer"
><ha-button
slot="primaryAction"
appearance="plain"
@click=${this.closeDialog}
>${this._i18n.localize("ui.common.close")}</ha-button
></ha-dialog-footer
>
</ha-dialog>`;
}
static styles = css`
ha-dialog {
--dialog-content-padding: 0;
}
ha-alert {
--ha-border-radius-sm: 0;
--ha-alert-padding: var(--ha-space-3) var(--ha-space-6);
}
lorawan-device-details {
padding: var(--ha-space-6);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-lorawan-device": DialogLoRaWANDevice;
}
}
@@ -0,0 +1,67 @@
import type { ContextType } from "@lit/context";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { navigate } from "../../../../../common/navigate";
import { isNavigationClick } from "../../../../../common/dom/is-navigation-click";
import { consume } from "../../../../../common/decorators/consume";
import { uiContext, connectionContext } from "../../../../../data/context";
import type { LoRaWANDevice } from "../../../../../data/lorawan";
import { brandsUrl } from "../../../../../util/brands-url";
@customElement("lorawan-connection-link")
export class LoRaWANConnectionLink extends LitElement {
@property({ attribute: false })
public connection!: LoRaWANDevice["connection"];
@state()
@consume({ context: uiContext, subscribe: true })
private _ui!: ContextType<typeof uiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
protected render() {
return html` <a
href=${`/config/integrations/integration/${this.connection.domain}${__DEMO__ ? "?" : "#"}config_entry=${this.connection.entry_id}`}
@click=${this._navigate}
>
<img
alt=""
src=${brandsUrl({ domain: this.connection.domain, type: "icon", darkOptimized: this._ui?.themes.darkMode }, this._connection?.hassUrl())}
/>
<span>${this.connection.title}</span>
</a>`;
}
private _navigate(ev: MouseEvent) {
ev.stopPropagation();
const path = isNavigationClick(ev);
if (path) navigate(path);
}
static styles = css`
a {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
color: var(--primary-color);
text-decoration: none;
min-height: 44px;
}
a:hover {
text-decoration: underline;
}
img {
width: 24px;
height: 24px;
object-fit: contain;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"lorawan-connection-link": LoRaWANConnectionLink;
}
}
@@ -0,0 +1,126 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-icon-next";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-base";
import type { LoRaWANDevices } from "../../../../../data/lorawan";
import { subscribeLoRaWANDevices } from "../../../../../data/lorawan";
import "../../../../../layouts/hass-subpage";
import { panelIsReady } from "../../../../../layouts/panel-ready";
import type { HomeAssistant } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
@customElement("lorawan-connections")
export class LoRaWANConnections extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _snapshot?: LoRaWANDevices;
@state() private _error = false;
private _unsubscribe?: Promise<UnsubscribeFunc>;
protected firstUpdated() {
this._subscribe();
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) this._subscribe();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe?.then((unsubscribe) => unsubscribe());
}
private _subscribe() {
this._error = false;
this._unsubscribe = subscribeLoRaWANDevices(
this.hass.connection,
(snapshot) => {
this._snapshot = snapshot;
panelIsReady(this);
}
).catch(() => {
this._error = true;
panelIsReady(this);
return () => undefined;
});
}
protected render() {
const snapshot = this._snapshot;
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.lorawan.overview.connections")}
back-path="/config/lorawan"
>
<div class="container">
${this._error ? html`<ha-alert alert-type="error">${this.hass.localize("ui.panel.config.lorawan.overview.load_error")}<ha-button slot="action" appearance="plain" @click=${this._subscribe}>${this.hass.localize("ui.common.retry")}</ha-button></ha-alert>` : nothing}
${!snapshot && !this._error ? html`<ha-spinner></ha-spinner>` : nothing}
${
snapshot
? snapshot.connections.length
? html`<ha-card
><ha-list-base>
${snapshot.connections.map(
(connection) =>
html`<ha-list-item-button
href=${`/config/lorawan/devices?connection=${connection.entry_id}`}
>
<img
slot="start"
alt=""
width="24"
height="24"
src=${brandsUrl({ domain: connection.domain, type: "icon", darkOptimized: this.hass.themes.darkMode }, this.hass.hassUrl())}
/>
<div slot="headline">${connection.title}</div>
<div slot="supporting-text">
${this.hass.localize("ui.panel.config.lorawan.overview.devices_count", { count: snapshot.devices.filter((device) => device.connection.entry_id === connection.entry_id).length })}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
)}
</ha-list-base></ha-card
>`
: html`<ha-alert alert-type="info"
>${this.hass.localize("ui.panel.config.lorawan.overview.no_connections")}</ha-alert
>`
: nothing
}
</div>
</hass-subpage>`;
}
static styles = css`
:host {
display: block;
height: 100%;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card,
ha-alert {
max-width: 600px;
margin: var(--ha-space-6) auto var(--ha-space-4);
overflow: hidden;
}
ha-spinner {
display: block;
margin: var(--ha-space-8) auto;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"lorawan-connections": LoRaWANConnections;
}
}
@@ -0,0 +1,155 @@
import type { ContextType } from "@lit/context";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../../../../common/decorators/consume";
import "../../../../../components/ha-relative-time";
import { internationalizationContext } from "../../../../../data/context";
import type { LoRaWANDevice } from "../../../../../data/lorawan";
import "./lorawan-connection-link";
@customElement("lorawan-device-details")
export class LoRaWANDeviceDetails extends LitElement {
@property({ attribute: false }) public device!: LoRaWANDevice;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _time(value: string | null) {
return value
? html`<ha-relative-time
.datetime=${value}
title=${value}
></ha-relative-time>`
: this._i18n.localize("ui.panel.config.lorawan.not_observed");
}
protected render() {
const device = this.device;
return html`
<dl>
<dt>${this._i18n.localize("ui.panel.config.lorawan.connection")}</dt>
<dd>
<lorawan-connection-link
.connection=${device.connection}
></lorawan-connection-link>
</dd>
<dt>${this._i18n.localize("ui.panel.config.lorawan.dev_eui")}</dt>
<dd class="identifier">${device.dev_eui}</dd>
<dt>${this._i18n.localize("ui.panel.config.lorawan.application")}</dt>
<dd>${device.application_id || "—"}</dd>
<dt>${this._i18n.localize("ui.panel.config.lorawan.last_uplink")}</dt>
<dd>${this._time(device.last_uplink)}</dd>
<dt>${this._i18n.localize("ui.panel.config.lorawan.last_join")}</dt>
<dd>${this._time(device.last_join)}</dd>
<dt>${this._i18n.localize("ui.panel.config.lorawan.last_status")}</dt>
<dd>${this._time(device.last_status)}</dd>
${
device.downlink_margin === null
? nothing
: html`<dt>
${this._i18n.localize("ui.panel.config.lorawan.downlink_margin")}
</dt>
<dd>${device.downlink_margin} dB</dd>`
}
</dl>
<h3>${this._i18n.localize("ui.panel.config.lorawan.gateways")}</h3>
${
device.receptions.length
? html` <table>
<thead>
<tr>
<th>
${this._i18n.localize("ui.panel.config.lorawan.gateway")}
</th>
<th>
${this._i18n.localize("ui.panel.config.lorawan.rssi")}
</th>
<th>${this._i18n.localize("ui.panel.config.lorawan.snr")}</th>
</tr>
</thead>
<tbody>
${device.receptions.map(
(reception) =>
html`<tr>
<td class="identifier">${reception.gateway_id}</td>
<td>
${reception.rssi === null ? "—" : `${reception.rssi} dBm`}
</td>
<td>
${reception.snr === null ? "—" : `${reception.snr} dB`}
</td>
</tr>`
)}
</tbody>
</table>`
: html`<p>
${this._i18n.localize("ui.panel.config.lorawan.not_observed")}
</p>`
}
`;
}
static styles = css`
:host {
display: block;
}
dl {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: var(--ha-space-6);
row-gap: var(--ha-space-3);
align-items: center;
margin: 0;
}
dt {
color: var(--secondary-text-color);
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.identifier {
font-family: var(--ha-font-family-code);
}
h3 {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
margin-top: var(--ha-space-6);
}
table {
width: 100%;
border-collapse: collapse;
}
th {
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
th,
td {
text-align: start;
padding: var(--ha-space-3) var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
th:first-child,
td:first-child {
padding-inline-start: 0;
overflow-wrap: anywhere;
}
th:not(:first-child),
td:not(:first-child) {
white-space: nowrap;
}
@media (max-width: 450px) {
dl {
column-gap: var(--ha-space-3);
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"lorawan-device-details": LoRaWANDeviceDetails;
}
}
@@ -0,0 +1,311 @@
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { mdiPowerPlug } from "@mdi/js";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { QueryParamsController } from "../../../../../common/controllers/query-params-controller";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { navigate } from "../../../../../common/navigate";
import type {
DataTableColumnContainer,
DataTableRowData,
RowClickedEvent,
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-relative-time";
import "../../../../../components/ha-svg-icon";
import type {
LoRaWANDevice,
LoRaWANConnection,
} from "../../../../../data/lorawan";
import {
strongestLoRaWANReception,
subscribeLoRaWANDevices,
} from "../../../../../data/lorawan";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { panelIsReady } from "../../../../../layouts/panel-ready";
import { loadVirtualizer } from "../../../../../resources/virtualizer";
import type { HomeAssistant, Route } from "../../../../../types";
import "./lorawan-connection-link";
import { connectionLabels } from "./connection-labels";
interface DeviceRow extends LoRaWANDevice, DataTableRowData {
id: string;
rssi: number | null;
connection_title: string;
}
@customElement("lorawan-devices")
export class LoRaWANDevices extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _connectionId = "";
private _queryParams = new QueryParamsController(
this,
{ string: ["connection"] as const },
{
enabled: () => true,
navigated: () => this._readConnectionFilter(),
}
);
private _tabs = [
{
translationKey: "ui.panel.config.lorawan.devices",
path: "/config/lorawan/devices",
},
];
@state() private _devices?: LoRaWANDevice[];
@state() private _error = false;
@state() private _connections: LoRaWANConnection[] = [];
private _unsubscribe?: Promise<UnsubscribeFunc>;
private _connectionLabels = memoizeOne(connectionLabels);
private _rows = memoizeOne(
(
devices: LoRaWANDevice[] | undefined,
connection: string | null,
labels: Map<string, string>
): DeviceRow[] =>
(devices ?? [])
.filter(
(device) => !connection || device.connection.entry_id === connection
)
.map((device) => ({
...device,
id: `${device.connection.entry_id}:${device.dev_eui}`,
rssi: strongestLoRaWANReception(device.receptions)?.rssi ?? null,
connection_title: labels.get(device.connection.entry_id)!,
}))
);
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) this._subscribe();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe?.then((unsubscribe) => unsubscribe());
this._unsubscribe = undefined;
}
protected firstUpdated() {
this._readConnectionFilter();
this._subscribe();
}
private _subscribe() {
this._error = false;
this._unsubscribe = loadVirtualizer()
.then(() =>
subscribeLoRaWANDevices(
this.hass.connection,
({ devices, connections }) => {
this._devices = devices;
this._connections = connections;
panelIsReady(this);
}
)
)
.catch(() => {
this._error = true;
panelIsReady(this);
return () => undefined;
});
}
private _columns = memoizeOne(
(
localize: LocalizeFunc,
narrow: boolean
): DataTableColumnContainer<DeviceRow> => ({
name: {
title: localize("ui.panel.config.lorawan.device"),
main: true,
showNarrow: true,
filterable: true,
sortable: true,
flex: 2,
minWidth: "220px",
template: (device) =>
html`<div>
<div class="primary">${device.name || device.dev_eui}</div>
<div class="secondary">
${[device.manufacturer, device.model].filter(Boolean).join(" · ") || device.dev_eui}
</div>
</div>`,
},
dev_eui: {
title: localize("ui.panel.config.lorawan.dev_eui"),
hidden: true,
filterable: true,
},
connection_title: {
groupable: true,
urlName: "connection",
hidden: narrow,
title: localize("ui.panel.config.lorawan.connection"),
filterable: true,
sortable: true,
minWidth: "210px",
flex: 1.5,
template: (device) =>
html`<lorawan-connection-link
.connection=${device.connection}
></lorawan-connection-link>`,
},
last_uplink: {
hidden: narrow,
title: localize("ui.panel.config.lorawan.last_uplink"),
sortable: true,
minWidth: "150px",
template: (device) => this._time(device.last_uplink, localize),
},
rssi: {
hidden: narrow,
title: localize("ui.panel.config.lorawan.rssi"),
sortable: true,
type: "numeric",
minWidth: "90px",
maxWidth: "110px",
template: (device) =>
device.rssi === null ? "—" : `${device.rssi} dBm`,
},
battery_level: {
hidden: narrow,
title: localize("ui.panel.config.lorawan.battery"),
sortable: true,
type: "numeric",
minWidth: "75px",
maxWidth: "100px",
template: (device) =>
device.external_power_source
? html`<ha-svg-icon
.path=${mdiPowerPlug}
aria-label=${localize("ui.panel.config.lorawan.external_power")}
title=${localize("ui.panel.config.lorawan.external_power")}
></ha-svg-icon>`
: device.battery_level === null
? "—"
: `${Math.round(device.battery_level)}%`,
},
})
);
private _time(timestamp: string | null, localize: LocalizeFunc) {
return timestamp
? html`<ha-relative-time
.datetime=${timestamp}
title=${timestamp}
></ha-relative-time>`
: localize("ui.panel.config.lorawan.not_observed");
}
private _readConnectionFilter() {
this._connectionId =
this._queryParams.searchParams()?.get("connection") ?? "";
}
private _connectionChanged(ev: HaSelectSelectEvent) {
this._connectionId = ev.detail.value === "all" ? "" : ev.detail.value;
this._queryParams.write({ connection: this._connectionId });
}
private _clearFilter() {
this._connectionId = "";
this._queryParams.write({ connection: "" });
}
protected render() {
const labels = this._connectionLabels(this._connections);
return html`<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
.route=${this.route}
.tabs=${this._tabs}
.columns=${this._columns(this.hass.localize, this.narrow)}
.data=${this._rows(this._devices, this._connectionId, labels)}
.loading=${this._devices === undefined && !this._error}
.loadError=${this._error}
.noDataText=${this.hass.localize("ui.panel.config.lorawan.no_devices")}
.filters=${this._connectionId ? 1 : 0}
has-filters
sync-url
clickable
back-path="/config/lorawan"
@row-click=${this._rowClicked}
@retry-load=${this._subscribe}
@clear-filter=${this._clearFilter}
>
<ha-select
slot="filter-pane"
.label=${this.hass.localize("ui.panel.config.lorawan.connection")}
.value=${this._connectionId || "all"}
.options=${[
{
value: "all",
label: this.hass.localize(
"ui.panel.config.lorawan.all_connections"
),
},
...this._connections.map((connection) => ({
value: connection.entry_id,
label: labels.get(connection.entry_id)!,
})),
]}
@selected=${this._connectionChanged}
></ha-select>
</hass-tabs-subpage-data-table>`;
}
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const device = this._devices!.find(
(candidate) =>
`${candidate.connection.entry_id}:${candidate.dev_eui}` === ev.detail.id
)!;
if (device.device_id) {
navigate(`/config/devices/device/${device.device_id}`);
return;
}
fireEvent(this, "show-dialog", {
dialogTag: "dialog-lorawan-device",
dialogImport: () => import("./dialog-lorawan-device"),
dialogParams: { device },
});
}
static styles = css`
:host {
display: block;
height: 100%;
}
hass-tabs-subpage-data-table {
display: block;
--data-table-row-height: 72px;
}
ha-select {
margin: var(--ha-space-4);
}
@media (max-width: 600px) {
hass-tabs-subpage-data-table {
--data-table-row-height: 132px;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"lorawan-devices": LoRaWANDevices;
}
}
@@ -0,0 +1,264 @@
import {
mdiCheck,
mdiDevices,
mdiPlus,
mdiLanConnect,
mdiCloseCircleOutline,
mdiVectorPolyline,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import type { LoRaWANDevices } from "../../../../../data/lorawan";
import { subscribeLoRaWANDevices } from "../../../../../data/lorawan";
import "../../../../../layouts/hass-subpage";
import { panelIsReady } from "../../../../../layouts/panel-ready";
import { haLora } from "../../../../../resources/ha-icons";
import type { HomeAssistant } from "../../../../../types";
@customElement("lorawan-overview")
export class LoRaWANOverview extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _snapshot?: LoRaWANDevices;
@state() private _error = false;
private _unsubscribe?: Promise<UnsubscribeFunc>;
protected firstUpdated() {
this._subscribe();
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) this._subscribe();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe?.then((unsubscribe) => unsubscribe());
}
private _subscribe() {
this._error = false;
this._unsubscribe = subscribeLoRaWANDevices(
this.hass.connection,
(snapshot) => {
this._snapshot = snapshot;
panelIsReady(this);
}
).catch(() => {
this._error = true;
panelIsReady(this);
return () => undefined;
});
}
protected render() {
const snapshot = this._snapshot;
const connected = !!snapshot?.connections.length;
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.lorawan.title")}
back-path="/config/connectivity"
has-fab
>
<ha-button
slot="fab"
size="l"
@click=${this._addDevice}
.disabled=${!snapshot?.connections.length}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.lorawan.add_device")}
</ha-button>
<div class="container">
${this._error ? html`<ha-alert alert-type="error">${this.hass.localize("ui.panel.config.lorawan.overview.load_error")}<ha-button slot="action" appearance="plain" @click=${this._subscribe}>${this.hass.localize("ui.common.retry")}</ha-button></ha-alert>` : nothing}
${!snapshot && !this._error ? html`<ha-spinner></ha-spinner>` : nothing}
${
snapshot
? html`
<ha-card class="summary">
<div class="summary-content">
<div
class="status-icon ${connected ? "connected" : "unavailable"}"
>
<ha-svg-icon
.path=${connected ? mdiCheck : mdiCloseCircleOutline}
></ha-svg-icon>
</div>
<div>
<h2>
${this.hass.localize("ui.panel.config.lorawan.overview.total_connections", { count: snapshot.connections.length })}
</h2>
<p>
${this.hass.localize("ui.panel.config.lorawan.overview.devices_count", { count: snapshot.devices.length })}
</p>
</div>
<ha-svg-icon
class="logo"
.path=${haLora}
.viewBox=${"0 0 250 166"}
></ha-svg-icon>
</div>
</ha-card>
<ha-card>
<div class="card-heading">
<h2>
${this.hass.localize("ui.panel.config.lorawan.overview.network")}
</h2>
<ha-button
appearance="filled"
href="/config/lorawan/topology"
><ha-svg-icon
slot="start"
.path=${mdiVectorPolyline}
></ha-svg-icon
>${this.hass.localize("ui.panel.config.lorawan.overview.show_map")}</ha-button
>
</div>
<ha-list-nav
.ariaLabel=${this.hass.localize("ui.panel.config.lorawan.overview.network")}
>
<ha-list-item-button
href="/config/lorawan/devices?group=connection"
><ha-svg-icon
slot="start"
.path=${mdiDevices}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.lorawan.overview.devices_count", { count: snapshot.devices.length })}
</div>
<ha-icon-next slot="end"></ha-icon-next
></ha-list-item-button>
<ha-list-item-button href="/config/lorawan/connections">
<ha-svg-icon
slot="start"
.path=${mdiLanConnect}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.lorawan.overview.total_connections", { count: snapshot.connections.length })}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
</ha-list-nav>
</ha-card>
`
: nothing
}
</div>
</hass-subpage>`;
}
private _addDevice() {
fireEvent(this, "show-dialog", {
dialogTag: "dialog-lorawan-add-device",
dialogImport: () => import("./dialog-lorawan-add-device"),
dialogParams: { connections: this._snapshot!.connections },
});
}
static styles = css`
:host {
display: block;
height: 100%;
}
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card {
overflow: hidden;
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
.summary {
margin-top: var(--ha-space-6);
}
.summary-content {
display: flex;
align-items: center;
gap: var(--ha-space-4);
padding: var(--ha-space-4);
}
.summary h2 {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
}
.status-icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: var(--ha-space-10);
height: var(--ha-space-10);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--success-color);
}
.status-icon.unavailable {
--icon-color: var(--error-color);
}
.status-icon::before {
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color);
opacity: 0.2;
}
.status-icon ha-svg-icon {
color: var(--icon-color);
}
.logo {
--mdc-icon-size: 40px;
margin-inline-start: auto;
flex-shrink: 0;
}
h2 {
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
margin: 0;
}
p {
color: var(--secondary-text-color);
margin: var(--ha-space-1) 0 0;
}
.card-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--ha-space-3);
padding: var(--ha-space-4);
}
ha-spinner {
display: block;
margin: var(--ha-space-8) auto;
}
ha-alert {
margin-bottom: var(--ha-space-4);
}
@media (max-width: 450px) {
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
.card-heading {
flex-wrap: wrap;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"lorawan-overview": LoRaWANOverview;
}
}
@@ -0,0 +1,47 @@
import { customElement, property } from "lit/decorators";
import type { RouterOptions } from "../../../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../../../layouts/hass-router-page";
import type { HomeAssistant } from "../../../../../types";
@customElement("lorawan-router")
class LoRaWANRouter extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
protected routerOptions: RouterOptions = {
defaultPage: "overview",
showLoading: true,
routes: {
overview: {
tag: "lorawan-overview",
load: () => import("./lorawan-overview"),
},
connections: {
tag: "lorawan-connections",
load: () => import("./lorawan-connections"),
},
devices: {
tag: "lorawan-devices",
load: () => import("./lorawan-devices"),
},
topology: {
tag: "lorawan-topology",
load: () => import("./lorawan-topology"),
},
},
};
protected updatePageEl(el): void {
el.hass = this.hass;
el.narrow = this.narrow;
el.isWide = this.isWide;
el.route = this.routeTail;
}
}
declare global {
interface HTMLElementTagNameMap {
"lorawan-router": LoRaWANRouter;
}
}
@@ -0,0 +1,377 @@
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import memoizeOne from "memoize-one";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../../../../common/dom/fire_event";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { navigate } from "../../../../../common/navigate";
import type {
NetworkData,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-relative-time";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type {
LoRaWANDevice,
LoRaWANDevices,
} from "../../../../../data/lorawan";
import { subscribeLoRaWANDevices } from "../../../../../data/lorawan";
import type { LoRaWANTopology, LoRaWANTopologyNode } from "./lorawan_topology";
import { buildLoRaWANTopology } from "./lorawan_topology";
import "../../../../../layouts/hass-subpage";
import { panelIsReady } from "../../../../../layouts/panel-ready";
import type { HomeAssistant } from "../../../../../types";
@customElement("lorawan-topology")
export class LoRaWANTopologyPanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _snapshot?: LoRaWANDevices;
@state() private _error = false;
@state() private _search = "";
private _unsubscribe?: Promise<UnsubscribeFunc>;
private _topology: LoRaWANTopology = { nodes: [], links: [] };
protected firstUpdated() {
this._subscribe();
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) this._subscribe();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe?.then((unsubscribe) => unsubscribe());
}
private _subscribe() {
this._error = false;
this._unsubscribe = subscribeLoRaWANDevices(
this.hass.connection,
(snapshot) => {
this._snapshot = snapshot;
panelIsReady(this);
}
).catch(() => {
this._error = true;
panelIsReady(this);
return () => undefined;
});
}
private _nodeName(node: LoRaWANTopologyNode) {
return node.kind === "connection"
? node.connection.title
: node.kind === "gateway"
? node.gateway_id
: node.device.name || node.device.dev_eui;
}
private _graphData = memoizeOne(
(topology: LoRaWANTopology, narrow: boolean): NetworkData => {
const style = getComputedStyle(this);
const colors = {
connection: style.getPropertyValue("--primary-color"),
gateway: style.getPropertyValue("--teal-color"),
device: style.getPropertyValue("--blue-color"),
link: style.getPropertyValue("--divider-color"),
};
const nodes: NetworkNode[] = [];
let offset = 0;
for (const root of topology.nodes.filter(
(node) => node.kind === "connection"
)) {
const children = topology.nodes.filter(
(node) => node.connection.entry_id === root.connection.entry_id
);
const gateways = children.filter((node) => node.kind === "gateway");
const devices = children.filter((node) => node.kind === "device");
const height = Math.max(devices.length, gateways.length, 1) * 100;
for (const node of children) {
const index =
node.kind === "connection"
? 0
: node.kind === "gateway"
? gateways.indexOf(node)
: devices.indexOf(node);
const count =
node.kind === "connection"
? 1
: node.kind === "gateway"
? gateways.length
: devices.length;
nodes.push({
id: node.id,
name: this._nodeName(node),
context: node.kind === "device" ? node.device.model : undefined,
symbol:
node.kind === "connection"
? "roundRect"
: node.kind === "gateway"
? "diamond"
: "circle",
symbolSize: node.kind === "device" ? 20 : 30,
itemStyle: {
color: colors[node.kind],
},
label: {
position:
narrow && node.kind === "device"
? "top"
: narrow && node.kind === "gateway"
? "left"
: "right",
},
fixed: node.kind === "connection",
x:
node.kind === "connection"
? 0
: node.kind === "gateway"
? 320
: 640,
y: offset + ((index + 0.5) * height) / count,
});
}
offset += height + 120;
}
return {
nodes,
links: topology.links.map((link) => ({
source: link.source,
target: link.target,
symbol: "none",
value: link.reception?.rssi ?? undefined,
lineStyle: {
color:
link.kind === "membership"
? colors.connection
: link.kind === "unobserved"
? colors.link
: colors.gateway,
width: link.kind === "membership" ? 2 : 1.5,
type: link.kind === "unobserved" ? "dashed" : "solid",
},
})),
};
}
);
protected willUpdate(changed: PropertyValues) {
if (
changed.has("hass") &&
changed.get("hass")?.themes !== this.hass.themes
) {
this._graphData.clear();
}
if (changed.has("_snapshot")) {
this._topology = buildLoRaWANTopology(
this._snapshot ?? { connections: [], devices: [] }
);
}
}
protected render() {
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.lorawan.topology.title")}
.scrollable=${false}
back-path="/config/lorawan"
>
${this.narrow ? html`<div slot="header">${this._renderSearch()}</div>` : nothing}
${
this._error
? html`<ha-alert alert-type="error"
>${this.hass.localize("ui.panel.config.lorawan.topology.load_error")}<ha-button
slot="action"
appearance="plain"
@click=${this._subscribe}
>${this.hass.localize("ui.common.retry")}</ha-button
></ha-alert
>`
: !this._snapshot
? html`<ha-spinner></ha-spinner>`
: !this._snapshot.connections.length
? html`<ha-alert alert-type="info"
>${this.hass.localize("ui.panel.config.lorawan.topology.no_connections")}</ha-alert
>`
: html`<div class="map">
<ha-network-graph
.data=${this._graphData(this._topology, this.narrow)}
.searchFilter=${this._search}
.tooltipFormatter=${this._tooltip}
@chart-click=${this._chartClicked}
>${!this.narrow ? this._renderSearch("search") : nothing}</ha-network-graph
>
<div class="legend">
<span
><i class="connection-marker"></i
>${this.hass.localize("ui.panel.config.lorawan.topology.connection")}</span
>
<span
><i class="gateway-marker"></i
>${this.hass.localize("ui.panel.config.lorawan.topology.gateway")}</span
>
<span
><i class="device-marker"></i
>${this.hass.localize("ui.panel.config.lorawan.topology.device")}</span
>
</div>
</div>`
}
</hass-subpage>`;
}
private _renderSearch(slot = "") {
return html`<ha-input-search
appearance="outlined"
slot=${slot}
.value=${this._search}
.placeholder=${this.hass.localize("ui.panel.config.lorawan.topology.search")}
@input=${this._searchChanged}
></ha-input-search>`;
}
private _searchChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
this._search = ev.target.value ?? "";
}
private _tooltip = (params: TopLevelFormatterParams) => {
const item = params as CallbackDataParams;
const data = item.data;
if (!data || typeof data !== "object") return nothing;
if (item.dataType === "edge" && "source" in data && "target" in data) {
const link = this._topology.links.find(
(candidate) =>
candidate.source === data.source && candidate.target === data.target
);
if (!link) return nothing;
if (link.kind === "unobserved") {
return html`${this.hass.localize("ui.panel.config.lorawan.topology.unobserved")}`;
}
if (!link.reception || !link.device) return nothing;
return html`<strong
>${link.reception.gateway_id} →
${link.device.name || link.device.dev_eui}</strong
><br />${this.hass.localize("ui.panel.config.lorawan.rssi")}:
${link.reception.rssi === null ? "—" : `${link.reception.rssi} dBm`}<br />${this.hass.localize("ui.panel.config.lorawan.snr")}:
${link.reception.snr === null ? "—" : `${link.reception.snr} dB`}${link.device.last_uplink ? html`<br />${this.hass.localize("ui.panel.config.lorawan.last_uplink")}: <ha-relative-time .datetime=${link.device.last_uplink}></ha-relative-time>` : nothing}`;
}
if (!("id" in data)) return nothing;
const node = this._topology.nodes.find(
(candidate) => candidate.id === data.id
);
return node
? html`<strong>${this._nodeName(node)}</strong
><br />${node.connection.title}`
: nothing;
};
private _chartClicked(ev: HASSDomEvent<HASSDomEvents["chart-click"]>) {
const data = ev.detail.data;
if (!data || typeof data !== "object" || !("id" in data)) return;
const node = this._topology.nodes.find(
(candidate) => candidate.id === data.id
);
if (node?.kind === "device") this._openDevice(node.device);
else if (node?.kind === "connection") {
navigate(
`/config/integrations/integration/${node.connection.domain}${__DEMO__ ? "?" : "#"}config_entry=${node.connection.entry_id}`
);
}
}
private _openDevice(device: LoRaWANDevice) {
if (device.device_id) {
navigate(`/config/devices/device/${device.device_id}`);
return;
}
fireEvent(this, "show-dialog", {
dialogTag: "dialog-lorawan-device",
dialogImport: () => import("./dialog-lorawan-device"),
dialogParams: { device },
});
}
static styles = css`
:host {
display: block;
height: 100%;
}
[slot="header"] {
display: flex;
align-items: center;
}
ha-input-search {
flex: 1;
}
.map {
position: relative;
height: 100%;
}
ha-network-graph {
height: 100%;
}
.legend {
position: absolute;
bottom: 0;
inset-inline: 0;
pointer-events: none;
display: flex;
gap: var(--ha-space-4);
justify-content: center;
padding: 0 var(--ha-space-3) var(--ha-space-4);
font-size: var(--ha-font-size-s);
}
.legend span {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
}
.legend i {
width: 12px;
height: 12px;
display: inline-block;
}
.connection-marker {
background: var(--primary-color);
border-radius: 3px;
}
.gateway-marker {
background: var(--teal-color);
transform: rotate(45deg);
}
.device-marker {
background: var(--blue-color);
border-radius: 50%;
}
ha-spinner {
display: block;
margin: var(--ha-space-8) auto;
}
ha-alert {
margin: var(--ha-space-4);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"lorawan-topology": LoRaWANTopologyPanel;
}
}
@@ -0,0 +1,91 @@
import type {
LoRaWANConnection,
LoRaWANDevice,
LoRaWANDevices,
LoRaWANReception,
} from "../../../../../data/lorawan";
export type LoRaWANTopologyNode =
| { id: string; kind: "connection"; connection: LoRaWANConnection }
| {
id: string;
kind: "gateway";
connection: LoRaWANConnection;
gateway_id: string;
}
| {
id: string;
kind: "device";
connection: LoRaWANConnection;
device: LoRaWANDevice;
};
export interface LoRaWANTopologyLink {
source: string;
target: string;
kind: "membership" | "reception" | "unobserved";
reception?: LoRaWANReception;
device?: LoRaWANDevice;
}
export interface LoRaWANTopology {
nodes: LoRaWANTopologyNode[];
links: LoRaWANTopologyLink[];
}
export const buildLoRaWANTopology = (
snapshot: LoRaWANDevices
): LoRaWANTopology => {
const nodes: LoRaWANTopologyNode[] = [];
const links: LoRaWANTopologyLink[] = [];
const gateways = new Set<string>();
for (const connection of snapshot.connections) {
nodes.push({
id: `connection:${connection.entry_id}`,
kind: "connection",
connection,
});
}
for (const device of snapshot.devices) {
const connection = device.connection;
const connectionId = `connection:${connection.entry_id}`;
const deviceId = `device:${connection.entry_id}:${device.dev_eui}`;
nodes.push({ id: deviceId, kind: "device", connection, device });
const receptions = device.receptions.filter(
(reception) => reception.gateway_id
);
for (const reception of receptions) {
const gatewayId = `gateway:${connection.entry_id}:${reception.gateway_id}`;
if (!gateways.has(gatewayId)) {
gateways.add(gatewayId);
nodes.push({
id: gatewayId,
kind: "gateway",
connection,
gateway_id: reception.gateway_id,
});
links.push({
source: connectionId,
target: gatewayId,
kind: "membership",
});
}
links.push({
source: gatewayId,
target: deviceId,
kind: "reception",
reception,
device,
});
}
if (!receptions.length) {
links.push({
source: connectionId,
target: deviceId,
kind: "unobserved",
device,
});
}
}
return { nodes, links };
};
@@ -3,8 +3,8 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../../../common/dom/fire_event";
import type { HomeAssistant } from "../../../../../../types";
import "../../../../../../components/ha-alert";
import "../../../../../../components/ha-icon-next";
import "../../../../../../components/ha-secure-connection-alert";
import "../../../../../../components/item/ha-list-item-button";
import "../../../../../../components/list/ha-list-base";
@@ -19,11 +19,9 @@ export class ZWaveJsAddNodeSelectMethod extends LitElement {
return html`
${
!this.hideQrWebcam && !window.isSecureContext
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.select_method.webcam_unsupported"
)}</ha-alert
>`
? html`<ha-secure-connection-alert
feature="camera"
></ha-secure-connection-alert>`
: nothing
}
<ha-list-base>
@@ -34,7 +34,6 @@ import type {
ZwaveJSNodeMetadata,
} from "../../../../../data/zwave_js";
import {
computeSetConfigParamStatus,
fetchZwaveNodeCapabilities,
fetchZwaveNodeConfigParameters,
fetchZwaveNodeMetadata,
@@ -62,7 +61,7 @@ const icons = {
};
interface ConfigParamResult {
status: ZWaveJSSetConfigParamStatus;
status: ZWaveJSSetConfigParamStatus | "error";
error?: string;
}
@@ -691,7 +690,7 @@ class ZWaveJSNodeConfig extends LitElement {
);
this._config![target.key].value = value;
this._setResult(target.key, computeSetConfigParamStatus(result.status));
this._setResult(target.key, result.status);
} catch (err: any) {
this._setError(target.key, err.message);
}
+77 -10
View File
@@ -16,16 +16,20 @@ import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import type {
FlattenObjectKeys,
LocalizeFunc,
} from "../../../common/translations/localize";
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/chips/ha-assist-chip";
import "../../../components/ha-dropdown";
import type {
HaDropdown,
@@ -52,6 +56,7 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route, TranslationDict } from "../../../types";
import {
getCreatedAtTableColumn,
@@ -146,6 +151,11 @@ export class HaConfigLabels extends LitElement {
private _openingOverflow = false;
@state() private _selected: string[] = [];
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _columns = memoizeOne((localize: LocalizeFunc, narrow: boolean) => {
const columns: DataTableColumnContainer<LabelRegistryEntry> = {
icon: {
@@ -273,6 +283,9 @@ export class HaConfigLabels extends LitElement {
@row-click=${this._editLabel}
clickable
id="label_id"
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
>
<ha-icon-button
slot="toolbar-icon"
@@ -280,6 +293,12 @@ export class HaConfigLabels extends LitElement {
.label=${this.hass.localize("ui.common.help")}
.path=${mdiHelpCircleOutline}
></ha-icon-button>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize("ui.panel.config.labels.delete_selected")}
.disabled=${!this._selected.length}
@click=${this._deleteSelected}
></ha-assist-chip>
<ha-button slot="fab" size="l" @click=${this._addLabel}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.labels.add_label")}
@@ -300,7 +319,7 @@ export class HaConfigLabels extends LitElement {
`
)}
<wa-divider></wa-divider>
<ha-dropdown-item variant="danger" value="remove">
<ha-dropdown-item variant="danger" value="delete">
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
${this.hass.localize("ui.common.delete")}
</ha-dropdown-item>
@@ -342,7 +361,7 @@ export class HaConfigLabels extends LitElement {
updateEntry: entry
? (values) => this._updateLabel(entry, values)
: undefined,
removeEntry: entry ? () => this._removeLabel(entry) : undefined,
removeEntry: entry ? () => this._deleteLabel(entry) : undefined,
});
}
@@ -369,21 +388,21 @@ export class HaConfigLabels extends LitElement {
return updated;
}
private _handleRemoveLabelClick = () => {
this._removeLabel(this._overflowLabel);
private _handleDeleteLabelClick = () => {
this._deleteLabel(this._overflowLabel);
};
private async _removeLabel(selectedLabel: LabelRegistryEntry) {
private async _deleteLabel(selectedLabel: LabelRegistryEntry) {
if (
!(await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.labels.confirm_remove_title"
"ui.panel.config.labels.confirm_delete_title"
),
text: this.hass.localize("ui.panel.config.labels.confirm_remove", {
text: this.hass.localize("ui.panel.config.labels.confirm_delete", {
label: selectedLabel.name || selectedLabel.label_id,
}),
dismissText: this.hass!.localize("ui.common.cancel"),
confirmText: this.hass!.localize("ui.common.remove"),
confirmText: this.hass!.localize("ui.common.delete"),
destructive: true,
}))
) {
@@ -400,6 +419,54 @@ export class HaConfigLabels extends LitElement {
}
}
private _handleSelectionChanged = (
ev: HASSDomEvent<SelectionChangedEvent>
): void => {
this._selected = ev.detail.value;
};
private _deleteSelected = () => {
let deleteFailed = false;
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.labels.confirm_delete_selected_title"
),
text: this.hass.localize(
"ui.panel.config.labels.confirm_delete_selected",
{ count: this._selected.length }
),
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
action: async () => {
const results = await Promise.allSettled(
this._selected.map((labelId) =>
deleteLabelRegistryEntry(this.hass, labelId)
)
);
const deletedIds = new Set(
this._selected.filter(
(_id, index) => results[index].status === "fulfilled"
)
);
this._labels = this._labels.filter(
(label) => !deletedIds.has(label.label_id)
);
deleteFailed = hasRejectedItems(results);
this._dataTable.clearSelection();
},
}).then((confirmed) => {
if (confirmed && deleteFailed) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.labels.delete_failed_title"
),
text: this.hass.localize("ui.panel.config.labels.delete_failed"),
});
}
});
};
private _handleOverflowAction = (ev: HaDropdownSelectEvent) => {
const action = ev.detail.item.value;
@@ -422,8 +489,8 @@ export class HaConfigLabels extends LitElement {
case "navigate-scripts":
this._navigateConfig("/config/script/dashboard");
break;
case "remove":
this._handleRemoveLabelClick();
case "delete":
this._handleDeleteLabelClick();
break;
}
};
@@ -9,17 +9,21 @@ import {
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoize from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import { stringCompare } from "../../../../common/string/compare";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { hasRejectedItems } from "../../../../common/util/promise-all-settled-results";
import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../../components/data-table/ha-data-table";
import "../../../../components/chips/ha-assist-chip";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dropdown";
@@ -60,6 +64,7 @@ import {
import type { WindowWithPreloads } from "../../../../data/preloads";
import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../../types";
import { loadLovelaceResources } from "../../../lovelace/common/load-resources";
import { loadDashboardStrategyWithCreateSuggestions } from "../../../lovelace/strategies/get-strategy";
@@ -105,6 +110,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
@state() private _loadFailed = false;
@state() private _selected: string[] = [];
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
@state()
@storage({
storage: "sessionStorage",
@@ -431,6 +441,9 @@ export class HaConfigLovelaceDashboards extends LitElement {
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
@row-click=${this._handleRowClicked}
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
.loadError=${
this._loadFailed
? this.hass.localize(
@@ -457,6 +470,14 @@ export class HaConfigLovelaceDashboards extends LitElement {
</ha-dropdown-item>
</a>
</ha-dropdown>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize(
"ui.panel.config.lovelace.dashboards.delete_selected"
)}
.disabled=${!this._deletableSelected().length}
@click=${this._removeSelected}
></ha-assist-chip>
<ha-button slot="fab" size="l" @click=${this._addDashboard}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
@@ -718,6 +739,71 @@ export class HaConfigLovelaceDashboards extends LitElement {
}
}
private _deletableSelected = () =>
this._dashboards.filter(
(dashboard) =>
this._selected.includes(dashboard.url_path) &&
dashboard.mode === "storage"
);
private _handleSelectionChanged = (
ev: HASSDomEvent<SelectionChangedEvent>
): void => {
this._selected = ev.detail.value;
};
private _removeSelected = () => {
const deletable = this._deletableSelected();
const allDeletable = deletable.length === this._selected.length;
let deleteFailed = false;
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.confirm_delete_selected_title"
),
text: this.hass.localize(
allDeletable
? "ui.panel.config.lovelace.dashboards.confirm_delete_selected"
: "ui.panel.config.lovelace.dashboards.confirm_delete_selected_partly",
allDeletable
? { count: deletable.length }
: { deletable: deletable.length, selected: this._selected.length }
),
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
action: async () => {
const results = await Promise.allSettled(
deletable.map((dashboard) =>
deleteDashboard(this.hass!, dashboard.id)
)
);
const deletedUrlPaths = new Set(
deletable
.filter(
(_dashboard, index) => results[index].status === "fulfilled"
)
.map((dashboard) => dashboard.url_path)
);
this._dashboards = this._dashboards.filter(
(dashboard) => !deletedUrlPaths.has(dashboard.url_path)
);
deleteFailed = hasRejectedItems(results);
this._dataTable.clearSelection();
},
}).then((confirmed) => {
if (confirmed && deleteFailed) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.delete_failed_title"
),
text: this.hass.localize(
"ui.panel.config.lovelace.dashboards.delete_failed"
),
});
}
});
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
@@ -0,0 +1,46 @@
export interface DebugConsoleRun {
id: number;
code: string;
}
/**
* The code a console is running. Runs go on side by side, like in the console
* of a web browser, so code that waits does not hold up the next.
*/
export class DebugConsoleRuns {
public pending: DebugConsoleRun[] = [];
private _nextId = 0;
// Bumped by clearing the log, so runs started before it do not refill it.
private _generation = 0;
constructor(private readonly _changed: () => void) {}
/**
* Run code, and return its result, or nothing when the log got cleared in
* the meantime.
*/
public async run<T>(
code: string,
execute: (code: string) => Promise<T>
): Promise<T | undefined> {
const run = { id: this._nextId++, code };
const generation = this._generation;
this.pending = [...this.pending, run];
this._changed();
try {
const result = await execute(code);
return generation === this._generation ? result : undefined;
} finally {
this.pending = this.pending.filter((pending) => pending !== run);
this._changed();
}
}
/** Forget the results of runs still going, as the log they belong to is gone. */
public clear() {
this._generation++;
}
}
@@ -0,0 +1,68 @@
// What the debug console remembers in the browser, per user, so accounts taking
// turns in the same browser share nothing. All of it is a convenience: without
// storage, the console works and forgets it on reload.
const STORAGE_KEY_HISTORY = "tools-debug-console-history";
const STORAGE_KEY_ALLOW_PASTING = "tools-debug-console-allow-pasting";
export const MAX_HISTORY = 100;
/** Return the code the user ran before, oldest first. */
export const loadHistory = (userId: string): string[] => {
try {
const stored = JSON.parse(
window.localStorage.getItem(`${STORAGE_KEY_HISTORY}-${userId}`) ?? "[]"
);
return Array.isArray(stored) ? stored : [];
} catch (_err: unknown) {
return [];
}
};
/** Add code the user ran to the history, and return the new history. */
export const addToHistory = (
userId: string,
history: string[],
code: string
): string[] => {
// Running the same code again in a row adds nothing new to browse back to.
const updated =
history[history.length - 1] === code
? history
: [...history, code].slice(-MAX_HISTORY);
try {
window.localStorage.setItem(
`${STORAGE_KEY_HISTORY}-${userId}`,
JSON.stringify(updated)
);
} catch (_err: unknown) {
// Kept for this page load only.
}
return updated;
};
/** Return if the user unlocked pasting into the console. */
export const loadPasteAllowed = (userId: string): boolean => {
try {
return (
window.localStorage.getItem(`${STORAGE_KEY_ALLOW_PASTING}-${userId}`) ===
"true"
);
} catch (_err: unknown) {
return false;
}
};
/** Remember the user unlocked pasting into the console. */
export const savePasteAllowed = (userId: string): void => {
try {
window.localStorage.setItem(
`${STORAGE_KEY_ALLOW_PASTING}-${userId}`,
"true"
);
} catch (_err: unknown) {
// Allowed for this page load only.
}
};
@@ -0,0 +1,232 @@
import type { ContextType } from "@lit/context";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../../common/decorators/consume";
import type { HASSDomTargetEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../components/ha-checkbox";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import {
apiContext,
internationalizationContext,
} from "../../../../data/context";
import { acceptDebugConsoleWarning } from "../../../../data/debug_console";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import type {
DebugConsoleWarningDialogParams,
DebugConsoleWarningResult,
} from "./show-dialog-debug-console-warning";
const RISKS = ["scams", "access", "safety", "logged"] as const;
// Long enough to read the risks before they can be accepted.
const READ_SECONDS = 10;
/**
* Asks a user to accept the risks before using the debug console, on versions
* of Home Assistant that are not for development.
*/
@customElement("dialog-debug-console-warning")
export class DialogDebugConsoleWarning extends DialogMixin<DebugConsoleWarningDialogParams>(
LitElement
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state() private _understood = false;
@state() private _accepting = false;
@state() private _error?: string;
@state() private _secondsLeft = READ_SECONDS;
private _result: DebugConsoleWarningResult = "closed";
private _countdown?: number;
public connectedCallback(): void {
super.connectedCallback();
const shownAt = Date.now();
this._secondsLeft = READ_SECONDS;
this._countdown = window.setInterval(() => {
const elapsed = Math.floor((Date.now() - shownAt) / 1000);
this._secondsLeft = Math.max(READ_SECONDS - elapsed, 0);
if (this._secondsLeft === 0) {
this._stopCountdown();
}
}, 1000);
}
public disconnectedCallback(): void {
this._stopCountdown();
this.params?.closedCallback(this._result);
super.disconnectedCallback();
}
protected render() {
if (!this.params || !this._i18n) {
return nothing;
}
return html`
<ha-dialog
open
prevent-scrim-close
width="medium"
@keydown=${this._escapeListener}
.headerTitle=${this._i18n.localize(
"ui.panel.config.tools.tabs.debug_console.warning.title"
)}
>
<ha-icon-button
slot="headerNavigationIcon"
.label=${this._i18n.localize("ui.common.close")}
.path=${mdiClose}
@click=${this._decline}
></ha-icon-button>
<p>
${this._i18n.localize(
"ui.panel.config.tools.tabs.debug_console.warning.intro"
)}
</p>
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<ha-alert alert-type="warning">
<ul>
${RISKS.map(
(risk) =>
html`<li>
${this._i18n.localize(
`ui.panel.config.tools.tabs.debug_console.warning.risks.${risk}`
)}
</li>`
)}
</ul>
</ha-alert>
<ha-checkbox
autofocus
.checked=${this._understood}
.disabled=${this._accepting}
@change=${this._understoodChanged}
>
${this._i18n.localize(
"ui.panel.config.tools.tabs.debug_console.warning.understand"
)}
</ha-checkbox>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._decline}
>
${this._i18n.localize(
"ui.panel.config.tools.tabs.debug_console.warning.go_back"
)}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._understood || this._secondsLeft > 0}
.loading=${this._accepting}
@click=${this._accept}
>
${
this._secondsLeft > 0
? this._i18n.localize(
"ui.panel.config.tools.tabs.debug_console.warning.continue_in",
{ seconds: this._secondsLeft }
)
: this._i18n.localize(
"ui.panel.config.tools.tabs.debug_console.warning.continue"
)
}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _stopCountdown(): void {
window.clearInterval(this._countdown);
this._countdown = undefined;
}
private _understoodChanged(ev: HASSDomTargetEvent<HaCheckbox>): void {
this._understood = ev.target.checked;
}
private async _accept(): Promise<void> {
if (!this._understood || this._secondsLeft > 0 || this._accepting) {
return;
}
this._accepting = true;
this._error = undefined;
try {
await acceptDebugConsoleWarning(this._api);
} catch (err: unknown) {
this._error = (err as { message?: string }).message ?? String(err);
return;
} finally {
this._accepting = false;
}
this._result = "accepted";
this.closeDialog();
}
private _decline() {
this._result = "declined";
this.closeDialog();
}
// Escape closes the dialog too, which declines it like the buttons do. In
// the capture phase, as the dialog itself stops it from going further.
private _escapeListener = {
handleEvent: (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
this._result = "declined";
}
},
capture: true,
};
static styles = css`
p {
margin-top: 0;
}
ha-alert {
display: block;
margin-bottom: var(--ha-space-4);
}
ul {
margin: 0;
padding-inline-start: var(--ha-space-5);
}
li + li {
margin-top: var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-debug-console-warning": DialogDebugConsoleWarning;
}
}
@@ -0,0 +1,23 @@
import { fireEvent } from "../../../../common/dom/fire_event";
/**
* How the warning closed: accepted, declined with Go back, its close button,
* or Escape, or closed by something else, like navigating away.
*/
export type DebugConsoleWarningResult = "accepted" | "declined" | "closed";
export interface DebugConsoleWarningDialogParams {
/** Called once the dialog is gone, with how it closed. */
closedCallback: (result: DebugConsoleWarningResult) => void;
}
export const showDebugConsoleWarningDialog = (
element: HTMLElement,
dialogParams: DebugConsoleWarningDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-debug-console-warning",
dialogImport: () => import("./dialog-debug-console-warning"),
dialogParams,
});
};
@@ -0,0 +1,336 @@
import type { ContextType } from "@lit/context";
import { mdiChevronRight } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-svg-icon";
import { consume } from "../../../../common/decorators/consume";
import { internationalizationContext } from "../../../../data/context";
import type {
DebugConsoleChild,
DebugConsoleGetAttributeResult,
DebugConsoleInspectResult,
DebugConsoleObjects,
DebugConsoleValue,
} from "../../../../data/debug_console";
/**
* A value in the console. Objects expand into a tree, loading their children
* from the backend on demand, like the object inspector in browser consoles.
*/
@customElement("tools-debug-console-value")
export class ToolsDebugConsoleValue extends LitElement {
// Not the hass object: trees can be large, and should not update on every
// state change in Home Assistant.
@property({ attribute: false }) public objects!: DebugConsoleObjects;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: DebugConsoleValue;
@property() public key?: string;
/** Set for a property that is only evaluated once selected. */
@property({ type: Boolean }) public getter = false;
/** The object the getter belongs to. */
@property({ attribute: false }) public parentId?: string;
@state() private _expanded = false;
@state() private _children?: DebugConsoleInspectResult;
@state() private _error?: string;
@state() private _evaluated?: DebugConsoleValue;
// Properties can change things, so one evaluation at a time.
@state() private _evaluating = false;
// Bumped when the value changes, so answers about an earlier one are dropped.
private _request = 0;
// Inspecting runs code of the object, so reopening waits for the answer
// instead of asking again.
private _inspecting = false;
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("value") || changedProps.has("parentId")) {
this._request++;
this._expanded = false;
this._inspecting = false;
this._children = undefined;
this._error = undefined;
this._evaluated = undefined;
}
}
protected render() {
const key = this.key
? html`<span class="key">${this.key}</span><span class="colon">: </span>`
: nothing;
if (this._error) {
return html`${key}<span class="error">${this._error}</span>`;
}
const value = this._value;
if (this.getter && !value) {
const label = this._i18n.localize(
"ui.panel.config.tools.tabs.debug_console.evaluate_property",
{ property: this.key ?? "" }
);
return html`${key}<button
class="getter"
title=${label}
aria-label=${label}
.disabled=${this._evaluating}
@click=${this._evaluate}
>
(...)
</button>`;
}
if (!value) {
return nothing;
}
if (value.type !== "object") {
return html`${key}${this._renderPrimitive(value)}`;
}
return html`
<button
class="toggle"
aria-expanded=${this._expanded ? "true" : "false"}
@click=${this._toggle}
>
<ha-svg-icon
class=${this._expanded ? "chevron expanded" : "chevron"}
.path=${mdiChevronRight}
></ha-svg-icon>
${key}<span class="class">${value.class}</span>${
value.length !== undefined
? html`<span class="length">(${value.length})</span>`
: nothing
}
<span class="preview">${value.preview}</span>
</button>
${
this._expanded && this._children
? html`<div class="children">
${this._children.children.map((child) => this._renderChild(child))}
${
this._children.truncated
? html`<div class="truncated">
${this._i18n.localize(
"ui.panel.config.tools.tabs.debug_console.truncated",
{ count: this._children.children.length }
)}
</div>`
: nothing
}
</div>`
: nothing
}
`;
}
private get _value() {
return this._evaluated ?? this.value;
}
private _renderPrimitive(
value: Exclude<DebugConsoleValue, { type: "object" }>
) {
switch (value.type) {
case "none":
return html`<span class="keyword">None</span>`;
case "boolean":
return html`<span class="keyword"
>${value.value ? "True" : "False"}</span
>`;
case "number":
return html`<span class="number">${value.value}</span>`;
case "string":
return html`<span class="string"
>'${value.value}${value.truncated ? "…" : ""}'</span
>`;
case "text":
return html`<span class="text"
>${value.value}${value.truncated ? "…" : ""}</span
>`;
}
return nothing;
}
private _renderChild(child: DebugConsoleChild) {
return html`<div class="child">
<tools-debug-console-value
.objects=${this.objects}
.key=${child.key}
.value=${"value" in child ? child.value : undefined}
.getter=${"getter" in child}
.parentId=${
this._value?.type === "object" ? this._value.object_id : undefined
}
></tools-debug-console-value>
</div>`;
}
private async _toggle() {
const value = this._value;
if (value?.type !== "object") {
return;
}
this._expanded = !this._expanded;
if (!this._expanded || this._children || this._inspecting) {
return;
}
const request = this._request;
this._inspecting = true;
try {
const children = await this.objects.inspect(value.object_id);
if (request === this._request) {
this._children = children;
}
} catch (err: unknown) {
if (request === this._request) {
this._error = (err as { message?: string }).message ?? String(err);
}
} finally {
if (request === this._request) {
this._inspecting = false;
}
}
}
private async _evaluate() {
if (this.parentId === undefined || !this.key || this._evaluating) {
return;
}
const request = this._request;
this._evaluating = true;
let result: DebugConsoleGetAttributeResult;
try {
result = await this.objects.getAttribute(this.parentId, this.key);
} catch (err: unknown) {
result = { error: (err as { message?: string }).message ?? String(err) };
} finally {
this._evaluating = false;
}
if (request !== this._request) {
return;
}
if ("error" in result) {
this._error = result.error;
} else {
this._evaluated = result.value;
}
}
static readonly styles = css`
:host {
display: block;
font-family: var(--ha-font-family-code);
word-break: break-word;
}
/* Only on text, as the template whitespace between elements must collapse. */
.string,
.text,
.error {
white-space: pre-wrap;
}
button {
all: unset;
cursor: pointer;
}
button:focus-visible {
outline: 1px solid var(--primary-color);
}
.toggle {
display: inline;
}
.chevron {
--mdc-icon-size: 14px;
vertical-align: middle;
margin-inline-start: -16px;
transition: transform 0.1s;
color: var(--secondary-text-color);
}
.chevron.expanded {
transform: rotate(90deg);
}
:host-context([dir="rtl"]) .chevron:not(.expanded) {
transform: rotate(180deg);
}
.children {
padding-inline-start: var(--ha-space-4);
}
.child {
padding-inline-start: 16px;
}
.key {
color: var(--accent-color);
}
.colon,
.length,
.preview,
.truncated,
.getter {
color: var(--secondary-text-color);
}
.class {
font-style: italic;
}
.preview {
margin-inline-start: 0.5em;
}
/* The colors of the code editor, so values look like code there. */
.keyword {
color: var(--codemirror-keyword, #6262ff);
}
.number {
color: var(--codemirror-number, #ca7841);
}
.string {
color: var(--codemirror-string, #07a);
}
.error {
color: var(--error-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"tools-debug-console-value": ToolsDebugConsoleValue;
}
}
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -11,6 +11,7 @@ import "../../../components/ha-icon-button-arrow-prev";
import "../../../components/ha-tab-group";
import "../../../components/ha-tab-group-tab";
import "../../../components/ha-top-app-bar-fixed";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import type { HomeAssistant, Route } from "../../../types";
import "./tools-router";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -36,6 +37,10 @@ const TOOLS_TABS = [
panel: "event",
translationKey: "ui.panel.config.tools.tabs.events.title",
},
{
panel: "debug-console",
translationKey: "ui.panel.config.tools.tabs.debug_console.title",
},
{
panel: "statistics",
translationKey: "ui.panel.config.tools.tabs.statistics.title",
@@ -81,7 +86,12 @@ class PanelTools extends LitElement {
</ha-dropdown-item>
</ha-dropdown>
<ha-tab-group @wa-tab-show=${this._handlePageSelected} slot="subRow">
${TOOLS_TABS.map(
${TOOLS_TABS.filter(
(tab) =>
tab.panel !== "debug-console" ||
// Only loaded when enabled in YAML.
isComponentLoaded(this.hass.config, "debug_console")
).map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
@@ -130,7 +130,7 @@ export class DialogStatisticsFix extends LitElement {
"ui.panel.config.tools.tabs.statistics.fix_issue.state_class_removed.info_text_4"
)}
<a
href="https://developers.home-assistant.io/docs/core/entity/sensor/#long-term-statistics"
href="https://contributors.home-assistant.io/core/entities/sensor/#long-term-statistics"
target="_blank"
rel="noreferrer noopener"
>
+4
View File
@@ -38,6 +38,10 @@ class ToolsRouter extends HassRouterPage {
tag: "tools-template",
load: () => import("./template/tools-template"),
},
"debug-console": {
tag: "tools-debug-console",
load: () => import("./debug_console/tools-debug-console"),
},
statistics: {
tag: "tools-statistics",
load: () => import("./statistics/tools-statistics"),
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { extractSearchParam } from "../../../../common/url/search-params";
@@ -7,6 +7,7 @@ import "../../../../components/ha-assist-pipeline-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../components/ha-checkbox";
import "../../../../components/ha-secure-connection-alert";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import type {
@@ -97,6 +98,13 @@ export class AssistPipelineRunDebug extends LitElement {
.value=${this._pipelineId}
@value-changed=${this._pipelinePicked}
></ha-assist-pipeline-picker>
${
!window.isSecureContext
? html`<ha-secure-connection-alert
feature="microphone"
></ha-secure-connection-alert>`
: nothing
}
<div class="start-buttons">
<ha-button
appearance="filled"
@@ -253,6 +261,24 @@ export class AssistPipelineRunDebug extends LitElement {
);
}
private async _startRecorder(audioRecorder: AudioRecorder) {
const microphoneError = await audioRecorder.start();
if (!microphoneError) {
return true;
}
this._audioBuffer = undefined;
this._finished = true;
showAlertDialog(this, {
text: this.hass.localize(
`ui.dialogs.voice_command.microphone_${microphoneError}`
),
});
return false;
}
private async _runAudioWakeWordPipeline() {
const audioRecorder = new AudioRecorder((data) => {
if (this._audioBuffer) {
@@ -263,7 +289,10 @@ export class AssistPipelineRunDebug extends LitElement {
});
this._audioBuffer = [];
await audioRecorder.start();
if (!(await this._startRecorder(audioRecorder))) {
return;
}
let run: PipelineRun | undefined;
@@ -306,7 +335,7 @@ export class AssistPipelineRunDebug extends LitElement {
// Play audio when we're done.
if (updatedRun.stage === "done" && !updatedRun.error) {
const url = updatedRun.tts!.tts_output!.url;
const url = this.hass.hassUrl(updatedRun.tts!.tts_output!.url);
const audio = new Audio(url);
audio.addEventListener("ended", () => {
if (
@@ -346,7 +375,10 @@ export class AssistPipelineRunDebug extends LitElement {
});
this._audioBuffer = [];
await audioRecorder.start();
if (!(await this._startRecorder(audioRecorder))) {
return;
}
let run: PipelineRun | undefined;
@@ -386,7 +418,7 @@ export class AssistPipelineRunDebug extends LitElement {
// Play audio when we're done.
if (updatedRun.stage === "done") {
const url = updatedRun.tts!.tts_output!.url;
const url = this.hass.hassUrl(updatedRun.tts!.tts_output!.url);
const audio = new Audio(url);
audio.addEventListener("ended", () => {
if (
@@ -535,6 +567,10 @@ export class AssistPipelineRunDebug extends LitElement {
width: 100%;
margin-bottom: 16px;
}
ha-secure-connection-alert {
width: 100%;
margin-bottom: 16px;
}
.start-row ha-input {
flex: 1;
}
@@ -590,7 +590,7 @@ export class AssistPipelineDebug extends LitElement {
// Stop any existing audio first
this._stopTTS();
const url = this.pipelineRun!.tts!.tts_output!.url;
const url = this.hass.hassUrl(this.pipelineRun!.tts!.tts_output!.url);
this._audioElement = new Audio(url);
this._audioElement.addEventListener("error", () => {
@@ -17,6 +17,9 @@ import "../../config/energy/components/ha-energy-water-settings";
import "../../../components/ha-button";
import type { Lovelace, LovelaceCard } from "../../lovelace/types";
// Grid, solar, battery, gas, water, and individual devices
const STEPS = 6;
@customElement("energy-setup-wizard-card")
export class EnergySetupWizard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -53,7 +56,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
<p>
${this.hass.localize("ui.panel.energy.setup.step", {
step: this._step + 1,
steps: 6,
steps: STEPS,
})}
</p>
${
@@ -103,7 +106,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
: html`<div></div>`
}
${
this._step < 4
this._step < STEPS - 1
? html`<ha-button @click=${this._next}
>${this.hass.localize("ui.panel.energy.setup.next")}</ha-button
>`
@@ -131,7 +134,7 @@ export class EnergySetupWizard extends LitElement implements LovelaceCard {
}
private _next() {
if (this._step === 5) {
if (this._step === STEPS - 1) {
return;
}
this._step++;
@@ -0,0 +1,14 @@
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { AlertCardConfig } from "../cards/types";
import type { CardSuggestionProvider } from "./types";
export const alertCardSuggestions: CardSuggestionProvider<AlertCardConfig> = {
getEntitySuggestion(_hass, entityId) {
if (!ALERT_DOMAINS.includes(computeDomain(entityId))) {
return null;
}
return { config: { type: "alert", entity: entityId } };
},
};
@@ -1,4 +1,5 @@
import { alarmPanelCardSuggestions } from "./hui-alarm-panel-card-suggestions";
import { alertCardSuggestions } from "./hui-alert-card-suggestions";
import { calendarCardSuggestions } from "./hui-calendar-card-suggestions";
import { gaugeCardSuggestions } from "./hui-gauge-card-suggestions";
import { historyGraphCardSuggestions } from "./hui-history-graph-card-suggestions";
@@ -19,6 +20,7 @@ export const CARD_SUGGESTION_PROVIDERS: Record<string, CardSuggestionProvider> =
{
tile: tileCardSuggestions,
"alarm-panel": alarmPanelCardSuggestions,
alert: alertCardSuggestions,
calendar: calendarCardSuggestions,
gauge: gaugeCardSuggestions,
humidifier: humidifierCardSuggestions,
+57 -9
View File
@@ -8,6 +8,12 @@ import { consume } from "../../../common/decorators/consume";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
DEFAULT_ALERT_COLOR,
isAlertActive,
} from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import "../../../components/ha-card";
import "../../../components/ha-relative-time";
@@ -17,12 +23,40 @@ import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { formattersContext } from "../../../data/context";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceGridOptions,
} from "../types";
import { tileCardStyle } from "./tile/tile-card-style";
import type { AlertCardConfig } from "./types";
@customElement("hui-alert-card")
export class HuiAlertCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
await import("../editor/config-elements/hui-alert-card-editor");
return document.createElement("hui-alert-card-editor");
}
public static getStubConfig(
hass: HomeAssistant,
entities: string[],
entitiesFallback: string[]
): AlertCardConfig {
const [entity] = findEntities(
hass,
1,
entities,
entitiesFallback,
ALERT_DOMAINS
);
return { type: "alert", entity: entity || "" };
}
@state() private _config?: AlertCardConfig;
@state()
@@ -36,11 +70,20 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
public setConfig(config: AlertCardConfig): void {
if (
!isValidEntityId(config.entity) ||
(config.color !== undefined && typeof config.color !== "string") ||
(config.alert_color !== undefined &&
typeof config.alert_color !== "string") ||
(config.clear_color !== undefined &&
typeof config.clear_color !== "string") ||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
) {
throw new Error("Invalid configuration");
}
if (!ALERT_DOMAINS.includes(computeDomain(config.entity))) {
throw new Error(
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
);
}
this._config = config;
}
@@ -71,18 +114,22 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
});
const pulse = this._config.pulse === true;
const hasColor =
this._config.color !== undefined && this._config.color !== "none";
const active = isAlertActive(stateObj);
const color = active
? (this._config.alert_color ?? DEFAULT_ALERT_COLOR)
: this._config.clear_color;
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
return html`
<ha-card
class=${classMap({ pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-alert-color":
this._config.color && hasColor
? computeCssColor(this._config.color)
: undefined,
color && hasColor ? computeCssColor(color) : undefined,
"--ha-alert-static-opacity": pulse
? undefined
: "var(--ha-alert-pulse-opacity)",
@@ -169,7 +216,8 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
position: relative;
}
@media (prefers-reduced-motion: reduce) {
ha-card::before {
ha-card::before,
ha-card.pulse::before {
animation: none;
opacity: var(--ha-alert-pulse-opacity);
}
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more