Compare commits

...
Author SHA1 Message Date
Petar Petrov 0531888064 Harden the ESPHome setup wizard after review feedback.
Show cached capabilities while offline, wait for user data before the banner, and expose setup status to assistive tech.
2026-08-25 16:37:23 +03:00
Petar Petrov 24e26b398e Add an ESPHome device setup checklist on the device page.
Surface advertised proxy capabilities in a banner and dialog so users can continue Bluetooth, audio, Z-Wave, and serial setup after the device is added.
2026-08-25 15:46:52 +03:00
tormazsandGitHub bf939babfd Fix ha-control-select losing its value when activated by a screen reader (#53777)
The option handlers read the value from ev.target, which is whichever
element the click landed on. Real pointer events are retargeted to the
outer role="radio" element by .option .content { pointer-events: none },
but a screen reader's synthetic activation click is not hit-tested, so it
lands on the inner content element, which has no value.

ev.currentTarget is always the element the listener is bound to, so it
always carries the value.
2026-08-25 15:39:05 +03:00
noksideandGitHub d41b21b9d6 Fix timeCachePromiseFunc cache handling (#53773)
Fix promise cache result handling
2026-08-25 15:31:08 +03:00
Jan BouwhuisandGitHub 7e495f38d2 Expand sensor entity constants and add device classes (#53748)
* Expand sensor entity constants and add device classes

* Updated constants

* Fix typo

* Fix name

* Restore part original generated script comment

* Add convertible units and fix null type

* Fix type

* Update constants

* Fix import SENSOR_NUMERIC_DEVICE_CLASSES
2026-08-25 14:28:13 +02:00
Petar PetrovandGitHub 5cff0cb4c9 Focus nested form-dialog fields when a nested level is pushed (#53757)
* Focus nested form-dialog fields after a nested level is pushed.

Host focus on ha-form does not pierce selector shadows, so keyboard
focus stayed on the opener instead of the nested controls.

* Simplify nested form-dialog focus and fix CI types.

Wait one render and focus the first real control instead of waiting for
custom-element upgrades or walking a general focusable tree.

* Wait for lazy selector upgrades before nested form focus.

nextRender is not enough on a cold selector chunk; wait for undefined
custom elements so nested focus does not depend on network timing.
2026-08-25 15:14:33 +03:00
Paul BotteinandGitHub dbe2e2c079 Reflect the active filters in the target picker counts (#53716) 2026-08-25 13:53:26 +02:00
Paul BotteinandGitHub e22b770252 Clarify when agents should add tests (#53775) 2026-08-25 12:28:47 +01:00
Paul BotteinandGitHub fd388d99bf Show area and disable pulse by default in alert card (#53774) 2026-08-25 12:17:52 +01:00
Aidan TimsonandGitHub f9bfc66d5b Add alert card (#53758)
* Add security alerts card

* Fix security alert cards

* Validate security alert card configuration

* Keep a single generic alert card

* Use entity context in alert card
2026-08-25 10:45:53 +01:00
renovate[bot]andGitHub c4ab017aed Update dependency @rspack/dev-server to v2.2.1 (#53772) 2026-08-25 09:27:59 +01:00
Paul BotteinandGitHub e22ef3e1f3 Merge the domain and device class filters into a type filter (#53710)
* Merge the domain and device class filters into a type filter

* Only scan the entities while the type filter panel is open
2026-08-25 08:12:00 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7a3764790b Update dependency @types/luxon to v3.7.5 (#53770)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 08:40:20 +03:00
karwostsandGitHub c499c2e030 Fix more-info setting dirty state tracking (#53762) 2026-08-25 07:02:41 +02:00
13ec52ddbe Fix Z-Wave add node security strategy selection not sticking (#53769)
The security strategy step rendered ha-form with a data object built
from a @state field that was never assigned, so the form was always
bound to an undefined strategy. ha-form's own optimistic data update
made the radio look selected after a click, but the next re-render of
the step (triggered by any hass update) re-committed the undefined
value and cleared the selection.

Make the step a controlled component: the dialog already owns
_inclusionStrategy and resets it when navigating back, so pass it down
as a property instead of keeping a second, unused copy in the child.


Claude-Session: https://claude.ai/code/session_011MGgDoAUxzf9KBexn6uiPc

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 07:01:47 +02:00
d288cfd30a Add supported_speeds parameter for cover movement actions (#53572)
add supported_speeds for cover service actions

Co-authored-by: karwosts <[email protected]>
2026-08-24 20:10:19 +02:00
karwostsandGitHub 643e8934b4 Allow media selector to optionally select a media directory (#27873)
Allow media browser to select a directory
2026-08-24 20:05:21 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9f3ca39738 Update dependency eslint to v10.9.0 (#53765)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 17:03:14 +03:00
renovate[bot]andGitHub 8cee5155e6 Update dependency @rsdoctor/rspack-plugin to v1.6.3 (#53761) 2026-08-24 14:37:06 +01:00
Paul BotteinandGitHub 15341a0aad Show climate summary card with only temperature sensors (#53760) 2026-08-24 14:51:04 +02:00
Petar PetrovandGitHub 9f8a2253ac Add keyboard and screen reader navigation to the energy device charts (#53754)
* Sonify value-first charts now that the extension reads them

* Announce device names instead of statistic ids in sonified charts
2026-08-24 14:45:43 +02:00
Maarten LakerveldandGitHub 97df5a5bf0 Key more-info details memoize on attribute name formatter (#53755)
_getDetailData caches on stateObj alone while formatting via this.hass,
so results go stale when translation-based format functions reload (e.g.
backend translations finishing after open, or a language switch) until
the entity next changes state. Pass hass.formatEntityAttributeName as an
extra memoize argument so the cache invalidates when format functions
are recreated.
2026-08-24 11:59:22 +02:00
Petar PetrovandGitHub 19cd06bb8c Fix energy period rollover overnight and in the first hour (#53717)
* Fix energy dashboard staying on yesterday after midnight.

* Catch up the energy live day before subscribe fetches.

* Keep energy day math in the server timezone so DST cannot skip a live day.

Browser-local addDays can jump a calendar day on a 23-hour DST fallback.
Assert against tz-internal endOfDay/addDays under Europe/Berlin so UTC CI
catches a regression, and prove the 01:00 timer and catch-up refresh fetch
the live day rather than only updating collection.start.

* Don't follow the live day from midnightRollover alone.

A stored non-today preset would otherwise be discarded on the first subscribe. Drop tautological UTC DST tests that cannot fail.
2026-08-24 10:16:00 +02:00
64 changed files with 5970 additions and 679 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
- `yarn lint` passes when practical for the scope.
- `yarn test` or focused relevant tests are green when practical for the scope.
- Tests are added or updated for new data processing and utilities where applicable.
- Each test added by the change protects real logic, not the look of a component.
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
- Components handle loading, error, unavailable, and missing-entity states.
- Entity existence is checked before property access.
+6 -5
View File
@@ -49,12 +49,13 @@ Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`;
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## Unit And Utility Tests
## When To Add Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
- Mock WebSocket connections and API calls at boundaries.
- Cover loading, error, unavailable, and missing-entity states where relevant.
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
## Dev Servers
@@ -0,0 +1,47 @@
name: Sync device class constants
# Mirrors the device class constants for Home Assistant Core's into the
# build-time default in src/data/devce_classes.ts and opens a PR
# when it drifts. Reads homeassistant/generated/device_classes.json from core.
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # Daily, 04:00 UTC
permissions:
contents: read
jobs:
sync:
name: Sync
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
- name: Format
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
- name: Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
branch: chore/sync-numeric-device-classes
commit-message: Update numeric sensor device classes
title: Update numeric sensor device classes
body: |
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
`SensorDeviceClass`.
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
@@ -1,7 +1,8 @@
name: Sync numeric device classes
name: Sync sensor entity constants
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
# Mirrors Home Assistant Core's numeric `SensorDeviceClass`, `SensorStateClass`,
# units and related device and state classes arrays into the
# build-time default in src/data/sensor_entity_constants.ts and opens a PR
# when it drifts. Reads homeassistant/generated/sensor.json from core.
on:
@@ -28,8 +29,8 @@ jobs:
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate numeric device classes
run: ./script/gen_numeric_device_classes
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
- name: Format
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
+1
View File
@@ -33,6 +33,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
- All user-facing text must be localized through the translation system.
- Do not write tests just because you changed some code. Write a test when there is real logic that could break without anyone noticing, and explain what the test protects.
## Project Skills
+40
View File
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/generated/device_classes.json";
const TARGET = join(paths.root_dir, "src", "data", "device_classes.ts");
gulp.task("gen-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const domainDeviceClasses = data ?? {};
if (!Object.keys(domainDeviceClasses).length) {
throw new Error(`No device classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's
// entity platform device classes. Do not edit by hand.
// Regenerate with \`script/gen_device_classes\`.
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
${Object.entries(domainDeviceClasses)
.map(
([domain, deviceClasses]) =>
` "${domain}": [${deviceClasses.map((deviceClass) => `"${deviceClass}"`).join(", ")}],`
)
.join("\n")}
};
`;
await writeFile(TARGET, content);
});
@@ -1,40 +0,0 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_numeric_device_classes.ts"
);
gulp.task("gen-numeric-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const classes = [...(data.numeric_device_classes ?? [])].sort();
if (!classes.length) {
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_numeric_device_classes\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
`;
await writeFile(TARGET, content);
});
@@ -0,0 +1,83 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_entity_constants.ts"
);
gulp.task("gen-sensor-entity-constants", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
const deviceClassUnits = data.device_class_units ?? {};
const convertibleClassUnits = data.convertible_units ?? {};
const stateClasses = [...(data.state_classes ?? [])].sort();
const stateClassUnits = data.state_class_units ?? {};
if (
!numericDeviceClasses.length ||
!stateClasses.length ||
!Object.keys(deviceClassUnits).length ||
!Object.keys(stateClassUnits).length
) {
throw new Error(
`No sensor device classes, state classes or units found in ${SOURCE_URL}`
);
}
const content = `// This file is auto-generated from Home Assistant Core's \`DEVICE_CLASS_UNITS\`
// and \`STATE_CLASS_UNITS\`) and \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_sensor_entity_constants\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${numericDeviceClasses.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
${Object.entries(deviceClassUnits)
.map(
([deviceClass, units]) =>
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<string, (string | null)[]> = {
${Object.entries(convertibleClassUnits)
.map(
([deviceClass, units]) =>
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
export const SENSOR_STATE_CLASSES: string[] = [
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
];
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
${Object.entries(stateClassUnits)
.map(
([stateClass, units]) =>
` ${stateClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
)
.join("\n")}
};
`;
await writeFile(TARGET, content);
});
+2 -1
View File
@@ -9,8 +9,9 @@ import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
import "./gather-static.js";
import "./gen-device-classes.js";
import "./gen-icons-json.js";
import "./gen-numeric-device-classes.js";
import "./gen-sensor-entity-constants.js";
import "./landing-page.js";
import "./locale-data.js";
import "./rspack.js";
+4 -4
View File
@@ -152,9 +152,9 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.2",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.1.10",
"@rspack/dev-server": "2.2.0",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -165,7 +165,7 @@
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.4",
"@types/luxon": "3.7.5",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
@@ -176,7 +176,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.8.1",
"eslint": "10.9.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -8,4 +8,4 @@ set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-numeric-device-classes
./node_modules/.bin/gulp gen-device-classes
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Safe bash settings
# -e Exit on command fail
# -u Exit on unset variable
# -o pipefail Exit if piped command has error code
set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-sensor-entity-constants
+3
View File
@@ -304,6 +304,9 @@ export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
swing_mode: "swing_modes",
swing_horizontal_mode: "swing_horizontal_modes",
},
cover: {
speed: "supported_speeds",
},
event: {
event_type: "event_types",
},
+10 -7
View File
@@ -55,17 +55,20 @@ export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
}
const resultPromise = func(hass, ...args);
anyHass[cacheKey] = resultPromise;
const cachePromise = resultPromise.then((result) => ({
result,
cacheKey: generateCacheKey?.(hass, result),
}));
anyHass[cacheKey] = cachePromise;
resultPromise.then(
cachePromise.then(
// When successful, set timer to clear cache
(result) => {
anyHass[cacheKey] = {
result,
cacheKey: generateCacheKey?.(hass, result),
};
anyHass[cacheKey] = result;
setTimeout(() => {
anyHass[cacheKey] = undefined;
if (anyHass[cacheKey] === result) {
anyHass[cacheKey] = undefined;
}
}, cacheTime);
},
// On failure, clear cache right away
+104 -17
View File
@@ -27,26 +27,57 @@ const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// lead nowhere.
const MIN_NAVIGABLE_POINTS = 2;
// Mirrors the extension's own reading of a point: it takes `value` as [x, y] and
// drops anything whose y is not a real number. That rejects gap-only series, and
// also value-first pairs like the energy device charts' [amount, "sensor.foo"].
// Counts no further than `limit` so this stays cheap on charts with many points.
const itemValues = (raw: unknown): unknown[] | null => {
if (Array.isArray(raw)) {
return raw;
}
if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
if (Array.isArray(value)) {
return value;
}
}
return null;
};
// ECharts spells empty values and numbers as strings too, and neither names a
// category.
const NON_CATEGORY_STRINGS = new Set(["-", "NaN", "null", "undefined"]);
const isCategoryKey = (value: unknown): boolean =>
typeof value === "string" &&
!NON_CATEGORY_STRINGS.has(value) &&
Number.isNaN(Number(value));
// A chart with the value axis on x, like the energy device charts, encodes its
// items value-first: [amount, "sensor.foo"]. The extension only reads a series
// that way when every item has that shape, so mirror the same gate.
const isValueFirstSeries = (data: readonly unknown[]): boolean =>
data.length > 0 &&
data.every((raw) => {
const values = itemValues(raw);
return (
!!values && typeof values[0] === "number" && isCategoryKey(values[1])
);
});
// Mirrors the extension's own reading of a point: it takes `value` as [x, y]
// (or [y, category] in a value-first series) and drops anything whose y is not
// a real number, which rejects gap-only series. Counts no further than `limit`
// so this stays cheap on charts with many points.
const countNumericPoints = (data: unknown, limit: number): number => {
if (!Array.isArray(data)) {
return 0;
}
const valueFirst = isValueFirstSeries(data);
let found = 0;
for (const raw of data) {
let y: unknown = raw;
if (Array.isArray(raw)) {
y = raw.length > 1 ? raw[1] : raw[0];
const values = itemValues(raw);
if (values) {
y = valueFirst ? values[0] : values.length > 1 ? values[1] : values[0];
} else if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
y = Array.isArray(value)
? value.length > 1
? value[1]
: value[0]
: value;
y = (raw as { value?: unknown }).value;
}
if (typeof y === "number" && !Number.isNaN(y)) {
found += 1;
@@ -95,6 +126,10 @@ interface SonifyChartOptions {
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
// Maps a category key or item name to what should be announced for it, so
// cards that key their data on ids (like the energy device charts) can have
// the display names read out instead. Returning undefined keeps the original.
formatLabel?: (label: string) => string | undefined;
onError: (error: string) => void;
}
@@ -163,6 +198,47 @@ const appendSonificationStyles = () => {
document.head.append(style);
};
// Rebuilds the labels the extension would announce — the category axis's data,
// or the item names on pies, which ignore whatever vestigial axes the chart
// options carry — with each one run through the card's formatter. Returns
// undefined when there is nothing to reword, so the extension's own labels
// stay untouched.
const buildValueLabels = (
categoryAxis: { type?: string; data?: unknown } | undefined,
firstSeries: { type?: string; data?: unknown } | undefined,
formatLabel?: (label: string) => string | undefined
): string[] | undefined => {
if (!formatLabel) {
return undefined;
}
const axisData =
categoryAxis?.type === "category" &&
Array.isArray(categoryAxis.data) &&
categoryAxis.data.length
? categoryAxis.data
: undefined;
const labels = axisData
? axisData.map((entry) =>
entry && typeof entry === "object"
? String((entry as { value?: unknown }).value ?? "")
: String(entry ?? "")
)
: firstSeries?.type === "pie" && Array.isArray(firstSeries.data)
? firstSeries.data.map((raw) => {
const name = (raw as { name?: unknown } | null)?.name;
if (typeof name === "string") {
return name;
}
const values = itemValues(raw);
return values && isCategoryKey(values[1]) ? String(values[1]) : "";
})
: undefined;
if (!labels?.length || labels.every((label) => !label)) {
return undefined;
}
return labels.map((label) => formatLabel(label) ?? label);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
@@ -179,8 +255,8 @@ export const sonifyChart = async (
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
const xAxis = ensureArray(chartOptions.xAxis)?.[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)?.[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
@@ -196,16 +272,25 @@ export const sonifyChart = async (
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly.
// axis to name when there is no category axis, so label both explicitly. On a
// horizontal chart the announced x is the category from the y axis and the
// announced y is the value from the x axis, so the sources swap.
const isTimeAxis = xAxis?.type === "time";
const isHorizontal = xAxis?.type === "value" && yAxis?.type === "category";
const valueLabels = buildValueLabels(
isHorizontal ? yAxis : xAxis,
readable[0],
options.formatLabel
);
const x = {
label:
xAxis?.name ||
(isHorizontal ? yAxis?.name : xAxis?.name) ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
...(valueLabels ? { valueLabels } : {}),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
@@ -213,7 +298,9 @@ export const sonifyChart = async (
: undefined,
};
const y = {
label: yAxis?.name || localize("ui.components.history_charts.value"),
label:
(isHorizontal ? xAxis?.name : yAxis?.name) ||
localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
+6
View File
@@ -117,6 +117,11 @@ export class HaChartBase extends LitElement {
@property({ type: String }) public height?: string;
// Lets cards that key their data on ids have display names announced
// instead when the chart is navigated with Chart2Music.
@property({ attribute: false })
public sonificationLabelFormatter?: (label: string) => string | undefined;
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@@ -583,6 +588,7 @@ export class HaChartBase extends LitElement {
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
formatLabel: this.sonificationLabelFormatter,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
+3 -3
View File
@@ -101,7 +101,7 @@ export class HaControlSelect extends LitElement {
private _handleOptionClick(ev: MouseEvent) {
if (this.disabled) return;
const value = (ev.target as any).value;
const value = (ev.currentTarget as any).value;
this.value = value;
fireEvent(this, "value-changed", { value });
}
@@ -109,7 +109,7 @@ export class HaControlSelect extends LitElement {
private _handleOptionMouseDown(ev: MouseEvent) {
if (this.disabled) return;
ev.preventDefault();
const value = (ev.target as any).value;
const value = (ev.currentTarget as any).value;
this._activeIndex = this.options?.findIndex(
(option) => option.value === value
);
@@ -121,7 +121,7 @@ export class HaControlSelect extends LitElement {
private _handleOptionFocus(ev: FocusEvent) {
if (this.disabled) return;
const value = (ev.target as any).value;
const value = (ev.currentTarget as any).value;
this._activeIndex = this.options?.findIndex(
(option) => option.value === value
);
-287
View File
@@ -1,287 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
interface DeviceClassItem {
deviceClass: string;
domain: string;
name: string;
}
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-device-classes.caption")}
${
this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
),
(item) => item.deviceClass,
(item) =>
html`<ha-check-list-item
.value=${item.deviceClass}
.selected=${(this.value || []).includes(item.deviceClass)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${item.domain}
.deviceClass=${item.deviceClass}
.state=${item.domain === "binary_sensor" ? "on" : undefined}
></ha-domain-icon>
${item.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined
): DeviceClassItem[] =>
this._deviceClassItems(this._deviceClassDomains(states), localize)
.filter(
(item) =>
!filter ||
item.deviceClass.toLowerCase().includes(filter) ||
item.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _deviceClassDomains = memoizeOne(
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
const domains = new Map<string, string[]>();
Object.values(states).forEach((stateObj) => {
const deviceClass = stateObj.attributes.device_class;
if (!deviceClass) {
return;
}
const domain = computeStateDomain(stateObj);
const known = domains.get(deviceClass);
if (!known) {
domains.set(deviceClass, [domain]);
} else if (!known.includes(domain)) {
known.push(domain);
}
});
return domains;
}
);
private _deviceClassItems = memoizeOne(
(
deviceClassDomains: Map<string, string[]>,
localize: LocalizeFunc
): DeviceClassItem[] =>
[...deviceClassDomains].map(([deviceClass, domains]) => {
for (const domain of domains) {
const name = localize(
`component.${domain}.entity_component.${deviceClass}.name`
);
if (name) {
return { deviceClass, domain, name };
}
}
return { deviceClass, domain: domains[0], name: deviceClass };
}),
([domainsA, localizeA], [domainsB, localizeB]) =>
localizeA === localizeB &&
domainsA.size === domainsB.size &&
[...domainsA].every(
([deviceClass, domains]) =>
domainsB.get(deviceClass)?.join() === domains.join()
)
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const deviceClasses = this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
);
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
const preserved = (this.value || []).filter((d) => !visible.has(d));
const selected = [...ev.detail.index]
.map((i) => deviceClasses[i]?.deviceClass)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+519
View File
@@ -0,0 +1,519 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiChevronDown,
mdiChevronUp,
mdiFilterVariantRemove,
mdiShape,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeRTL } from "../common/util/compute_rtl";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import {
computeDeviceClassName,
NO_DEVICE_CLASS,
} from "../data/entity/device_class";
import {
entityTypeKey,
parseEntityType,
usedEntityTypes,
} from "../data/entity/entity_type";
import { domainToName } from "../data/integration";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
// Core picks this one from the battery level, so it has no usable default.
const FIXED_TYPE_ICONS: Record<string, string> = {
"sensor/battery": "mdi:battery",
};
interface TypeRow {
key: string;
domain: string;
deviceClass?: string;
name: string;
deviceClasses?: string[];
expanded?: boolean;
last?: boolean;
}
@customElement("ha-filter-entity-types")
export class HaFilterEntityTypes extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _filter?: string;
@state() private _expandedDomains = new Set<string>();
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
private _badgeTypes?: Map<string, string[]>;
protected render() {
const count = this.value?.length
? this._count(this.value, this._badgeTypes!)
: 0;
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-entity-types.caption")}
${
count
? html`<div class="badge">${count}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
</ha-expansion-panel>
${this._panel.showContent ? this._renderContent() : nothing}
`;
}
private _renderContent() {
const rows = this._rows(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this._expandedDomains
);
const rtl = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
return html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list-selectable
multi
controlled
aria-label=${this._localize("ui.components.filter-entity-types.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
rows,
(row) => row.key,
(row) => this._renderRow(row, rtl)
)}
</ha-list-selectable>
</div>`;
}
private _renderRow(row: TypeRow, rtl: boolean) {
const selected = this._isSelected(row);
const expandable = !!row.deviceClasses?.length;
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
class=${classMap({ child: !!row.deviceClass, rtl })}
.value=${row.key}
.selected=${selected}
.indeterminate=${!selected && this._isPartiallySelected(row)}
>
${
row.deviceClass
? html`<ha-tree-indicator
slot="start"
.end=${!!row.last}
></ha-tree-indicator>`
: nothing
}
${
row.deviceClass === NO_DEVICE_CLASS
? html`<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>`
: html`<ha-domain-icon
slot="start"
.icon=${FIXED_TYPE_ICONS[row.key]}
.domain=${row.domain}
.deviceClass=${row.deviceClass}
.state=${row.domain === "binary_sensor" ? "on" : undefined}
?brand-fallback=${!row.deviceClass}
></ha-domain-icon>`
}
<span slot="headline">${row.name}</span>
${
expandable
? html`<ha-icon-button
slot="end"
data-domain=${row.domain}
.path=${row.expanded ? mdiChevronUp : mdiChevronDown}
.label=${this._localize(
row.expanded
? "ui.components.filter-entity-types.collapse"
: "ui.components.filter-entity-types.expand"
)}
@click=${this._toggleDomain}
@keydown=${this._handleChevronKeydown}
></ha-icon-button>`
: nothing
}
</ha-list-item-option>
`;
}
private _types = memoizeOne(usedEntityTypes);
// A selected domain counts for the classes it stands for, so that collapsing
// the last one does not drop the count to one.
private _count = memoizeOne(
(value: string[], types: Map<string, string[]>): number =>
value.reduce((count, key) => {
const { domain, deviceClass } = parseEntityType(key);
return (
count +
(deviceClass ? 1 : Math.max(types.get(domain)?.length ?? 0, 1))
);
}, 0)
);
private _rows = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
expandedDomains: Set<string>
): TypeRow[] => {
const types = this._types(states);
const domains = [...types.keys()]
.map((domain) => ({ domain, name: domainToName(localize, domain) }))
.sort((a, b) => stringCompare(a.name, b.name, language));
const rows: TypeRow[] = [];
for (const { domain, name } of domains) {
const deviceClasses = types
.get(domain)!
.map((deviceClass) => ({
deviceClass,
name: this._deviceClassName(localize, domain, deviceClass),
}))
.sort((a, b) => {
if (a.deviceClass === NO_DEVICE_CLASS) {
return 1;
}
if (b.deviceClass === NO_DEVICE_CLASS) {
return -1;
}
return stringCompare(a.name, b.name, language);
});
const matchingClasses = deviceClasses.filter((entry) =>
this._matches(filter, entry.deviceClass, entry.name)
);
const domainMatches = this._matches(filter, domain, name);
if (!domainMatches && !matchingClasses.length) {
continue;
}
// Only a search that matched nothing but device classes unfolds them.
const revealed = !!filter && !domainMatches;
const expanded = revealed || expandedDomains.has(domain);
rows.push({
key: domain,
domain,
name,
deviceClasses: deviceClasses.map((entry) => entry.deviceClass),
expanded,
});
if (!deviceClasses.length || !expanded) {
continue;
}
const children = revealed ? matchingClasses : deviceClasses;
children.forEach((entry, index) => {
rows.push({
key: entityTypeKey(domain, entry.deviceClass),
domain,
deviceClass: entry.deviceClass,
name: entry.name,
last: index === children.length - 1,
});
});
}
return rows;
}
);
private _deviceClassName(
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string {
return deviceClass === NO_DEVICE_CLASS
? localize("ui.components.filter-entity-types.no_device_class")
: computeDeviceClassName(localize, domain, deviceClass);
}
private _matches(
filter: string | undefined,
slug: string,
name: string
): boolean {
return (
!filter ||
slug.toLowerCase().includes(filter) ||
name.toLowerCase().includes(filter)
);
}
private _isSelected(row: TypeRow): boolean {
const value = this.value;
if (!value?.length) {
return false;
}
return value.includes(row.domain) || value.includes(row.key);
}
private _isPartiallySelected(row: TypeRow): boolean {
if (row.deviceClass || !this.value?.length) {
return false;
}
return this.value.some(
(key) => parseEntityType(key).domain === row.domain && key !== row.domain
);
}
public willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
// While closed, the badge reuses the classes it last saw rather than
// rescanning every entity on each state change.
if (this._panel.showContent || !this._badgeTypes) {
this._badgeTypes = this._types(this._states);
}
if (changed.has("expanded") && this.expanded) {
this._expandedDomains = new Set(
(this.value ?? [])
.map((key) => parseEntityType(key))
.filter((type) => type.deviceClass)
.map((type) => type.domain)
);
}
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
// The list activates the focused row on Enter and Space, which would select
// the domain instead of expanding it.
private _handleChevronKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
}
}
private _toggleDomain(ev: Event) {
ev.stopPropagation();
const { domain } = (ev.currentTarget as HTMLElement).dataset;
if (!domain) {
return;
}
const expandedDomains = new Set(this._expandedDomains);
if (!expandedDomains.delete(domain)) {
expandedDomains.add(domain);
}
this._expandedDomains = expandedDomains;
}
private _handleItemToggled(ev: CustomEvent<number>) {
// The list indexes its items by registration order, which a search reorders,
// so read the key off the clicked option instead.
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
const key = option?.value;
if (!key) {
return;
}
const { domain, deviceClass } = parseEntityType(key);
const value = new Set(this.value ?? []);
const siblings = (this._types(this._states).get(domain) ?? []).map(
(entry) => entityTypeKey(domain, entry)
);
// Drops the classes the domain no longer exposes too, so that a stale key
// can never sit next to the domain that covers it.
const selectDomain = () => {
value.forEach((selected) => {
if (parseEntityType(selected).domain === domain) {
value.delete(selected);
}
});
value.add(domain);
};
if (!deviceClass) {
if (!value.delete(domain)) {
selectDomain();
}
} else if (value.delete(domain)) {
siblings.forEach((sibling) => {
if (sibling !== key) {
value.add(sibling);
}
});
} else if (!value.delete(key)) {
value.add(key);
if (siblings.length && siblings.every((sibling) => value.has(sibling))) {
selectDomain();
}
}
this.value = [...value];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
filterPanelStyles,
css`
/* The list scrolls through its own container, not through the host. */
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
/* Keeps a row that carries the chevron as tall as one that does not. */
ha-list-item-option {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-list-item-option ha-icon-button {
--ha-icon-button-size: 32px;
}
.child::part(base) {
padding-inline-start: 48px;
}
ha-tree-indicator {
width: 56px;
position: absolute;
top: 0px;
left: 0px;
}
.rtl ha-tree-indicator {
right: 0px;
left: initial;
transform: scaleX(-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-entity-types": HaFilterEntityTypes;
}
}
+49 -50
View File
@@ -2,15 +2,15 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import { entityTypeFilterFunc } from "../data/entity/entity_type";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-device-classes";
import "./ha-filter-domains";
import "./ha-filter-entity-types";
import "./ha-filter-integrations";
import "./ha-target-picker";
@@ -19,8 +19,8 @@ import "./ha-target-picker";
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
domains?: string[];
deviceClasses?: string[];
/** Domains (`sensor`) and domains narrowed to a device class (`sensor/power`). */
types?: string[];
integrations?: string[];
}
@@ -44,38 +44,30 @@ export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
/**
* Narrows entity IDs down by the selected filters: an entity is kept when it
* matches every filter that has a selection.
* Matches an entity against the selected filters: it is kept when it matches
* every filter that has a selection. Undefined when nothing is selected.
*/
export const applySourceFilters = (
entityIds: string[],
export const sourceFilterFunc = (
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const domains = filters.domains?.length ? filters.domains : undefined;
const deviceClasses = filters.deviceClasses?.length
? filters.deviceClasses
): ((entityId: string) => boolean) | undefined => {
const matchesType = filters.types?.length
? entityTypeFilterFunc(filters.types, states)
: undefined;
const integrations = filters.integrations?.length
? filters.integrations
: undefined;
if (!domains && !deviceClasses && !integrations) {
return entityIds;
if (!matchesType && !integrations) {
return undefined;
}
return entityIds.filter((entityId) => {
if (domains && !domains.includes(computeDomain(entityId))) {
return (entityId: string) => {
if (matchesType && !matchesType(entityId)) {
return false;
}
if (deviceClasses) {
const deviceClass = states[entityId]?.attributes.device_class;
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
return false;
}
}
if (integrations) {
const integration =
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
@@ -84,13 +76,24 @@ export const applySourceFilters = (
}
}
return true;
});
};
};
/** Narrows entity IDs down by the selected filters. */
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const matches = sourceFilterFunc(filters, states, entities, entitySources);
return matches ? entityIds.filter(matches) : entityIds;
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* domain, device class and integration. Meant to be placed in an
* `ha-filter-pane`.
* entity type and integration. Meant to be placed in an `ha-filter-pane`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
@@ -106,6 +109,8 @@ export class HaSourcesPicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false }) public entitySources?: EntitySources;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@@ -129,6 +134,12 @@ export class HaSourcesPicker extends LitElement {
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.activeFilter=${this._activeFilter(
this.filters,
this.hass.states,
this.hass.entities,
this.entitySources
)}
.primaryEntitiesOnly=${false}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
@@ -136,18 +147,12 @@ export class HaSourcesPicker extends LitElement {
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-domains
.value=${this.filters.domains}
.expanded=${this._expandedFilter === "domains"}
@data-table-filter-changed=${this._domainsChanged}
@expanded-changed=${this._domainsExpanded}
></ha-filter-domains>
<ha-filter-device-classes
.value=${this.filters.deviceClasses}
.expanded=${this._expandedFilter === "deviceClasses"}
@data-table-filter-changed=${this._deviceClassesChanged}
@expanded-changed=${this._deviceClassesExpanded}
></ha-filter-device-classes>
<ha-filter-entity-types
.value=${this.filters.types}
.expanded=${this._expandedFilter === "types"}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._typesExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@@ -158,6 +163,8 @@ export class HaSourcesPicker extends LitElement {
`;
}
private _activeFilter = memoizeOne(sourceFilterFunc);
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
@@ -168,12 +175,8 @@ export class HaSourcesPicker extends LitElement {
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _domainsChanged(ev: CustomEvent) {
this._filterChanged("domains", ev);
}
private _deviceClassesChanged(ev: CustomEvent) {
this._filterChanged("deviceClasses", ev);
private _typesChanged(ev: CustomEvent) {
this._filterChanged("types", ev);
}
private _integrationsChanged(ev: CustomEvent) {
@@ -191,12 +194,8 @@ export class HaSourcesPicker extends LitElement {
});
}
private _domainsExpanded(ev: CustomEvent) {
this._filterExpanded("domains", ev);
}
private _deviceClassesExpanded(ev: CustomEvent) {
this._filterExpanded("deviceClasses", ev);
private _typesExpanded(ev: CustomEvent) {
this._filterExpanded("types", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
+11
View File
@@ -108,6 +108,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Entities that pass the filters the page currently has on. Narrows the
* counts, unlike `entityFilter`, which says what can be picked at all.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
@property({ type: Boolean, reflect: true }) public disabled = false;
@state() private _selectedSection?: TargetTypeFloorless;
@@ -286,6 +293,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ entity: entityIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -306,6 +314,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ device: deviceIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -329,6 +338,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -348,6 +358,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ label: labelIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -470,15 +470,19 @@ export class HaMediaPlayerBrowse extends LitElement {
? MediaClassBrowserSettings[currentItem.children_media_class]
: MediaClassBrowserSettings.directory;
const canPickCurrent =
currentItem?.can_play ||
(currentItem && this.accept?.includes("directory"));
return html`
${
currentItem.can_play || showSearch
canPickCurrent || showSearch
? html`
<div
class="header ${classMap({
"no-img": !currentItem.thumbnail,
"no-dialog": !this.dialog,
"search-only": !currentItem.can_play,
"search-only": !canPickCurrent,
})}"
@transitionend=${this._setHeaderHeight}
>
@@ -491,7 +495,7 @@ export class HaMediaPlayerBrowse extends LitElement {
: nothing
}
${
currentItem.can_play
canPickCurrent
? html`<div class="header-content">
${
currentItem.thumbnail
@@ -503,7 +507,7 @@ export class HaMediaPlayerBrowse extends LitElement {
></ha-media-browser-thumbnail>
${
this.narrow &&
currentItem?.can_play &&
canPickCurrent &&
(!this.accept ||
canPlayChildren.has(
currentItem.media_content_id
@@ -546,7 +550,7 @@ export class HaMediaPlayerBrowse extends LitElement {
}
</div>
${
currentItem.can_play &&
canPickCurrent &&
(!currentItem.thumbnail || !this.narrow)
? html`
<ha-button
@@ -118,6 +118,16 @@ class DialogTargetDetails extends LitElement implements HassDialog {
);
};
private _combinedFilter = memoizeOne(
(
entityFilter: HaEntityPickerEntityFilterFunc | undefined,
activeFilter: (entityId: string) => boolean
): HaEntityPickerEntityFilterFunc =>
(stateObj) =>
(!entityFilter || entityFilter(stateObj)) &&
activeFilter(stateObj.entity_id)
);
private _selectorTarget() {
return this._params?.selector?.target || null;
}
@@ -127,6 +137,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return nothing;
}
const { activeFilter } = this._params;
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
let includeDomains: string[] | undefined;
@@ -145,6 +157,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
}
if (activeFilter) {
entityFilter = this._combinedFilter(entityFilter, activeFilter);
}
const waitingForSources =
this._params.selector &&
this._hasIntegration(this._params.selector) &&
@@ -11,6 +11,7 @@ export interface TargetDetailsDialogParams {
selector?: TargetSelector;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
activeFilter?: (entityId: string) => boolean;
includeDomains?: string[];
includeDeviceClasses?: string[];
primaryEntitiesOnly?: boolean;
@@ -34,6 +34,9 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -88,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -92,6 +92,13 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Entities that pass the filters the page currently has on. Narrows the
* count, and the target details, but not what the target resolves to.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -189,7 +196,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
@@ -222,12 +229,7 @@ export class HaTargetPickerItemRow extends LitElement {
${
this.expand || !entries.referenced_entities.length
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</span>`
: html`<ha-button
appearance="filled"
@@ -235,12 +237,7 @@ export class HaTargetPickerItemRow extends LitElement {
size="xs"
@click=${this._openDetails}
>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</ha-button>`
}
</div>
@@ -334,6 +331,28 @@ export class HaTargetPickerItemRow extends LitElement {
`;
}
private _entityCounts(entries: ExtractFromTargetResultReferenced) {
const total = entries.referenced_entities.length;
return {
total,
count: this.activeFilter
? entries.referenced_entities.filter(this.activeFilter).length
: total,
};
}
private _entitiesLabel(entries: ExtractFromTargetResultReferenced): string {
const { count, total } = this._entityCounts(entries);
return this.activeFilter
? this.hass.localize(
"ui.components.target-picker.entities_count_filtered",
{ count, total }
)
: this.hass.localize("ui.components.target-picker.entities_count", {
count,
});
}
private _renderEntries() {
const entries = this.parentEntries || this._entries;
@@ -816,6 +835,7 @@ export class HaTargetPickerItemRow extends LitElement {
itemId: this.itemId,
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
activeFilter: this.activeFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
primaryEntitiesOnly: this.primaryEntitiesOnly,
+180
View File
@@ -0,0 +1,180 @@
// This file is auto-generated from Home Assistant Core's
// entity platform device classes. Do not edit by hand.
// Regenerate with `script/gen_device_classes`.
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
binary_sensor: [
"battery",
"battery_charging",
"carbon_monoxide",
"cold",
"connectivity",
"door",
"garage_door",
"gas",
"heat",
"light",
"lock",
"moisture",
"motion",
"moving",
"occupancy",
"opening",
"plug",
"power",
"presence",
"problem",
"running",
"safety",
"smoke",
"sound",
"tamper",
"update",
"vibration",
"window",
],
button: ["identify", "restart", "update"],
cover: [
"awning",
"blind",
"curtain",
"damper",
"door",
"garage",
"gate",
"shade",
"shutter",
"window",
],
event: ["button", "doorbell", "motion"],
humidifier: ["dehumidifier", "humidifier"],
infrared: ["emitter", "receiver"],
media_player: ["projector", "receiver", "speaker", "tv"],
number: [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
],
sensor: [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"date",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"enum",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"timestamp",
"uptime",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
],
switch: ["outlet", "switch"],
update: ["firmware"],
valve: ["gas", "water"],
};
+171 -54
View File
@@ -21,7 +21,6 @@ import {
} from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
@@ -799,8 +798,8 @@ const clearEnergyCollectionPreferences = (hass: HomeAssistant) => {
};
const scheduleHourlyRefresh = (collection: EnergyCollection) => {
if (collection._refreshTimeout) {
clearTimeout(collection._refreshTimeout);
if (collection._refreshTimeout !== undefined) {
window.clearTimeout(collection._refreshTimeout);
}
if (collection._active && (!collection.end || collection.end > new Date())) {
@@ -859,18 +858,92 @@ export const getEnergyDefaultPeriodStorageKey = (
return `energy-default-period-${key}`;
};
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
// statistic exists — rolling over at midnight would show an empty graph.
export const getNextEnergyPeriodStart = (
// When today's first hourly statistic becomes available (01:00 in the
// configured timezone). Rolling the statistics view over at midnight would
// show an empty graph.
export const getEnergyFirstStatisticAt = (
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): Date => addHours(calcDate(now, startOfDay, locale, config), 1);
// The statistics Energy view shows yesterday until 01:00 so the graph is not
// empty. The real-time "Now" view never does this — it has live data.
export const shouldFallbackEnergyPeriodToYesterday = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): boolean =>
!midnightRollover &&
now.getTime() < getEnergyFirstStatisticAt(now, locale, config).getTime();
// Live day used while a rollover timer is scheduled (today, or the hour-0
// yesterday fallback). Custom dates do not use this. If the user already
// picked today during hour 0, keep today rather than snapping back.
export const getEnergyLiveDayPeriod = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
currentStart: Date
): { start: Date; end: Date } => {
const todayStart = calcDate(now, startOfDay, locale, config);
if (
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
currentStart.getTime() !== todayStart.getTime()
) {
const yesterday = calcDate(now, addDays, locale, config, -1);
return {
start: calcDate(yesterday, startOfDay, locale, config),
end: calcDate(yesterday, endOfDay, locale, config),
};
}
return {
start: todayStart,
end: calcDate(now, endOfDay, locale, config),
};
};
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
// statistic exists — rolling over at midnight would show an empty graph.
// Pass `periodStart` when the collection is on a specific day: hour-0
// yesterday (and any older stale live day) must wake at today 01:00, not
// tomorrow 01:00. Keep tomorrow 01:00 only when the user already picked today.
export const getNextEnergyPeriodStart = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
periodStart?: Date
): Date => {
const dayEnd = calcDate(now, endOfDay, locale, config);
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
const todayStart = calcDate(now, startOfDay, locale, config);
if (
periodStart &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
periodStart.getTime() !== todayStart.getTime()
) {
return getEnergyFirstStatisticAt(now, locale, config);
}
// Next midnight in the configured zone, not browser-local addDays, so a
// DST transition cannot skip a server-tz day.
const nextMidnight = addMilliseconds(
calcDate(now, endOfDay, locale, config),
1
);
return midnightRollover ? nextMidnight : addHours(nextMidnight, 1);
};
export const getEnergyDataCollection = (
@@ -929,12 +1002,80 @@ export const getEnergyDataCollection = (
}
) as EnergyCollection;
collection._active = 0;
collection.prefs = options.prefs;
// True while the collection is tracking the rolling "today" (or hour-0
// yesterday) day. Cleared when the user picks a custom range.
let followLiveDay = false;
const applyLiveDayPeriod = (now: Date): boolean => {
const live = getEnergyLiveDayPeriod(
midnightRollover,
now,
hass.locale,
hass.config,
collection.start
);
const changed =
collection.start.getTime() !== live.start.getTime() ||
collection.end?.getTime() !== live.end.getTime();
collection.start = live.start;
collection.end = live.end;
return changed;
};
const clearUpdatePeriodTimeout = () => {
if (collection._updatePeriodTimeout !== undefined) {
window.clearTimeout(collection._updatePeriodTimeout);
collection._updatePeriodTimeout = undefined;
}
};
const scheduleUpdatePeriod = () => {
clearUpdatePeriodTimeout();
const scheduledAt = new Date();
collection._updatePeriodTimeout = window.setTimeout(
() => {
if (applyLiveDayPeriod(new Date())) {
collection.refresh();
}
scheduleUpdatePeriod();
},
Math.max(
0,
getNextEnergyPeriodStart(
midnightRollover,
scheduledAt,
hass.locale,
hass.config,
collection.start
).getTime() - scheduledAt.getTime()
)
);
};
const origSubscribe = collection.subscribe;
collection.subscribe = (subscriber: (data: EnergyData) => void) => {
// Catch up before origSubscribe so the first fetch uses the live day.
// Refresh only when state already exists: cold subscribe fetches via
// origSubscribe; a re-subscribe inside the 5s unsub grace does not.
const needsRefresh =
followLiveDay &&
applyLiveDayPeriod(new Date()) &&
collection.state !== undefined;
if (followLiveDay) {
scheduleUpdatePeriod();
}
const unsub = origSubscribe(subscriber);
collection._active++;
if (needsRefresh) {
collection.refresh();
}
if (collection._refreshTimeout === undefined) {
scheduleHourlyRefresh(collection);
}
@@ -942,79 +1083,55 @@ export const getEnergyDataCollection = (
return () => {
collection._active--;
if (collection._active < 1) {
clearTimeout(collection._refreshTimeout);
collection._refreshTimeout = undefined;
if (collection._refreshTimeout !== undefined) {
window.clearTimeout(collection._refreshTimeout);
collection._refreshTimeout = undefined;
}
clearUpdatePeriodTimeout();
}
unsub();
};
};
collection._active = 0;
collection.prefs = options.prefs;
const now = new Date();
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
// Set start to start of today if we have data for today, otherwise yesterday.
// The real-time "Now" view always tracks today; it shows live data even
// before today's first statistic exists, so it never falls back to yesterday.
const now = new Date();
const preferredPeriod =
(localStorage.getItem(
getEnergyDefaultPeriodStorageKey(hass, options.key)
) as DateRange) || "today";
const period =
preferredPeriod === "today" && hour === "0" && !midnightRollover
preferredPeriod === "today" &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
hass.locale,
hass.config
)
? "yesterday"
: preferredPeriod;
const [start, end] = calcDateRange(hass.locale, hass.config, period);
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
const scheduleUpdatePeriod = () => {
collection._updatePeriodTimeout = window.setTimeout(
() => {
collection.start = calcDate(
new Date(),
startOfDay,
hass.locale,
hass.config
);
collection.end = calcDate(
new Date(),
endOfDay,
hass.locale,
hass.config
);
collection.refresh();
scheduleUpdatePeriod();
},
getNextEnergyPeriodStart(
midnightRollover,
new Date(),
hass.locale,
hass.config
).getTime() - Date.now()
);
};
scheduleUpdatePeriod();
followLiveDay = preferredPeriod === "today";
collection.isActive = () => !!collection._active;
collection.clearPrefs = () => {
collection.prefs = undefined;
};
collection.setPeriod = (newStart: Date, newEnd?: Date) => {
if (collection._updatePeriodTimeout) {
clearTimeout(collection._updatePeriodTimeout);
collection._updatePeriodTimeout = undefined;
}
clearUpdatePeriodTimeout();
collection.start = newStart;
collection.end = newEnd;
if (
const periodNow = new Date();
followLiveDay =
collection.start.getTime() ===
calcDate(new Date(), startOfDay, hass.locale, hass.config).getTime() &&
calcDate(periodNow, startOfDay, hass.locale, hass.config).getTime() &&
collection.end?.getTime() ===
calcDate(new Date(), endOfDay, hass.locale, hass.config).getTime()
) {
calcDate(periodNow, endOfDay, hass.locale, hass.config).getTime();
if (followLiveDay) {
scheduleUpdatePeriod();
}
};
+75
View File
@@ -0,0 +1,75 @@
import type { LocalizeFunc } from "../../common/translations/localize";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../sensor_entity_constants";
export const NO_DEVICE_CLASS = "none";
// Mirrors core's `<Domain>DeviceClass` enums, until the backend exposes them.
export const DOMAIN_DEVICE_CLASSES: Record<string, readonly string[]> = {
binary_sensor: [
"battery",
"battery_charging",
"carbon_monoxide",
"cold",
"connectivity",
"door",
"garage_door",
"gas",
"heat",
"light",
"lock",
"moisture",
"motion",
"moving",
"occupancy",
"opening",
"plug",
"power",
"presence",
"problem",
"running",
"safety",
"smoke",
"sound",
"tamper",
"update",
"vibration",
"window",
],
button: ["identify", "restart", "update"],
cover: [
"awning",
"blind",
"curtain",
"damper",
"door",
"garage",
"gate",
"shade",
"shutter",
"window",
],
event: ["button", "doorbell", "motion"],
humidifier: ["dehumidifier", "humidifier"],
image_processing: ["alpr", "face", "ocr"],
infrared: ["emitter", "receiver"],
media_player: ["projector", "receiver", "speaker", "tv"],
number: SENSOR_NUMERIC_DEVICE_CLASSES,
sensor: [
...SENSOR_NUMERIC_DEVICE_CLASSES,
"date",
"enum",
"timestamp",
"uptime",
],
switch: ["outlet", "switch"],
update: ["firmware"],
valve: ["gas", "water"],
};
export const computeDeviceClassName = (
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string =>
localize(`component.${domain}.entity_component.${deviceClass}.name`) ||
deviceClass;
+91
View File
@@ -0,0 +1,91 @@
import { computeDomain } from "../../common/entity/compute_domain";
import type { HomeAssistant } from "../../types";
import { DOMAIN_DEVICE_CLASSES, NO_DEVICE_CLASS } from "./device_class";
const SEPARATOR = "/";
export interface EntityType {
domain: string;
deviceClass?: string;
}
export const entityTypeKey = (domain: string, deviceClass?: string): string =>
deviceClass ? `${domain}${SEPARATOR}${deviceClass}` : domain;
export const parseEntityType = (key: string): EntityType => {
const index = key.indexOf(SEPARATOR);
return index === -1
? { domain: key }
: {
domain: key.slice(0, index),
deviceClass: key.slice(index + SEPARATOR.length),
};
};
export const entityTypesNeedStates = (types?: string[]): boolean =>
!!types?.some((key) => key.includes(SEPARATOR));
// A domain worth no split maps to an empty list rather than to its lone bucket.
export const usedEntityTypes = (
states: HomeAssistant["states"]
): Map<string, string[]> => {
const byDomain = new Map<string, Set<string>>();
for (const stateObj of Object.values(states)) {
const domain = computeDomain(stateObj.entity_id);
let classes = byDomain.get(domain);
if (!classes) {
classes = new Set();
byDomain.set(domain, classes);
}
if (domain in DOMAIN_DEVICE_CLASSES) {
classes.add(stateObj.attributes.device_class || NO_DEVICE_CLASS);
}
}
return new Map(
[...byDomain].map(([domain, classes]) => [
domain,
classes.size > 1 ? [...classes] : [],
])
);
};
// Relies on a domain and its device classes never being selected at once.
export const entityTypeFilterFunc = (
types: string[],
states: HomeAssistant["states"]
): ((entityId: string) => boolean) => {
const domains = new Set<string>();
const deviceClasses = new Map<string, Set<string>>();
for (const key of types) {
const { domain, deviceClass } = parseEntityType(key);
if (deviceClass === undefined) {
domains.add(domain);
} else {
let classes = deviceClasses.get(domain);
if (!classes) {
classes = new Set();
deviceClasses.set(domain, classes);
}
classes.add(deviceClass);
}
}
return (entityId: string) => {
const domain = computeDomain(entityId);
if (domains.has(domain)) {
return true;
}
const classes = deviceClasses.get(domain);
if (!classes) {
return false;
}
const stateObj = states[entityId];
if (!stateObj) {
return false;
}
return classes.has(stateObj.attributes.device_class || NO_DEVICE_CLASS);
};
};
+34 -1
View File
@@ -1,9 +1,33 @@
import type { HomeAssistant } from "../types";
import type { CallWS, HomeAssistant } from "../types";
export interface ESPHomeEncryptionKey {
encryption_key: string;
}
export type ESPHomeSerialPortType = "TTL" | "RS232" | "RS485";
export interface ESPHomeBluetoothProxyCapabilities {
supported: boolean;
}
export interface ESPHomeZWaveProxyCapabilities {
supported: boolean;
home_id: number;
}
export interface ESPHomeSerialProxy {
name: string;
port_type: ESPHomeSerialPortType | null;
url: string;
}
export interface ESPHomeDeviceCapabilities {
available: boolean;
bluetooth_proxy: ESPHomeBluetoothProxyCapabilities;
zwave_proxy: ESPHomeZWaveProxyCapabilities;
serial_proxies: ESPHomeSerialProxy[];
}
export const fetchESPHomeEncryptionKey = (
hass: HomeAssistant,
entry_id: string
@@ -12,3 +36,12 @@ export const fetchESPHomeEncryptionKey = (
type: "esphome/get_encryption_key",
entry_id,
});
export const fetchESPHomeDeviceCapabilities = (
hass: { callWS: CallWS },
deviceId: string
): Promise<ESPHomeDeviceCapabilities> =>
hass.callWS({
type: "esphome/get_device_capabilities",
device_id: deviceId,
});
+164
View File
@@ -0,0 +1,164 @@
import { computeDomain } from "../common/entity/compute_domain";
import type { ConfigEntry } from "./config_entries";
import type { DeviceRegistryEntry } from "./device/device_registry";
import type { ESPHomeDeviceCapabilities } from "./esphome";
import type { ESPHomeFrontendUserData } from "./frontend";
export const MUSIC_ASSISTANT_ADDON_SLUG = "d5369777_music_assistant";
export const MUSIC_ASSISTANT_DOCS_URL = "https://music-assistant.io/";
export const ESPHOME_SERIAL_INTEGRATIONS = [
"anthemav",
"monoprice",
"russound_rio",
"jvc_projector",
"elkm1",
] as const;
export type ESPHomeSerialIntegrationDomain =
(typeof ESPHOME_SERIAL_INTEGRATIONS)[number];
export type ESPHomeCapabilityId =
"bluetooth" | "audio" | "connectivity" | "serial";
export type ESPHomeCapabilityStatus =
"not-started" | "active" | "detected" | "completed";
export interface ESPHomeSetupStatus {
bluetooth?: "completed";
audio?: "active" | "completed";
connectivity?: "not-started" | "detected" | "completed";
serial?: "not-started";
}
export const CAPABILITY_ORDER: ESPHomeCapabilityId[] = [
"bluetooth",
"audio",
"connectivity",
"serial",
];
/** Capability accents from the ESPHome device setup prototype. */
export const ESPHOME_CAPABILITY_ACCENTS: Record<ESPHomeCapabilityId, string> = {
bluetooth: "#2962ff",
audio: "#53c22b",
connectivity: "#00acc1",
serial: "#8353d1",
};
export const deviceHasMediaPlayerEntity = (
deviceId: string,
entities: Iterable<{ entity_id: string; device_id?: string | null }>
): boolean => {
for (const entity of entities) {
if (
entity.device_id === deviceId &&
computeDomain(entity.entity_id) === "media_player"
) {
return true;
}
}
return false;
};
export const hasESPHomeSetupCapabilities = (
capabilities: ESPHomeDeviceCapabilities | undefined | null,
options: { mediaPlayerSupported?: boolean } = {}
): boolean =>
Boolean(
capabilities &&
(capabilities.bluetooth_proxy.supported ||
options.mediaPlayerSupported ||
capabilities.zwave_proxy.supported ||
capabilities.serial_proxies.length > 0)
);
export const hasZWaveJSEntryForDevice = (
deviceId: string,
devices: Record<string, DeviceRegistryEntry>,
entries: ConfigEntry[]
): boolean => {
const zwaveEntryIds = new Set(
entries
.filter((entry) => entry.domain === "zwave_js" && !entry.disabled_by)
.map((entry) => entry.entry_id)
);
if (!zwaveEntryIds.size) {
return false;
}
const device = devices[deviceId];
if (device?.config_entries.some((entryId) => zwaveEntryIds.has(entryId))) {
return true;
}
return Object.values(devices).some(
(candidate) =>
candidate.via_device_id === deviceId &&
candidate.config_entries.some((entryId) => zwaveEntryIds.has(entryId))
);
};
export const deriveESPHomeSetupStatus = (
capabilities: ESPHomeDeviceCapabilities,
options: {
mediaPlayerSupported: boolean;
musicAssistantLoaded: boolean;
zwaveJsEntryExists: boolean;
}
): ESPHomeSetupStatus => {
const status: ESPHomeSetupStatus = {};
if (capabilities.bluetooth_proxy.supported) {
status.bluetooth = "completed";
}
if (options.mediaPlayerSupported) {
status.audio = options.musicAssistantLoaded ? "completed" : "active";
}
if (capabilities.zwave_proxy.supported) {
if (options.zwaveJsEntryExists) {
status.connectivity = "completed";
} else if (capabilities.zwave_proxy.home_id !== 0) {
status.connectivity = "detected";
} else {
status.connectivity = "not-started";
}
}
if (capabilities.serial_proxies.length > 0) {
status.serial = "not-started";
}
return status;
};
export const getESPHomeSetupCapabilityIds = (
status: ESPHomeSetupStatus
): ESPHomeCapabilityId[] =>
CAPABILITY_ORDER.filter((id) => status[id] !== undefined);
export const countRemainingESPHomeCapabilities = (
status: ESPHomeSetupStatus
): number =>
getESPHomeSetupCapabilityIds(status).filter(
(id) => status[id] !== "completed"
).length;
export const hasStartedNonBluetoothESPHomeSetup = (
status: ESPHomeSetupStatus
): boolean =>
status.audio === "completed" || status.connectivity === "completed";
export const isESPHomeSetupDeferred = (
data: ESPHomeFrontendUserData | null | undefined,
deviceId: string
): boolean => Boolean(data?.setupDeferred?.includes(deviceId));
export const withDeferredESPHomeDevice = (
data: ESPHomeFrontendUserData | null | undefined,
deviceId: string
): ESPHomeFrontendUserData => ({
...data,
setupDeferred: [...new Set([...(data?.setupDeferred ?? []), deviceId])],
});
+5
View File
@@ -18,6 +18,10 @@ export interface SidebarFrontendUserData {
hiddenPanels?: string[];
}
export interface ESPHomeFrontendUserData {
setupDeferred?: string[];
}
export interface CoreFrontendSystemData {
default_panel?: string;
onboarded_version?: string;
@@ -46,6 +50,7 @@ declare global {
interface FrontendUserData {
core: CoreFrontendUserData;
sidebar: SidebarFrontendUserData;
esphome: ESPHomeFrontendUserData;
}
interface FrontendSystemData {
core: CoreFrontendSystemData;
+1 -1
View File
@@ -1,4 +1,4 @@
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_numeric_device_classes";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_entity_constants";
import type { HomeAssistant } from "../types";
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
+431
View File
@@ -0,0 +1,431 @@
// This file is auto-generated from Home Assistant Core's `DEVICE_CLASS_UNITS`
// and `STATE_CLASS_UNITS`) and `SensorDeviceClass`
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
// Regenerate with `script/gen_sensor_entity_constants`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
];
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
absolute_humidity: ["g/m³", "mg/m³"],
apparent_power: ["kVA", "mVA", "VA"],
aqi: [null],
area: ["ac", "cm²", "ft²", "ha", "in²", "km²", "mi²", "mm²", "m²", "yd²"],
atmospheric_pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
battery: ["%"],
blood_glucose_concentration: ["mg/dL", "mmol/L"],
carbon_monoxide: ["mg/m³", "ppb", "ppm", "μg/m³"],
carbon_dioxide: ["ppm"],
conductivity: ["mS/cm", "S/cm", "μS/cm"],
current: ["A", "mA", "μA"],
data_rate: [
"B/s",
"bit/s",
"GB/s",
"Gbit/s",
"GiB/s",
"kB/s",
"kbit/s",
"KiB/s",
"MB/s",
"Mbit/s",
"MiB/s",
],
data_size: [
"B",
"bit",
"EB",
"EiB",
"GB",
"Gbit",
"GiB",
"kB",
"kbit",
"KiB",
"MB",
"Mbit",
"MiB",
"PB",
"PiB",
"TB",
"TiB",
"YB",
"YiB",
"ZB",
"ZiB",
],
distance: ["cm", "ft", "in", "km", "m", "mi", "mm", "nmi", "yd"],
duration: ["d", "h", "min", "ms", "s", "μs"],
energy: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
energy_distance: ["km/kWh", "kWh/100km", "mi/kWh", "Wh/km"],
energy_storage: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
frequency: ["GHz", "Hz", "kHz", "MHz", "mHz"],
gas: ["CCF", "ft³", "L", "MCF", "m³"],
humidity: ["%"],
illuminance: ["lx"],
irradiance: ["BTU/(h⋅ft²)", "W/m²"],
moisture: ["%"],
nitrogen_dioxide: ["ppb", "ppm", "μg/m³"],
nitrogen_monoxide: ["ppb", "μg/m³"],
nitrous_oxide: ["μg/m³"],
ozone: ["ppb", "ppm", "μg/m³"],
ph: [null],
pm1: ["μg/m³"],
pm10: ["μg/m³"],
pm25: ["μg/m³"],
pm4: ["μg/m³"],
power_factor: ["%", null],
power: ["GW", "kW", "MW", "mW", "TW", "W"],
precipitation: ["cm", "in", "mm"],
precipitation_intensity: ["in/d", "in/h", "mm/d", "mm/h"],
pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
radon: ["Bq/m³", "pCi/L"],
reactive_energy: ["kvarh", "varh"],
reactive_power: ["kvar", "mvar", "var"],
signal_strength: ["dB", "dBm"],
sound_pressure: ["dB", "dBA"],
speed: [
"Beaufort",
"ft/s",
"in/d",
"in/h",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/d",
"mm/h",
"mm/s",
"mph",
],
sulphur_dioxide: ["ppb", "μg/m³"],
temperature: ["K", "°C", "°F"],
temperature_delta: ["K", "°C", "°F"],
volatile_organic_compounds: ["mg/m³", "μg/m³"],
volatile_organic_compounds_parts: ["ppb", "ppm"],
voltage: ["kV", "MV", "mV", "V", "μV"],
volume: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
volume_flow_rate: [
"ft³/min",
"gal/d",
"gal/h",
"gal/min",
"L/h",
"L/min",
"L/s",
"mL/s",
"m³/h",
"m³/min",
"m³/s",
],
volume_storage: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
water: ["CCF", "ft³", "gal", "L", "MCF", "m³"],
weight: ["g", "kg", "lb", "mg", "oz", "st", "μg"],
wind_direction: ["°"],
wind_speed: [
"Beaufort",
"ft/s",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/s",
"mph",
],
};
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<
string,
(string | null)[]
> = {
absolute_humidity: ["g/m³", "mg/m³"],
apparent_power: ["kVA", "mVA", "VA"],
area: ["ac", "cm²", "ft²", "ha", "in²", "km²", "mi²", "mm²", "m²", "yd²"],
atmospheric_pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
blood_glucose_concentration: ["mg/dL", "mmol/L"],
carbon_monoxide: ["mg/m³", "ppb", "ppm", "μg/m³"],
conductivity: ["mS/cm", "S/cm", "μS/cm"],
current: ["A", "mA", "μA"],
data_rate: [
"B/s",
"bit/s",
"GB/s",
"Gbit/s",
"GiB/s",
"kB/s",
"kbit/s",
"KiB/s",
"MB/s",
"Mbit/s",
"MiB/s",
],
data_size: [
"B",
"bit",
"EB",
"EiB",
"GB",
"Gbit",
"GiB",
"kB",
"kbit",
"KiB",
"MB",
"Mbit",
"MiB",
"PB",
"PiB",
"TB",
"TiB",
"YB",
"YiB",
"ZB",
"ZiB",
],
distance: ["cm", "ft", "in", "km", "m", "mi", "mm", "nmi", "yd"],
duration: ["d", "h", "min", "ms", "s", "μs"],
energy: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
energy_distance: ["km/kWh", "kWh/100km", "mi/kWh", "Wh/km"],
energy_storage: [
"cal",
"Gcal",
"GJ",
"GWh",
"J",
"kcal",
"kJ",
"kWh",
"Mcal",
"MJ",
"MWh",
"mWh",
"TWh",
"Wh",
],
frequency: ["GHz", "Hz", "kHz", "MHz", "mHz"],
gas: ["CCF", "ft³", "L", "MCF", "m³"],
nitrogen_dioxide: ["ppb", "ppm", "μg/m³"],
nitrogen_monoxide: ["ppb", "μg/m³"],
ozone: ["ppb", "ppm", "μg/m³"],
power: ["GW", "kW", "MW", "mW", "TW", "W"],
power_factor: ["%", null],
precipitation: ["cm", "in", "mm"],
precipitation_intensity: ["in/d", "in/h", "mm/d", "mm/h"],
pressure: [
"bar",
"cbar",
"hPa",
"inHg",
"inH₂O",
"kPa",
"mbar",
"mmHg",
"mPa",
"Pa",
"psi",
],
radon: ["Bq/m³", "pCi/L"],
reactive_energy: ["kvarh", "varh"],
reactive_power: ["kvar", "mvar", "var"],
speed: [
"Beaufort",
"ft/s",
"in/d",
"in/h",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/d",
"mm/h",
"mm/s",
"mph",
],
sulphur_dioxide: ["ppb", "μg/m³"],
temperature: ["K", "°C", "°F"],
temperature_delta: ["K", "°C", "°F"],
volatile_organic_compounds: ["mg/m³", "μg/m³"],
volatile_organic_compounds_parts: ["ppb", "ppm"],
voltage: ["kV", "MV", "mV", "V", "μV"],
volume: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
volume_flow_rate: [
"ft³/min",
"gal/d",
"gal/h",
"gal/min",
"L/h",
"L/min",
"L/s",
"mL/s",
"m³/h",
"m³/min",
"m³/s",
],
volume_storage: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
water: ["CCF", "ft³", "gal", "L", "MCF", "m³"],
weight: ["g", "kg", "lb", "mg", "oz", "st", "μg"],
wind_speed: [
"Beaufort",
"ft/s",
"in/s",
"km/h",
"kn",
"m/min",
"m/s",
"mm/s",
"mph",
],
};
export const SENSOR_STATE_CLASSES: string[] = [
"measurement",
"measurement_angle",
"total",
"total_increasing",
];
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
measurement_angle: ["°"],
};
-64
View File
@@ -1,64 +0,0 @@
// This file is auto-generated from Home Assistant Core's `SensorDeviceClass`
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
// Regenerate with `script/gen_numeric_device_classes`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"radon",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { ESPHomeDeviceCapabilities } from "../../data/esphome";
export const loadESPHomeDeviceSetupDialog = () =>
import("./dialog-esphome-device-setup");
export interface ESPHomeDeviceSetupDialogParams {
deviceId: string;
deviceName?: string;
capabilities?: ESPHomeDeviceCapabilities;
mediaPlayerSupported?: boolean;
dialogClosedCallback?: () => void;
}
export const showESPHomeDeviceSetupDialog = (
element: HTMLElement,
dialogParams: ESPHomeDeviceSetupDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-esphome-device-setup",
dialogImport: loadESPHomeDeviceSetupDialog,
dialogParams,
});
};
+104 -5
View File
@@ -10,6 +10,7 @@ import deepClone from "deep-clone-simple";
import { deepActiveElement } from "../../common/dom/deep-active-element";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
@@ -123,6 +124,7 @@ export class DialogForm
this._initialData = deepClone(this._data);
this._error = undefined;
this._resetDirtyTracking();
void this._focusActiveForm(nested);
};
private _popStack(): StackEntry | undefined {
@@ -142,16 +144,111 @@ export class DialogForm
return prev;
}
private async _afterFormRender(): Promise<void> {
await this.updateComplete;
await this._form?.updateComplete;
await nextRender();
}
private async _waitForSelectorElements(): Promise<void> {
const selectors = this._form?.shadowRoot?.querySelectorAll("ha-selector");
if (selectors?.length) {
await Promise.all(
Array.from(selectors, (element) =>
"updateComplete" in element
? (element as LitElement).updateComplete
: undefined
)
);
}
const pending = this._undefinedCustomElements(this._form);
if (!pending.length) {
return;
}
await Promise.all(pending.map((tag) => customElements.whenDefined(tag)));
await nextRender();
}
private _undefinedCustomElements(root?: ParentNode): string[] {
const tags = new Set<string>();
const visit = (node: ParentNode) => {
if (node instanceof Element && node.shadowRoot) {
visit(node.shadowRoot);
}
for (const child of node.children) {
if (
child.localName.includes("-") &&
!customElements.get(child.localName)
) {
tags.add(child.localName);
}
visit(child);
}
};
if (root) {
visit(root);
}
return [...tags];
}
private _focusFirstControl(root = this._form): void {
if (!root) {
return;
}
const visit = (node: ParentNode): HTMLElement | undefined => {
if (node instanceof Element && node.shadowRoot) {
const inShadow = visit(node.shadowRoot);
if (inShadow) {
return inShadow;
}
}
for (const child of node.children) {
if (
child instanceof HTMLElement &&
child.matches("input, textarea, select, button")
) {
return child;
}
const found = visit(child);
if (found) {
return found;
}
}
return undefined;
};
visit(root)?.focus();
}
private async _focusActiveForm(
expectedParams: FormDialogParams
): Promise<void> {
await this._afterFormRender();
if (!this._open || this._params !== expectedParams) {
return;
}
await this._waitForSelectorElements();
if (!this._open || this._params !== expectedParams) {
return;
}
this._focusFirstControl();
}
private async _restoreFocusAndScroll(
scrollTop: number,
expectedParams: FormDialogParams,
focusTarget?: Element
): Promise<void> {
await this.updateComplete;
await this._form?.updateComplete;
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
await this._afterFormRender();
if (!this._open || this._params !== expectedParams || !this._dialog) {
return;
@@ -159,6 +256,8 @@ export class DialogForm
if (focusTarget instanceof HTMLElement && focusTarget.isConnected) {
focusTarget.focus();
} else {
this._focusFirstControl();
}
this._dialog.bodyContainer.scrollTop = scrollTop;
@@ -73,7 +73,10 @@ class HaMoreInfoDetails extends LitElement {
stateEntries,
attributes,
yamlData: stateYamlData,
} = this._getDetailData(this._stateObj);
} = this._getDetailData(
this._stateObj,
this.hass.formatEntityAttributeName
);
const { floor, area, device } = getEntityContext(
this._stateObj,
this.hass.entities,
@@ -211,7 +214,10 @@ class HaMoreInfoDetails extends LitElement {
private _getDetailData = memoizeOne(
(
stateObj: HassEntity
stateObj: HassEntity,
// cache key only: a new function is assigned when translation-based
// format functions reload, invalidating results formatted via this.hass
_formatEntityAttributeName: HomeAssistant["formatEntityAttributeName"]
): {
stateEntries: DetailEntry[];
attributes: { name: string; label: string }[];
+8 -4
View File
@@ -1029,10 +1029,14 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
}
if (changedProps.has("_currView") || changedProps.has("_entry")) {
if (this._currView === "settings" && this._entry) {
this._initDirtyTracking({ type: "deep" });
}
if (
this._currView === "settings" &&
this._entry &&
((changedProps.has("_currView") &&
changedProps.get("_currView") !== "settings") ||
(changedProps.has("_entry") && !changedProps.get("_entry")))
) {
this._initDirtyTracking({ type: "deep" });
}
if (changedProps.has("_currView")) {
@@ -42,6 +42,24 @@ export const climateEntityFilters: EntityFilter[] = [
},
];
export const hasClimateEntities = (hass: HomeAssistant): boolean => {
const hasAreaSensor = Object.values(hass.areas).some(
(area) =>
(area.temperature_entity_id && hass.states[area.temperature_entity_id]) ||
(area.humidity_entity_id && hass.states[area.humidity_entity_id])
);
if (hasAreaSensor) {
return true;
}
const entityIds = Object.keys(hass.states);
return climateEntityFilters.some((filter) =>
entityIds.some(generateEntityFilter(hass, filter))
);
};
const processAreasForClimate = (
areaIds: string[],
hass: HomeAssistant,
@@ -0,0 +1,259 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiAccessPoint,
mdiBluetooth,
mdiCheck,
mdiMusic,
mdiSwapHorizontal,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import { internationalizationContext } from "../../../../data/context";
import {
ESPHOME_CAPABILITY_ACCENTS,
getESPHomeSetupCapabilityIds,
type ESPHomeCapabilityId,
type ESPHomeSetupStatus,
} from "../../../../data/esphome_setup";
const CAPABILITY_ICONS: Record<ESPHomeCapabilityId, string> = {
bluetooth: mdiBluetooth,
audio: mdiMusic,
connectivity: mdiAccessPoint,
serial: mdiSwapHorizontal,
};
const CAPABILITY_TITLE_KEYS: Record<ESPHomeCapabilityId, LocalizeKeys> = {
bluetooth: "ui.panel.config.devices.esphome.setup_capability_bluetooth_title",
audio: "ui.panel.config.devices.esphome.setup_capability_audio_title",
connectivity:
"ui.panel.config.devices.esphome.setup_capability_connectivity_title",
serial: "ui.panel.config.devices.esphome.setup_capability_serial_title",
};
@customElement("ha-esphome-setup-banner")
export class HaESPHomeSetupBanner extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public deviceName!: string;
@property({ attribute: false }) public status: ESPHomeSetupStatus = {};
@property({ type: Boolean }) public started = false;
protected render() {
if (!this._i18n) {
return nothing;
}
const localize = this._i18n.localize;
const ids = getESPHomeSetupCapabilityIds(this.status);
return html`
<ha-card outlined>
<div class="content">
<div class="text">
<h2>
${
this.started
? localize(
"ui.panel.config.devices.esphome.setup_continue_title",
{ name: this.deviceName }
)
: localize("ui.panel.config.devices.esphome.setup_title")
}
</h2>
<p>
${localize("ui.panel.config.devices.esphome.setup_intro", {
count: ids.length,
})}
</p>
<div class="actions">
<ha-button @click=${this._setup}>
${localize("ui.panel.config.devices.esphome.setup_action")}
</ha-button>
<ha-button appearance="plain" @click=${this._later}>
${localize("ui.panel.config.devices.esphome.setup_later")}
</ha-button>
</div>
</div>
${
ids.length
? html`
<div class="pips">
${ids.map((id) => this._renderPip(id))}
</div>
`
: ""
}
</div>
</ha-card>
`;
}
private _renderPip(id: ESPHomeCapabilityId) {
const localize = this._i18n!.localize;
const completed = this.status[id] === "completed";
return html`
<div class="pip">
<span
class="pip-chip"
style="--capability-accent: ${ESPHOME_CAPABILITY_ACCENTS[id]}"
>
<ha-svg-icon .path=${CAPABILITY_ICONS[id]}></ha-svg-icon>
${
completed
? html`
<span
class="pip-check"
role="img"
aria-label=${localize(
"ui.panel.config.devices.esphome.setup_status_completed"
)}
>
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
</span>
`
: ""
}
</span>
<span class="pip-label"> ${localize(CAPABILITY_TITLE_KEYS[id])} </span>
</div>
`;
}
private _setup() {
fireEvent(this, "esphome-setup");
}
private _later() {
fireEvent(this, "esphome-setup-later");
}
static styles = css`
ha-card {
display: block;
}
.content {
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
padding: var(--ha-space-5);
}
.text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--ha-space-2);
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
}
p {
margin: 0;
max-width: 48ch;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--ha-space-1);
margin-block-start: var(--ha-space-2);
}
.pips {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--ha-space-3) var(--ha-space-6);
align-content: center;
}
.pip {
display: flex;
align-items: center;
gap: var(--ha-space-3);
min-width: 0;
}
.pip-chip {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-circle);
background: color-mix(in srgb, var(--capability-accent) 12%, transparent);
color: var(--capability-accent);
}
.pip-chip ha-svg-icon {
--mdc-icon-size: 18px;
}
.pip-check {
position: absolute;
inset-inline-end: -2px;
bottom: -2px;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: var(--ha-border-radius-circle);
background: var(--success-color);
color: var(--ha-color-on-success-loud);
border: 2px solid var(--card-background-color);
box-sizing: border-box;
}
.pip-check ha-svg-icon {
--mdc-icon-size: 10px;
}
.pip-label {
min-width: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
@media (min-width: 768px) {
.content {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-8);
}
.text {
flex: 1.05 1 16rem;
}
.pips {
flex: 1 1 12rem;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-esphome-setup-banner": HaESPHomeSetupBanner;
}
interface HASSDomEvents {
"esphome-setup": undefined;
"esphome-setup-later": undefined;
}
interface HTMLElementEventMap {
"esphome-setup": HASSDomEvent<HASSDomEvents["esphome-setup"]>;
"esphome-setup-later": HASSDomEvent<HASSDomEvents["esphome-setup-later"]>;
}
}
@@ -0,0 +1,85 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import { internationalizationContext } from "../../../../data/context";
@customElement("ha-esphome-setup-reminder")
export class HaESPHomeSetupReminder extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Number }) public remaining = 0;
@property({ type: Number }) public count = 0;
protected render() {
if (!this._i18n) {
return nothing;
}
const localize = this._i18n.localize;
const title =
this.remaining > 0
? localize("ui.panel.config.devices.esphome.setup_reminder_remaining", {
count: this.remaining,
})
: localize("ui.panel.config.devices.esphome.setup_reminder_done");
const lead =
this.remaining > 0
? localize("ui.panel.config.devices.esphome.setup_reminder_intro", {
count: this.count,
})
: localize(
"ui.panel.config.devices.esphome.setup_reminder_done_intro",
{ count: this.count }
);
return html`
<ha-card outlined .header=${title}>
<div class="card-content">
<p>${lead}</p>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._setup}>
${localize("ui.panel.config.devices.esphome.setup_action")}
</ha-button>
</div>
</ha-card>
`;
}
private _setup() {
fireEvent(this, "esphome-setup");
}
static styles = css`
:host {
display: block;
}
p {
margin: 0;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-normal);
}
.card-content {
padding-block-end: var(--ha-space-6);
}
/* Same as Device info slot="actions" on ha-config-device-page
(and ha-device-entities-card). */
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--ha-space-1) var(--ha-space-4) var(--ha-space-1)
var(--ha-space-1);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-esphome-setup-reminder": HaESPHomeSetupReminder;
}
}
@@ -19,7 +19,7 @@ import {
mdiTextureBox,
mdiTools,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -83,12 +83,33 @@ import {
findBatteryEntity,
updateEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import {
fetchESPHomeDeviceCapabilities,
type ESPHomeDeviceCapabilities,
} from "../../../data/esphome";
import {
countRemainingESPHomeCapabilities,
deriveESPHomeSetupStatus,
deviceHasMediaPlayerEntity,
getESPHomeSetupCapabilityIds,
hasESPHomeSetupCapabilities,
hasStartedNonBluetoothESPHomeSetup,
hasZWaveJSEntryForDevice,
isESPHomeSetupDeferred,
withDeferredESPHomeDevice,
} from "../../../data/esphome_setup";
import {
saveFrontendUserData,
subscribeFrontendUserData,
type ESPHomeFrontendUserData,
} from "../../../data/frontend";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import { regenerateEntityIds } from "../../../data/regenerate_entity_ids";
import type { RelatedResult } from "../../../data/search";
import { findRelated } from "../../../data/search";
import { filterAddToSceneEntityIds } from "../../../dialogs/add-to/add-to";
import { showESPHomeDeviceSetupDialog } from "../../../dialogs/esphome-device-setup/show-dialog-esphome-device-setup";
import {
showAlertDialog,
showConfirmationDialog,
@@ -110,6 +131,8 @@ import "../../logbook/ha-logbook";
import "./device-detail/ha-device-child-devices-card";
import "./device-detail/ha-device-entities-card";
import "./device-detail/ha-device-info-card";
import "./device-detail/ha-esphome-setup-banner";
import "./device-detail/ha-esphome-setup-reminder";
import "./device-detail/ha-device-linked-devices-card";
import "./device-detail/ha-device-via-devices-card";
import { showDeviceAddToDialog } from "./device-detail/show-dialog-device-add-to";
@@ -206,8 +229,18 @@ export class HaConfigDevicePage extends LitElement {
@state() private _deviceAlerts: DeviceAlert[] = [];
@state() private _esphomeCapabilities?: ESPHomeDeviceCapabilities;
@state() private _esphomeUserData: ESPHomeFrontendUserData | null = null;
@state() private _esphomeUserDataReady = false;
private _deviceAlertsActionsTimeout?: number;
private _unsubEsphomeUserData?: UnsubscribeFunc;
private _esphomeUserDataSubGeneration = 0;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -367,6 +400,7 @@ export class HaConfigDevicePage extends LitElement {
this._deviceAlerts = [];
this._deleteButtons = [];
this._diagnosticDownloadLinks = [];
this._esphomeCapabilities = undefined;
}
if (changedProps.has("deviceId") || changedProps.has("entries")) {
@@ -377,6 +411,7 @@ export class HaConfigDevicePage extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
loadDeviceRegistryDetailDialog();
this._subscribeESPHomeUserData();
}
protected updated(changedProps: PropertyValues<this>) {
@@ -390,9 +425,19 @@ export class HaConfigDevicePage extends LitElement {
}
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeESPHomeUserData();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this._deviceAlertsActionsTimeout);
this._esphomeUserDataSubGeneration += 1;
this._unsubEsphomeUserData?.();
this._unsubEsphomeUserData = undefined;
}
protected render() {
@@ -446,6 +491,43 @@ export class HaConfigDevicePage extends LitElement {
: undefined;
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const mediaPlayerSupported = deviceHasMediaPlayerEntity(
this.deviceId,
entities
);
const showESPHomeSetup =
this._esphomeUserDataReady &&
hasESPHomeSetupCapabilities(this._esphomeCapabilities, {
mediaPlayerSupported,
});
const esphomeDeferred = isESPHomeSetupDeferred(
this._esphomeUserData,
this.deviceId
);
const esphomeStatus = this._esphomeCapabilities
? deriveESPHomeSetupStatus(this._esphomeCapabilities, {
mediaPlayerSupported,
musicAssistantLoaded: isComponentLoaded(
this.hass.config,
"music_assistant"
),
zwaveJsEntryExists: hasZWaveJSEntryForDevice(
this.deviceId,
this.hass.devices,
this.entries
),
})
: undefined;
const esphomeRemaining = esphomeStatus
? countRemainingESPHomeCapabilities(esphomeStatus)
: 0;
const esphomeCapabilityCount = esphomeStatus
? getESPHomeSetupCapabilityIds(esphomeStatus).length
: 0;
const esphomeStarted = esphomeStatus
? hasStartedNonBluetoothESPHomeSetup(esphomeStatus)
: false;
const deviceInfo: TemplateResult[] = integrations.length
? [
html`<ha-list-nav slot="actions">
@@ -901,6 +983,15 @@ export class HaConfigDevicePage extends LitElement {
: ""
}
</ha-device-info-card>
${
showESPHomeSetup && esphomeDeferred
? html`<ha-esphome-setup-reminder
.remaining=${esphomeRemaining}
.count=${esphomeCapabilityCount}
@esphome-setup=${this._showESPHomeSetup}
></ha-esphome-setup-reminder>`
: nothing
}
<ha-device-child-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
@@ -1115,6 +1206,20 @@ export class HaConfigDevicePage extends LitElement {
}
</div>
</div>
${
showESPHomeSetup && !esphomeDeferred
? html`
<ha-esphome-setup-banner
class="fullwidth"
.deviceName=${deviceName}
.status=${esphomeStatus}
.started=${esphomeStarted}
@esphome-setup=${this._showESPHomeSetup}
@esphome-setup-later=${this._deferESPHomeSetup}
></ha-esphome-setup-banner>
`
: nothing
}
${columnContents.map(
(contents) => html`<div class="column">${contents}</div>`
)}
@@ -1129,6 +1234,105 @@ export class HaConfigDevicePage extends LitElement {
clearTimeout(this._deviceAlertsActionsTimeout);
this._getDeviceActions();
this._getDeviceAlerts();
this._fetchESPHomeCapabilities();
}
}
private async _subscribeESPHomeUserData() {
const generation = this._esphomeUserDataSubGeneration;
try {
const unsub = await subscribeFrontendUserData(
this.hass.connection,
"esphome",
({ value }) => {
if (generation !== this._esphomeUserDataSubGeneration) {
return;
}
this._esphomeUserData = value;
this._esphomeUserDataReady = true;
}
);
if (generation !== this._esphomeUserDataSubGeneration) {
unsub();
return;
}
this._unsubEsphomeUserData = unsub;
} catch (_err) {
if (generation !== this._esphomeUserDataSubGeneration) {
return;
}
this._esphomeUserData = null;
this._esphomeUserDataReady = true;
}
}
private async _fetchESPHomeCapabilities() {
const deviceId = this.deviceId;
const device = this.hass.devices[deviceId];
if (!device) {
this._esphomeCapabilities = undefined;
return;
}
const domains = this._integrations(
device,
this.entries,
this.manifests
).map((entry) => entry.domain);
if (!domains.includes("esphome")) {
this._esphomeCapabilities = undefined;
return;
}
try {
const capabilities = await fetchESPHomeDeviceCapabilities(
this.hass,
deviceId
);
if (this.deviceId !== deviceId) {
return;
}
this._esphomeCapabilities = capabilities;
} catch (_err) {
if (this.deviceId !== deviceId) {
return;
}
this._esphomeCapabilities = undefined;
}
}
private _showESPHomeSetup = () => {
const device = this.hass.devices[this.deviceId];
showESPHomeDeviceSetupDialog(this, {
deviceId: this.deviceId,
deviceName: device
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
: undefined,
capabilities: this._esphomeCapabilities,
mediaPlayerSupported: deviceHasMediaPlayerEntity(
this.deviceId,
this._entities(this.deviceId, this._entityReg, this.hass.devices)
),
dialogClosedCallback: () => {
this._fetchESPHomeCapabilities();
},
});
};
private async _deferESPHomeSetup() {
const previous = this._esphomeUserData;
const next = withDeferredESPHomeDevice(previous, this.deviceId);
this._esphomeUserData = next;
try {
await saveFrontendUserData(this.hass.connection, "esphome", next);
} catch (err: unknown) {
this._esphomeUserData = previous;
await showAlertDialog(this, {
text:
err instanceof Error
? err.message
: this.hass.localize(
"ui.panel.config.devices.esphome.setup_error_defer"
),
});
}
}
@@ -306,6 +306,7 @@ class DialogZWaveJSAddNode extends LitElement {
if (this._step === "choose_security_strategy") {
return html`<zwave-js-add-node-select-security-strategy
.hass=${this.hass}
.inclusionStrategy=${this._inclusionStrategy}
@z-wave-strategy-selected=${this._setSecurityStrategy}
></zwave-js-add-node-select-security-strategy>`;
}
@@ -1,4 +1,4 @@
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { css, html, LitElement } from "lit";
import memoizeOne from "memoize-one";
@@ -14,7 +14,7 @@ import "../../../../../../components/ha-form/ha-form";
export class ZWaveJsAddNodeSelectMethod extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() public _inclusionStrategy?: InclusionStrategy;
@property({ attribute: false }) public inclusionStrategy?: InclusionStrategy;
private _getSchema = memoizeOne((localize: LocalizeFunc): HaFormSchema[] => [
{
@@ -62,7 +62,7 @@ export class ZWaveJsAddNodeSelectMethod extends LitElement {
return html`
<ha-form
.schema=${this._getSchema(this.hass.localize)}
.data=${{ strategy: this._inclusionStrategy?.toString() }}
.data=${{ strategy: this.inclusionStrategy?.toString() }}
@value-changed=${this._selectStrategy}
.computeLabel=${this._computeLabel}
>
+10 -2
View File
@@ -49,6 +49,7 @@ import {
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import "../../components/ha-spinner";
import "../../components/ha-top-app-bar-fixed";
import type { EntitySources } from "../../data/entity/entity_sources";
@@ -216,6 +217,7 @@ class HaPanelHistory extends LitElement {
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.entitySources=${this._entitySources}
.disabled=${this._isLoading}
.description=${this.hass.localize(
"ui.panel.history.no_targets"
@@ -536,8 +538,10 @@ class HaPanelHistory extends LitElement {
this.hass.areas
),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
// Only a device class narrows down using the states.
entityTypesNeedStates(this._filters.types)
? this.hass.states
: EMPTY_STATES,
this.hass.entities,
this._entitySources
);
@@ -732,6 +736,10 @@ class HaPanelHistory extends LitElement {
haStyle,
haStyleScrollbar,
css`
:host {
/* The target picker chips need more room than a plain filter list. */
--ha-filter-pane-width: 340px;
}
ha-top-app-bar-fixed {
height: 100vh;
overflow-x: hidden;
+8 -2
View File
@@ -44,6 +44,7 @@ import {
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import { entityTypesNeedStates } from "../../data/entity/entity_type";
import "../../components/ha-top-app-bar-fixed";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { EntitySources } from "../../data/entity/entity_sources";
@@ -163,6 +164,7 @@ export class HaPanelLogbook extends LitElement {
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.entitySources=${this._entitySources}
.entityFilter=${this._filterFunc}
.description=${this.hass.localize(
"ui.panel.logbook.no_targets"
@@ -331,8 +333,10 @@ export class HaPanelLogbook extends LitElement {
return this.__filterEntityIds(
targetEntities ?? this.__logbookEntityIds(this.hass.states),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
// Only a device class narrows down using the states.
entityTypesNeedStates(this._filters.types)
? this.hass.states
: EMPTY_STATES,
this.hass.entities,
this._entitySources
);
@@ -579,6 +583,8 @@ export class HaPanelLogbook extends LitElement {
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
/* The target picker chips need more room than a plain filter list. */
--ha-filter-pane-width: 340px;
}
.content {
@@ -194,6 +194,7 @@ export class HuiEnergyDevicesGraphCard
this._legendData
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.sonificationLabelFormatter=${this._sonificationLabel}
.extraComponents=${[PieChart]}
.expandLegend=${this._config.expand_legend}
click-label-for-more-info
@@ -293,6 +294,14 @@ export class HuiEnergyDevicesGraphCard
}
);
// The chart data is keyed on statistic ids, which is what Chart2Music would
// otherwise announce. Names that aren't statistics — the untracked slice —
// are already display text, so those stay as they are.
private _sonificationLabel = (label: string): string | undefined =>
this._deviceLabels[label] || this._data?.statsMetadata[label]
? this._getDeviceName(label)
: undefined;
private _getDeviceName(statisticId: string): string {
const suffix = this._compoundStats.includes(statisticId)
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
+183
View File
@@ -0,0 +1,183 @@
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import "../../../components/ha-card";
import "../../../components/ha-relative-time";
import "../../../components/ha-state-icon";
import "../../../components/tile/ha-tile-container";
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 { tileCardStyle } from "./tile/tile-card-style";
import type { AlertCardConfig } from "./types";
@customElement("hui-alert-card")
export class HuiAlertCard extends LitElement implements LovelaceCard {
@state() private _config?: AlertCardConfig;
@state()
@consumeEntityState({ entityIdPath: ["_config", "entity"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
public setConfig(config: AlertCardConfig): void {
if (
!isValidEntityId(config.entity) ||
(config.color !== undefined && typeof config.color !== "string") ||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public getCardSize(): number {
return 1;
}
public getGridOptions(): LovelaceGridOptions {
return {
columns: 6,
rows: 1,
min_columns: 6,
min_rows: 1,
};
}
protected render() {
if (!this._config || !this._formatters) {
return nothing;
}
const stateObj = this._stateObj;
if (!stateObj) {
return nothing;
}
const stateDisplay = this._formatters.formatEntityState(stateObj);
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
});
const pulse = this._config.pulse === true;
const hasColor =
this._config.color !== undefined && this._config.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,
"--ha-alert-static-opacity": pulse
? undefined
: "var(--ha-alert-pulse-opacity)",
})}
>
<ha-tile-container
.interactive=${true}
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
@action=${this._handleAction}
>
<ha-tile-icon slot="icon">
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary">${computeStateName(stateObj)}</span>
<span slot="secondary">
${stateDisplay} ·
<ha-relative-time
.datetime=${stateObj.last_changed}
.format=${"short"}
></ha-relative-time>
${areaName ? html` · ${areaName}` : nothing}
</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
private _handleAction(ev: ActionHandlerEvent): void {
if (ev.detail.action === "tap" && this._config) {
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
}
}
static styles = [
tileCardStyle,
css`
@keyframes pulse-opacity {
from {
opacity: 0;
}
to {
opacity: var(--ha-pulse-opacity, 0.3);
}
}
:host {
display: block;
--ha-alert-pulse-duration: 1s;
--ha-alert-pulse-opacity: 0.3;
--ha-alert-static-opacity: 0;
}
ha-card {
position: relative;
overflow: hidden;
height: 100%;
}
ha-card:not(.no-color) {
--tile-color: var(--ha-alert-color);
}
ha-card.no-color {
--tile-color: var(--secondary-text-color);
}
ha-card::before {
position: absolute;
inset: 0;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
background-color: var(--ha-alert-color);
content: "";
opacity: var(--ha-alert-static-opacity);
pointer-events: none;
}
ha-card.pulse::before {
--ha-pulse-opacity: var(--ha-alert-pulse-opacity);
animation: pulse-opacity var(--ha-alert-pulse-duration) ease-in-out
infinite alternate;
}
ha-card:not(.pulse)::before {
animation: none;
}
ha-tile-container {
position: relative;
}
@media (prefers-reduced-motion: reduce) {
ha-card::before {
animation: none;
opacity: var(--ha-alert-pulse-opacity);
}
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-alert-card": HuiAlertCard;
}
}
+7
View File
@@ -718,6 +718,13 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
double_tap_action?: ActionConfig;
}
export interface AlertCardConfig extends LovelaceCardConfig {
type: "alert";
entity: string;
color?: string;
pulse?: boolean;
}
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
title: string;
entities: string[];
@@ -80,6 +80,7 @@ const LAZY_LOAD_TYPES = {
shortcut: () => import("../cards/hui-shortcut-card"),
"discovered-devices": () => import("../cards/hui-discovered-devices-card"),
repairs: () => import("../cards/hui-repairs-card"),
alert: () => import("../cards/hui-alert-card"),
updates: () => import("../cards/hui-updates-card"),
gauge: () => import("../cards/hui-gauge-card"),
"history-graph": () => import("../cards/hui-history-graph-card"),
@@ -26,7 +26,7 @@ import type {
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { SelectOption } from "../../../../data/selector";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../../../../data/sensor_numeric_device_classes";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../../../../data/sensor_entity_constants";
import type { HomeAssistant } from "../../../../types";
import type {
LovelaceCardFeatureConfig,
@@ -21,6 +21,7 @@ import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { ShortcutItem } from "../../../../data/home_shortcuts";
import { resolveShortcutItems } from "../../../../data/home_shortcuts";
import type { HomeAssistant } from "../../../../types";
import { hasClimateEntities } from "../../../climate/strategies/climate-view-strategy";
import type {
AreaCardConfig,
DiscoveredDevicesCardConfig,
@@ -291,10 +292,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
generateEntityFilter(hass, filter)
);
const climateFilters = HOME_SUMMARIES_FILTERS.climate.map((filter) =>
generateEntityFilter(hass, filter)
);
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(hass, filter)
);
@@ -307,9 +304,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
const hasMediaPlayers =
findEntities(allEntities, mediaPlayerFilter).length > 0;
const hasClimate =
hass.panels.climate &&
findEntities(allEntities, climateFilters).length > 0;
const hasClimate = hass.panels.climate && hasClimateEntities(hass);
const hasSecurity =
hass.panels.security &&
findEntities(allEntities, securityFilters).length > 0;
+62 -3
View File
@@ -806,6 +806,7 @@
"replace_device": "Replace",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"entities_count_filtered": "{count}/{total} {total, plural,\n one {entity}\n other {entities}\n}",
"target_details": "Target details",
"no_targets": "No targets",
"no_target_found": "No target found for {term}",
@@ -835,8 +836,11 @@
},
"style": "Time format style"
},
"filter-device-classes": {
"caption": "Device class"
"filter-entity-types": {
"caption": "Type",
"no_device_class": "No type",
"expand": "Expand",
"collapse": "Collapse"
},
"subpage-data-table": {
"filters": "Filters",
@@ -6952,7 +6956,62 @@
"esphome": {
"show_encryption_key": "Show encryption key",
"encryption_key_title": "ESPHome Encryption Key",
"encryption_key_description": "This is the encryption key for your ESPHome device. Keep it in a safe place, as you may need it when transferring devices between Home Assistant instances."
"encryption_key_description": "This is the encryption key for your ESPHome device. Keep it in a safe place, as you may need it when transferring devices between Home Assistant instances.",
"setup_title": "Set up your ESPHome proxy",
"setup_continue_title": "Continue setup for {name}",
"setup_intro": "This adapter unlocks {count} simultaneous {count, plural,\n one {capability}\n other {capabilities}\n} in Home Assistant. Set them up now, or come back to this checklist any time.",
"setup_action": "Set up",
"setup_later": "Later",
"setup_learn_more": "Learn more",
"setup_reminder_remaining": "Set up {count} more {count, plural,\n one {capability}\n other {capabilities}\n}",
"setup_reminder_done": "Everything is set up",
"setup_reminder_intro": "This adapter unlocks {count} simultaneous {count, plural,\n one {capability}\n other {capabilities}\n} in Home Assistant.",
"setup_reminder_done_intro": "All {count} simultaneous {count, plural,\n one {capability is}\n other {capabilities are}\n} configured.",
"setup_capability_bluetooth_title": "Bluetooth proxy",
"setup_capability_bluetooth_short": "Extend Bluetooth range",
"setup_capability_bluetooth_description": "Add nearby Bluetooth devices into Home Assistant. Adding multiple Bluetooth proxies allows for better connectivity with devices across your home.",
"setup_capability_audio_title": "Stream audio via Sendspin",
"setup_capability_audio_short": "Multi-room sound, recommended with Music Assistant",
"setup_capability_audio_description": "Stream audio from Home Assistant (including announcements, media, or radio); just note this audio will be unsynchronized with other players. For synchronized whole-home audio, use Music Assistant.",
"setup_capability_connectivity_title": "Connect a Zigbee or Z-Wave adapter",
"setup_capability_connectivity_short": "Network connectivity for ZBT-2 or ZWA-2",
"setup_capability_connectivity_description": "Plug a Connect ZBT-2 or ZWA-2 into the USB port to enable Zigbee or Z-Wave connectivity over Ethernet or Wi-Fi. Get your antenna in an optimal location for better device responsiveness.",
"setup_capability_serial_title": "Control a device over serial",
"setup_capability_serial_short": "AV receivers, amplifiers, and more",
"setup_capability_serial_description": "Add serial devices into Home Assistant, allowing for control of A/V receivers and multi-zone amplifiers.",
"setup_audio_home_assistant": "Home Assistant",
"setup_audio_builtin_playback": "Built-in playback",
"setup_audio_music_assistant": "Music Assistant",
"setup_audio_music_assistant_meta": "Synchronized multi-room audio",
"setup_audio_music_assistant_upsell": "The better way to stream with a free app",
"setup_audio_benefit_multiroom": "Synchronized multi-room playback",
"setup_audio_benefit_lossless": "Lossless, high-resolution audio",
"setup_audio_benefit_album_art": "Album art on every screen",
"setup_audio_active": "Active",
"setup_audio_recommended": "Recommended",
"setup_install_music_assistant": "Install Music Assistant",
"setup_open_music_assistant": "Open Music Assistant",
"setup_installing_music_assistant": "Installing Music Assistant",
"setup_starting_music_assistant": "Starting Music Assistant",
"setup_discovering_music_assistant": "Waiting for Music Assistant discovery",
"setup_error_music_assistant": "Couldn't install Music Assistant",
"setup_error_capabilities": "Couldn't load device capabilities",
"setup_error_defer": "Couldn't save setup for later",
"setup_error_serial_integration": "Couldn't start this integration",
"setup_status_completed": "Completed",
"setup_status_active": "Active",
"setup_zwave": "Set up Z-Wave",
"setup_what_are_adapters": "What are these adapters?",
"setup_adapters_title": "Add Zigbee or Z-Wave",
"setup_adapters_intro": "Plug a Home Assistant Connect adapter into your proxy's USB port to add wireless radios:",
"setup_adapter_zbt2": "Connect ZBT-2",
"setup_adapter_zbt2_description": "Adds a Zigbee radio.",
"setup_adapter_zwa2": "Connect ZWA-2",
"setup_adapter_zwa2_description": "Adds a Z-Wave radio.",
"setup_serial_choose": "Choose an integration",
"setup_serial_intro": "Choose an integration to set up a serial device on this ESPHome device. The integration will let you pick the serial port.",
"setup_serial_ports": "Ports on this device",
"setup_serial_integrations": "Serial integrations"
}
},
"entities": {
@@ -0,0 +1,178 @@
import { describe, expect, it, vi } from "vitest";
import { timeCachePromiseFunc } from "../../../src/common/util/time-cache-function-promise";
describe("timeCachePromiseFunc", () => {
it("reuses an in-flight request when the cache key is unchanged", async () => {
let resolveRequest!: (value: string) => void;
const request = new Promise<string>((resolve) => {
resolveRequest = resolve;
});
const fetchData = vi.fn(() => request);
const hass = { version: 1 };
const calls = Array.from({ length: 20 }, () =>
timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
)
);
expect(fetchData).toHaveBeenCalledTimes(1);
resolveRequest("result");
await expect(Promise.all(calls)).resolves.toEqual(Array(20).fill("result"));
expect(fetchData).toHaveBeenCalledTimes(1);
});
it("reuses a resolved cached result while the cache key is unchanged", async () => {
const fetchData = vi.fn().mockResolvedValue("result");
const hass = { version: 1 };
const first = await timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
const second = await timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
expect(first).toBe("result");
expect(second).toBe("result");
expect(fetchData).toHaveBeenCalledTimes(1);
});
it("refetches a resolved cached result when the cache key changes", async () => {
const fetchData = vi
.fn()
.mockResolvedValueOnce("first")
.mockResolvedValueOnce("second");
const hass = { version: 1 };
const first = await timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
hass.version = 2;
const second = await timeCachePromiseFunc(
"_testCache",
30_000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
expect(first).toBe("first");
expect(second).toBe("second");
expect(fetchData).toHaveBeenCalledTimes(2);
});
it("reuses an in-flight request without a cache key validator", async () => {
let resolveRequest!: (value: string) => void;
const request = new Promise<string>((resolve) => {
resolveRequest = resolve;
});
const fetchData = vi.fn(() => request);
const hass = {};
const first = timeCachePromiseFunc(
"_testCacheNoValidator",
30_000,
fetchData,
undefined,
hass
);
const second = timeCachePromiseFunc(
"_testCacheNoValidator",
30_000,
fetchData,
undefined,
hass
);
expect(fetchData).toHaveBeenCalledTimes(1);
resolveRequest("result");
await expect(first).resolves.toBe("result");
await expect(second).resolves.toBe("result");
expect(fetchData).toHaveBeenCalledTimes(1);
});
});
describe("timeCachePromiseFunc cache ownership", () => {
it("does not let an older cache timer clear a newer cached result", async () => {
vi.useFakeTimers();
try {
const fetchData = vi
.fn()
.mockResolvedValueOnce("first")
.mockResolvedValueOnce("second")
.mockResolvedValueOnce("third");
const hass = { version: 1 };
await timeCachePromiseFunc(
"_testTimerOwnership",
1000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
await vi.advanceTimersByTimeAsync(500);
hass.version = 2;
await timeCachePromiseFunc(
"_testTimerOwnership",
1000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
expect(fetchData).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(500);
const result = await timeCachePromiseFunc(
"_testTimerOwnership",
1000,
fetchData,
(currentHass: typeof hass) => currentHass.version,
hass
);
expect(result).toBe("second");
expect(fetchData).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
+245 -11
View File
@@ -1,7 +1,22 @@
import { describe, expect, it } from "vitest";
import { canSonifyChart } from "../../../src/components/chart/chart-sonification";
import type { EChartsType } from "echarts/core";
import { connect } from "echarts-extension-chart2music";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canSonifyChart,
sonifyChart,
} from "../../../src/components/chart/chart-sonification";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { FrontendLocaleData } from "../../../src/data/translation";
import type { HomeAssistant } from "../../../src/types";
import type { HaECSeries } from "../../../src/resources/echarts/echarts";
vi.mock("echarts-extension-chart2music", () => ({
connect: vi.fn((_chart, options) => {
options.cc.setAttribute("aria-live", "assertive");
return { update: vi.fn(), dispose: vi.fn() };
}),
}));
const series = (
items: { type: string; id?: string; name?: string; data?: unknown[] }[]
) => items as unknown as HaECSeries;
@@ -95,9 +110,9 @@ describe("canSonifyChart", () => {
expect(canSonifyChart(series([{ type: "line" }]))).toBe(false);
});
it("rejects value-first pairs, which the extension reads as a non-numeric y", () => {
// The energy device charts encode [amount, categoryName]. Chart2Music takes
// value as [x, y], so every one of those points is dropped.
it("accepts value-first pairs, like the energy device charts", () => {
// The energy device charts encode [amount, categoryName]. Since v0.1.1 the
// extension reads a series that way when every item has that shape.
expect(
canSonifyChart(
series([
@@ -110,18 +125,77 @@ describe("canSonifyChart", () => {
},
])
)
).toBe(true);
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
[12.5, "sensor.a"],
[8.25, "sensor.b"],
],
},
])
)
).toBe(true);
});
it("does not read empty markers or numeric strings as value-first pairs", () => {
// [2, "-"] is an ECharts gap inside an ordinary series and [1, "8"] is a
// numeric string y; treating either as [y, category] would fabricate
// points the chart never plotted.
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[1, "-"],
[2, "-"],
],
},
])
)
).toBe(false);
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[1, "8"],
[2, "12"],
],
},
])
)
).toBe(false);
});
it("reads a series value-first only when every item has that shape", () => {
// A lone [number, string] among ordinary pairs is an unreadable value, not
// a transposed one.
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
[12.5, "sensor.a"],
["-", "sensor.b"],
],
},
])
)
).toBe(false);
});
it("rejects a chart left with a single readable point", () => {
// The device pie's slices are all unreadable, leaving only its one-number
// total series — nothing the arrow keys could move between.
// One device slice alone offers nothing the arrow keys could move between.
expect(
canSonifyChart(
series([
{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] },
{ type: "pie", data: [24.5] },
])
series([{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] }])
)
).toBe(false);
});
@@ -190,3 +264,163 @@ describe("canSonifyChart", () => {
).toBe(false);
});
});
describe("sonifyChart", () => {
const mockedConnect = vi.mocked(connect);
const fakeChart = (option: Record<string, unknown>) =>
({
getOption: () => option,
on: vi.fn(),
off: vi.fn(),
}) as unknown as EChartsType;
const sonify = (
option: Record<string, unknown>,
formatLabel?: (label: string) => string | undefined
) =>
sonifyChart(fakeChart(option), {
cc: document.createElement("div"),
localize: ((key: string) => key) as LocalizeFunc,
locale: { language: "en" } as FrontendLocaleData,
config: {} as HomeAssistant["config"],
formatLabel,
onError: () => undefined,
});
const connectedAxes = () => mockedConnect.mock.lastCall![1]!.axes!;
beforeEach(() => {
mockedConnect.mockClear();
});
it("swaps the axis label sources on horizontal charts", async () => {
const sonification = await sonify({
xAxis: [{ type: "value", name: "kWh" }],
yAxis: [{ type: "category", name: "Device", data: ["a", "b"] }],
series: [
{
type: "bar",
data: [
[12.5, "a"],
[7.25, "b"],
],
},
],
});
expect(sonification).not.toBeNull();
// The announced x walks the categories of the y axis, and the announced y
// is the value from the x axis.
expect(connectedAxes()).toMatchObject({
x: { label: "Device" },
y: { label: "kWh" },
});
});
it("keeps the axis label sources on vertical charts", async () => {
await sonify({
xAxis: [{ type: "category", name: "Month", data: ["Jan", "Feb"] }],
yAxis: [{ type: "value", name: "kWh" }],
series: [{ type: "bar", data: [5, 9] }],
});
expect(connectedAxes()).toMatchObject({
x: { label: "Month" },
y: { label: "kWh" },
});
// Without a formatter the extension's own labels stay untouched.
expect(connectedAxes().x.valueLabels).toBeUndefined();
});
it("announces category keys through the label formatter", async () => {
await sonify(
{
xAxis: [{ type: "value", name: "kWh" }],
yAxis: [{ type: "category", data: ["sensor.a", "sensor.b"] }],
series: [
{
type: "bar",
data: [
{ name: "sensor.a", value: [12.5, "sensor.a"] },
{ name: "sensor.b", value: [7.25, "sensor.b"] },
],
},
],
},
(label) => (label === "sensor.a" ? "Dishwasher" : "Oven")
);
expect(connectedAxes().x.valueLabels).toEqual(["Dishwasher", "Oven"]);
});
it("formats pie slice names and keeps the ones the formatter declines", async () => {
await sonify(
{
series: [
{
type: "pie",
data: [
{ name: "sensor.a", value: [12.5, "sensor.a"] },
{ name: "Untracked consumption", value: [7.25, "untracked"] },
],
},
],
},
(label) => (label === "sensor.a" ? "Dishwasher" : undefined)
);
expect(connectedAxes().x.valueLabels).toEqual([
"Dishwasher",
"Untracked consumption",
]);
});
it("labels pie slices even when the options carry hidden empty axes", async () => {
// ha-chart-base's merged options include default axes even for pies, so
// an empty category axis must not block the item-name labels.
await sonify(
{
xAxis: [{ type: "category", show: false, data: [] }],
yAxis: [{ type: "value", show: false }],
series: [
{
type: "pie",
data: [
{ name: "sensor.a", value: [12.5, "sensor.a"] },
{ name: "sensor.b", value: [7.25, "sensor.b"] },
],
},
],
},
(label) => (label === "sensor.a" ? "Dishwasher" : "Oven")
);
expect(connectedAxes().x.valueLabels).toEqual(["Dishwasher", "Oven"]);
});
it("never overrides labels on time axis charts", async () => {
await sonify(
{
xAxis: [{ type: "time" }],
yAxis: [{ type: "value", name: "°C" }],
series: [
{
type: "line",
data: [
[1700000000000, 21.5],
[1700003600000, 22.1],
],
},
],
},
() => "wrong"
);
expect(connectedAxes()).toMatchObject({
x: { label: "ui.components.history_charts.time" },
y: { label: "°C" },
});
expect(connectedAxes().x.valueLabels).toBeUndefined();
});
});
+116
View File
@@ -0,0 +1,116 @@
import {
addDays,
addHours,
addMilliseconds,
endOfDay,
startOfDay,
} from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import { afterAll, assert, beforeAll, describe, it } from "vitest";
import { calcDate } from "../../src/common/datetime/calc_date";
import {
type FrontendLocaleData,
NumberFormat,
TimeFormat,
FirstWeekday,
DateFormat,
TimeZone,
} from "../../src/data/translation";
import {
getEnergyFirstStatisticAt,
getEnergyLiveDayPeriod,
getNextEnergyPeriodStart,
} from "../../src/data/energy";
const locale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
const tokyoConfig = { time_zone: "Asia/Tokyo" } as HassConfig;
// Dedicated file so Europe/Berlin can be pinned without leaking into other
// suites. Vitest's default TZ is Etc/UTC, where browser-local addDays and
// server-zone math often agree — so UTC CI would miss this regression.
describe("energy period DST (Europe/Berlin local TZ)", () => {
const originalTz = process.env.TZ;
beforeAll(() => {
process.env.TZ = "Europe/Berlin";
});
afterAll(() => {
process.env.TZ = originalTz;
});
it("uses a DST-fallback browser zone for this file", () => {
// 24 Oct 2026 is still CEST. If TZ pinning failed, offset is 0 (UTC).
assert.equal(
new Date("2026-10-24T14:30:00.000Z").getTimezoneOffset(),
-120
);
});
it("schedules tomorrow 01:00 in the server zone, not via browser-local addDays", () => {
// 23:30 JST on 24 Oct 2026. Europe/Berlin falls back on 25 Oct; local
// addDays(now, 1) then startOfDay in Tokyo skips to 26 Oct 01:00 JST.
const now = new Date("2026-10-24T14:30:00.000Z");
// Compare to the tz-internal formula rather than a hardcoded instant:
// the formula is the production invariant, and a pinned ISO string would
// not explain why UTC CI cannot catch a raw addDays(now, 1) regression.
const tzInternal = addHours(
addMilliseconds(calcDate(now, endOfDay, locale, tokyoConfig), 1),
1
);
const browserLocalAddDays = getEnergyFirstStatisticAt(
addDays(now, 1),
locale,
tokyoConfig
);
const actual = getNextEnergyPeriodStart(false, now, locale, tokyoConfig);
assert.equal(actual.getTime(), tzInternal.getTime());
assert.notEqual(actual.getTime(), browserLocalAddDays.getTime());
assert.equal(
actual.getTime(),
new Date("2026-10-24T16:00:00.000Z").getTime()
);
assert.equal(
browserLocalAddDays.getTime(),
new Date("2026-10-25T16:00:00.000Z").getTime()
);
});
it("resolves yesterday in the server zone, not via browser-local addDays", () => {
// 00:30 JST on 26 Oct 2026. Browser-local addDays can land two days back.
const now = new Date("2026-10-25T15:30:00.000Z");
const tzInternal = calcDate(
calcDate(now, addDays, locale, tokyoConfig, -1),
startOfDay,
locale,
tokyoConfig
);
const browserLocalAddDays = calcDate(
addDays(now, -1),
startOfDay,
locale,
tokyoConfig
);
const live = getEnergyLiveDayPeriod(
false,
now,
locale,
tokyoConfig,
new Date(0)
);
assert.equal(live.start.getTime(), tzInternal.getTime());
assert.notEqual(live.start.getTime(), browserLocalAddDays.getTime());
});
});
+408 -19
View File
@@ -1,6 +1,6 @@
import { startOfDay } from "date-fns";
import { addDays, endOfDay, startOfDay } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import { assert, describe, it } from "vitest";
import { afterEach, assert, describe, it, vi } from "vitest";
import { calcDate } from "../../src/common/datetime/calc_date";
import {
@@ -20,6 +20,11 @@ import {
formatPowerShort,
getNextEnergyPeriodStart,
getEnergyDefaultPeriodStorageKey,
getEnergyFirstStatisticAt,
getEnergyLiveDayPeriod,
shouldFallbackEnergyPeriodToYesterday,
getEnergyDataCollection,
EMPTY_PREFERENCES,
} from "../../src/data/energy";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
@@ -866,27 +871,55 @@ describe("Self-consumed solar gauge tests", () => {
});
});
describe("getNextEnergyPeriodStart", () => {
const locale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
// Pin the time zone (via TimeZone.server) so energy period tests do not
// depend on the machine's local zone.
const energyPeriodLocale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
const energyPeriodConfig = { time_zone: "America/New_York" } as HassConfig;
const energyPeriodDay = (now: Date, offset = 0) => {
const day = calcDate(
now,
addDays,
energyPeriodLocale,
energyPeriodConfig,
offset
);
return {
start: calcDate(day, startOfDay, energyPeriodLocale, energyPeriodConfig),
end: calcDate(day, endOfDay, energyPeriodLocale, energyPeriodConfig),
};
// Pin the time zone (via TimeZone.server) so the test does not depend on the
// machine's local zone.
const config = { time_zone: "America/New_York" } as HassConfig;
};
describe("getNextEnergyPeriodStart", () => {
const isMidnight = (date: Date) =>
calcDate(date, startOfDay, locale, config).getTime() === date.getTime();
calcDate(
date,
startOfDay,
energyPeriodLocale,
energyPeriodConfig
).getTime() === date.getTime();
it("rolls the real-time view over at midnight, statistics an hour later", () => {
const now = new Date("2026-06-19T15:30:00-04:00");
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
const statistics = getNextEnergyPeriodStart(false, now, locale, config);
const realTime = getNextEnergyPeriodStart(
true,
now,
energyPeriodLocale,
energyPeriodConfig
);
const statistics = getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig
);
// Real-time rolls over exactly at the next midnight.
assert.isTrue(isMidnight(realTime));
@@ -896,9 +929,14 @@ describe("getNextEnergyPeriodStart", () => {
);
// Statistics roll over an hour after midnight, on the same day boundary.
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000 - 1);
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000);
assert.equal(
calcDate(statistics, startOfDay, locale, config).getTime(),
calcDate(
statistics,
startOfDay,
energyPeriodLocale,
energyPeriodConfig
).getTime(),
realTime.getTime()
);
});
@@ -906,7 +944,12 @@ describe("getNextEnergyPeriodStart", () => {
it("advances the real-time view to the next midnight when called after midnight", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
const realTime = getNextEnergyPeriodStart(
true,
now,
energyPeriodLocale,
energyPeriodConfig
);
assert.isTrue(isMidnight(realTime));
// Next midnight is June 21, not the already-passed June 20 midnight.
@@ -915,6 +958,352 @@ describe("getNextEnergyPeriodStart", () => {
new Date("2026-06-21T00:00:00-04:00").getTime()
);
});
it("wakes a non-today live day at today 01:00 during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const todayOne = new Date("2026-06-20T01:00:00-04:00").getTime();
for (const offset of [-1, -2]) {
assert.equal(
getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, offset).start
).getTime(),
todayOne
);
}
});
it("keeps tomorrow 01:00 when statistics is already on today during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
assert.equal(
getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now).start
).getTime(),
new Date("2026-06-21T01:00:00-04:00").getTime()
);
});
});
describe("shouldFallbackEnergyPeriodToYesterday", () => {
it("is true for the statistics view before 01:00", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
assert.isTrue(
shouldFallbackEnergyPeriodToYesterday(
false,
now,
energyPeriodLocale,
energyPeriodConfig
)
);
assert.equal(
getEnergyFirstStatisticAt(
now,
energyPeriodLocale,
energyPeriodConfig
).getTime(),
new Date("2026-06-20T01:00:00-04:00").getTime()
);
});
it("is false at 01:00 and for the real-time view", () => {
const atOne = new Date("2026-06-20T01:00:00-04:00");
const beforeOne = new Date("2026-06-20T00:30:00-04:00");
assert.isFalse(
shouldFallbackEnergyPeriodToYesterday(
false,
atOne,
energyPeriodLocale,
energyPeriodConfig
)
);
assert.isFalse(
shouldFallbackEnergyPeriodToYesterday(
true,
beforeOne,
energyPeriodLocale,
energyPeriodConfig
)
);
});
});
describe("getEnergyLiveDayPeriod", () => {
it("keeps yesterday during hour 0 when that is the current period", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const { start, end } = energyPeriodDay(now, -1);
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
start
);
assert.equal(live.start.getTime(), start.getTime());
assert.equal(live.end.getTime(), end.getTime());
});
it("keeps today during hour 0 when the user already picked today", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const { start, end } = energyPeriodDay(now);
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
start
);
assert.equal(live.start.getTime(), start.getTime());
assert.equal(live.end.getTime(), end.getTime());
});
it("advances a stale yesterday to today after 01:00", () => {
const now = new Date("2026-06-20T10:00:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -1).start
);
const expected = energyPeriodDay(now);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
it("advances a two-day-old live day to today", () => {
const now = new Date("2026-06-20T10:00:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -2).start
);
const expected = energyPeriodDay(now);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
it("falls back to yesterday for a stale live day during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -2).start
);
const expected = energyPeriodDay(now, -1);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
});
describe("getEnergyDataCollection live day", () => {
afterEach(() => {
localStorage.clear();
vi.useRealTimers();
});
const createCollection = (
key: string,
preset?: string,
midnightRollover = false
) => {
const hass = createMockHass();
hass.locale = energyPeriodLocale;
hass.config = { ...hass.config, time_zone: "America/New_York" };
const callWS = vi.fn(async (msg: { type: string }) => {
if (msg.type === "energy/info") {
return { cost_sensors: {}, solar_forecast_domains: [] };
}
throw new Error(`unexpected ${msg.type}`);
});
Object.assign(hass, {
connection: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
connected: true,
},
callWS,
});
if (preset) {
localStorage.setItem(getEnergyDefaultPeriodStorageKey(hass, key), preset);
}
return {
collection: getEnergyDataCollection(hass, {
key,
prefs: EMPTY_PREFERENCES,
midnightRollover,
}),
callWS,
};
};
const energyInfoFetches = (callWS: ReturnType<typeof vi.fn>) =>
callWS.mock.calls.filter((call) => call[0].type === "energy/info");
it("advances hour-0 yesterday to today at 01:00 and fetches the new day", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
const { collection, callWS } = createCollection("energy_timer");
const refresh = vi.spyOn(collection, "refresh");
const unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
refresh.mockClear();
callWS.mockClear();
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date(), -1).start.getTime()
);
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
// Cards render EnergyData from the websocket store, not collection.start.
// The 01:00 callback must refresh() so getEnergyData runs for today.
assert.equal(refresh.mock.calls.length, 1);
assert.equal(energyInfoFetches(callWS).length, 1);
unsub();
});
it("catches up a stale live day on resubscribe", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection, callWS } = createCollection("energy_catchup");
const refresh = vi.spyOn(collection, "refresh");
let unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
refresh.mockClear();
callWS.mockClear();
unsub();
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
assert.equal(refresh.mock.calls.length, 1);
assert.equal(energyInfoFetches(callWS).length, 1);
unsub();
});
it("does not double-refresh on a cold subscribe after the unsub grace", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection, callWS } = createCollection("energy_cold_refresh");
const refresh = vi.spyOn(collection, "refresh");
let unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
unsub();
await vi.advanceTimersByTimeAsync(5000);
refresh.mockClear();
callWS.mockClear();
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
// The library's first fetch is not collection.refresh(); this spy only
// sees the extra refresh used during the unsub-grace re-subscribe.
assert.equal(refresh.mock.calls.length, 0);
assert.equal(energyInfoFetches(callWS).length, 1);
unsub();
});
it("keeps a custom setPeriod range overnight", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection("energy_custom_period");
let unsub = collection.subscribe(() => undefined);
const custom = energyPeriodDay(new Date(), -5);
collection.setPeriod(custom.start, custom.end);
unsub();
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
unsub = collection.subscribe(() => undefined);
assert.equal(collection.start.getTime(), custom.start.getTime());
unsub();
});
it("does not advance the period after the last subscriber leaves", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
const { collection } = createCollection("energy_unsub_timer");
const unsub = collection.subscribe(() => undefined);
const start = collection.start.getTime();
unsub();
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), start);
});
it("does not roll a remembered week preset over to today", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection("energy_week_stored", "this_week");
const weekStart = collection.start.getTime();
const unsub = collection.subscribe(() => undefined);
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), weekStart);
unsub();
});
it("does not roll a remembered week preset over to today with midnightRollover", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection(
"energy_week_stored_now",
"this_week",
true
);
const weekStart = collection.start.getTime();
const unsub = collection.subscribe(() => undefined);
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), weekStart);
unsub();
});
});
describe("getEnergyDefaultPeriodStorageKey", () => {
+125
View File
@@ -0,0 +1,125 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import type { HomeAssistant } from "../../../src/types";
import {
entityTypeFilterFunc,
entityTypesNeedStates,
parseEntityType,
usedEntityTypes,
} from "../../../src/data/entity/entity_type";
const state = (entityId: string, deviceClass?: string): HassEntity =>
({
entity_id: entityId,
attributes: deviceClass ? { device_class: deviceClass } : {},
}) as unknown as HassEntity;
const makeStates = (...entities: HassEntity[]): HomeAssistant["states"] =>
Object.fromEntries(entities.map((entity) => [entity.entity_id, entity]));
describe("parseEntityType", () => {
it("reads a domain and a device class", () => {
expect(parseEntityType("sensor")).toEqual({ domain: "sensor" });
expect(parseEntityType("sensor/power")).toEqual({
domain: "sensor",
deviceClass: "power",
});
});
});
describe("entityTypesNeedStates", () => {
it("only needs the states for a device class", () => {
expect(entityTypesNeedStates(["light", "cover"])).toBe(false);
expect(entityTypesNeedStates(["light", "sensor/power"])).toBe(true);
expect(entityTypesNeedStates(undefined)).toBe(false);
});
});
describe("usedEntityTypes", () => {
it("splits a domain by device class, none included", () => {
const types = usedEntityTypes(
makeStates(
state("binary_sensor.front_door", "door"),
state("binary_sensor.hall_motion", "motion"),
state("binary_sensor.unknown")
)
);
expect(types.get("binary_sensor")?.sort()).toEqual([
"door",
"motion",
"none",
]);
});
it("keeps a single-bucket domain whole", () => {
const types = usedEntityTypes(
makeStates(
state("light.kitchen"),
state("cover.garage", "garage"),
state("cover.gate", "garage")
)
);
expect(types.get("light")).toEqual([]);
expect(types.get("cover")).toEqual([]);
});
it("ignores a device class on a domain that has none", () => {
const types = usedEntityTypes(makeStates(state("light.kitchen", "bogus")));
expect(types.get("light")).toEqual([]);
});
});
describe("entityTypeFilterFunc", () => {
const states = makeStates(
state("binary_sensor.front_door", "door"),
state("binary_sensor.hall_motion", "motion"),
state("binary_sensor.unknown"),
state("light.kitchen")
);
it("matches a whole domain", () => {
const matches = entityTypeFilterFunc(["binary_sensor"], states);
expect(matches("binary_sensor.front_door")).toBe(true);
expect(matches("binary_sensor.unknown")).toBe(true);
expect(matches("light.kitchen")).toBe(false);
});
it("matches a device class", () => {
const matches = entityTypeFilterFunc(["binary_sensor/door"], states);
expect(matches("binary_sensor.front_door")).toBe(true);
expect(matches("binary_sensor.hall_motion")).toBe(false);
expect(matches("binary_sensor.unknown")).toBe(false);
});
it("matches the entities that carry no device class", () => {
const matches = entityTypeFilterFunc(["binary_sensor/none"], states);
expect(matches("binary_sensor.unknown")).toBe(true);
expect(matches("binary_sensor.front_door")).toBe(false);
});
it("unions the selection", () => {
const matches = entityTypeFilterFunc(
["light", "binary_sensor/door"],
states
);
expect(matches("light.kitchen")).toBe(true);
expect(matches("binary_sensor.front_door")).toBe(true);
expect(matches("binary_sensor.hall_motion")).toBe(false);
});
it("keeps an entity without a state only for its domain", () => {
const matches = entityTypeFilterFunc(["binary_sensor"], states);
const narrowed = entityTypeFilterFunc(["binary_sensor/none"], states);
expect(matches("binary_sensor.disabled")).toBe(true);
expect(narrowed("binary_sensor.disabled")).toBe(false);
});
});
+382
View File
@@ -0,0 +1,382 @@
import { describe, expect, it } from "vitest";
import type { ConfigEntry } from "../../src/data/config_entries";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import type { ESPHomeDeviceCapabilities } from "../../src/data/esphome";
import {
countRemainingESPHomeCapabilities,
deriveESPHomeSetupStatus,
deviceHasMediaPlayerEntity,
getESPHomeSetupCapabilityIds,
hasESPHomeSetupCapabilities,
hasStartedNonBluetoothESPHomeSetup,
hasZWaveJSEntryForDevice,
isESPHomeSetupDeferred,
withDeferredESPHomeDevice,
} from "../../src/data/esphome_setup";
const capabilities = (
overrides: Partial<ESPHomeDeviceCapabilities> = {}
): ESPHomeDeviceCapabilities => ({
available: true,
bluetooth_proxy: { supported: false },
zwave_proxy: {
supported: false,
home_id: 0,
},
serial_proxies: [],
...overrides,
});
const deriveOptions = (
overrides: Partial<{
mediaPlayerSupported: boolean;
musicAssistantLoaded: boolean;
zwaveJsEntryExists: boolean;
}> = {}
) => ({
mediaPlayerSupported: false,
musicAssistantLoaded: false,
zwaveJsEntryExists: false,
...overrides,
});
const entry = (overrides: Partial<ConfigEntry>): ConfigEntry =>
({
entry_id: "entry",
domain: "esphome",
title: "Device",
source: "user",
state: "loaded",
supports_options: true,
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: false,
supported_subentry_types: {},
num_subentries: 0,
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
...overrides,
}) as ConfigEntry;
const device = (
id: string,
overrides: Partial<DeviceRegistryEntry> = {}
): DeviceRegistryEntry =>
({
id,
config_entries: [],
via_device_id: null,
...overrides,
}) as DeviceRegistryEntry;
describe("hasESPHomeSetupCapabilities", () => {
it("is false when capabilities are missing", () => {
expect(hasESPHomeSetupCapabilities(undefined)).toBe(false);
});
it("is true when advertised even if the device is unavailable", () => {
expect(
hasESPHomeSetupCapabilities(
capabilities({
available: false,
bluetooth_proxy: { supported: true },
}),
{ mediaPlayerSupported: true }
)
).toBe(true);
});
it("is false for a device with no advertised capabilities", () => {
expect(hasESPHomeSetupCapabilities(capabilities())).toBe(false);
});
it("is true when any capability is supported", () => {
expect(
hasESPHomeSetupCapabilities(
capabilities({
bluetooth_proxy: { supported: true },
})
)
).toBe(true);
expect(
hasESPHomeSetupCapabilities(capabilities(), {
mediaPlayerSupported: true,
})
).toBe(true);
expect(
hasESPHomeSetupCapabilities(
capabilities({ zwave_proxy: { supported: true, home_id: 0 } })
)
).toBe(true);
expect(
hasESPHomeSetupCapabilities(
capabilities({
serial_proxies: [
{
name: "UART",
port_type: "TTL",
url: "esphome-hass://proxy/uart",
},
],
})
)
).toBe(true);
});
});
describe("deviceHasMediaPlayerEntity", () => {
it("is true when the device has a media_player entity", () => {
expect(
deviceHasMediaPlayerEntity("dev-1", [
{ entity_id: "sensor.proxy_rssi", device_id: "dev-1" },
{ entity_id: "media_player.proxy", device_id: "dev-1" },
])
).toBe(true);
});
it("ignores media players on other devices and non-media entities", () => {
expect(
deviceHasMediaPlayerEntity("dev-1", [
{ entity_id: "media_player.other", device_id: "dev-2" },
{ entity_id: "switch.proxy", device_id: "dev-1" },
])
).toBe(false);
});
});
describe("deriveESPHomeSetupStatus", () => {
it("omits rows that are not supported", () => {
expect(deriveESPHomeSetupStatus(capabilities(), deriveOptions())).toEqual(
{}
);
});
it("marks Bluetooth completed when the proxy is compiled in", () => {
const status = deriveESPHomeSetupStatus(
capabilities({
bluetooth_proxy: { supported: true },
}),
deriveOptions()
);
expect(status.bluetooth).toBe("completed");
expect(status.audio).toBeUndefined();
expect(status.connectivity).toBeUndefined();
expect(status.serial).toBeUndefined();
});
it("marks audio from media_player entities, not capabilities", () => {
const caps = capabilities();
expect(
deriveESPHomeSetupStatus(
caps,
deriveOptions({ mediaPlayerSupported: true })
).audio
).toBe("active");
expect(
deriveESPHomeSetupStatus(
caps,
deriveOptions({
mediaPlayerSupported: true,
musicAssistantLoaded: true,
})
).audio
).toBe("completed");
expect(
deriveESPHomeSetupStatus(caps, deriveOptions()).audio
).toBeUndefined();
});
it("derives connectivity from home_id and a zwave_js entry", () => {
const supported = capabilities({
zwave_proxy: { supported: true, home_id: 0 },
});
expect(
deriveESPHomeSetupStatus(supported, deriveOptions()).connectivity
).toBe("not-started");
expect(
deriveESPHomeSetupStatus(
capabilities({
zwave_proxy: { supported: true, home_id: 123456 },
}),
deriveOptions()
).connectivity
).toBe("detected");
expect(
deriveESPHomeSetupStatus(
capabilities({
zwave_proxy: { supported: true, home_id: 123456 },
}),
deriveOptions({ zwaveJsEntryExists: true })
).connectivity
).toBe("completed");
});
it("keeps serial at not-started when ports are advertised", () => {
expect(
deriveESPHomeSetupStatus(
capabilities({
serial_proxies: [
{
name: "RS232",
port_type: "RS232",
url: "esphome-hass://proxy/rs232",
},
],
}),
deriveOptions()
).serial
).toBe("not-started");
});
});
describe("remaining capabilities and continue-setup", () => {
it("counts incomplete rows and ignores Bluetooth", () => {
const status = deriveESPHomeSetupStatus(
capabilities({
bluetooth_proxy: { supported: true },
zwave_proxy: { supported: true, home_id: 0 },
serial_proxies: [
{ name: "UART", port_type: "TTL", url: "esphome-hass://proxy/uart" },
],
}),
deriveOptions({ mediaPlayerSupported: true })
);
expect(getESPHomeSetupCapabilityIds(status)).toEqual([
"bluetooth",
"audio",
"connectivity",
"serial",
]);
expect(getESPHomeSetupCapabilityIds(status).length).toBe(4);
expect(countRemainingESPHomeCapabilities(status)).toBe(3);
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(false);
});
it("does not count Bluetooth when the proxy is unsupported", () => {
const status = deriveESPHomeSetupStatus(
capabilities({
zwave_proxy: { supported: true, home_id: 0 },
serial_proxies: [
{ name: "UART", port_type: "TTL", url: "esphome-hass://proxy/uart" },
],
}),
deriveOptions({ mediaPlayerSupported: true })
);
expect(getESPHomeSetupCapabilityIds(status)).toEqual([
"audio",
"connectivity",
"serial",
]);
expect(getESPHomeSetupCapabilityIds(status).length).toBe(3);
});
it("does not treat Bluetooth-only setup as started", () => {
const status = deriveESPHomeSetupStatus(
capabilities({
bluetooth_proxy: { supported: true },
}),
deriveOptions()
);
expect(countRemainingESPHomeCapabilities(status)).toBe(0);
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(false);
});
it("reports everything set up when remaining is zero", () => {
const status = deriveESPHomeSetupStatus(
capabilities({
bluetooth_proxy: { supported: true },
}),
deriveOptions({
mediaPlayerSupported: true,
musicAssistantLoaded: true,
})
);
expect(countRemainingESPHomeCapabilities(status)).toBe(0);
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(true);
});
});
describe("hasZWaveJSEntryForDevice", () => {
it("detects a zwave_js entry on the ESPHome device itself", () => {
const devices = {
esphome: device("esphome", {
config_entries: ["esphome-entry", "zwave-entry"],
}),
};
const entries = [
entry({ entry_id: "esphome-entry", domain: "esphome" }),
entry({ entry_id: "zwave-entry", domain: "zwave_js" }),
];
expect(hasZWaveJSEntryForDevice("esphome", devices, entries)).toBe(true);
});
it("detects a child device via this ESPHome device", () => {
const devices = {
esphome: device("esphome", { config_entries: ["esphome-entry"] }),
controller: device("controller", {
config_entries: ["zwave-entry"],
via_device_id: "esphome",
}),
};
const entries = [
entry({ entry_id: "esphome-entry", domain: "esphome" }),
entry({ entry_id: "zwave-entry", domain: "zwave_js" }),
];
expect(hasZWaveJSEntryForDevice("esphome", devices, entries)).toBe(true);
});
it("ignores disabled zwave_js entries and unrelated devices", () => {
const devices = {
esphome: device("esphome", { config_entries: ["esphome-entry"] }),
other: device("other", {
config_entries: ["zwave-disabled"],
via_device_id: "someone-else",
}),
};
const entries = [
entry({ entry_id: "esphome-entry", domain: "esphome" }),
entry({
entry_id: "zwave-disabled",
domain: "zwave_js",
disabled_by: "user",
}),
];
expect(hasZWaveJSEntryForDevice("esphome", devices, entries)).toBe(false);
});
});
describe("Later persistence helpers", () => {
it("tracks deferred device ids per user data", () => {
expect(isESPHomeSetupDeferred(undefined, "dev-1")).toBe(false);
expect(isESPHomeSetupDeferred({ setupDeferred: ["dev-1"] }, "dev-1")).toBe(
true
);
const next = withDeferredESPHomeDevice(
{ setupDeferred: ["dev-1"] },
"dev-2"
);
expect(next.setupDeferred).toEqual(["dev-1", "dev-2"]);
expect(withDeferredESPHomeDevice(next, "dev-1").setupDeferred).toEqual([
"dev-1",
"dev-2",
]);
});
});
+138
View File
@@ -27,11 +27,37 @@ vi.mock("../../../src/components/ha-dialog-footer", () => {
return {};
});
const mockForm = vi.hoisted(() => ({
delayedTag: undefined as string | undefined,
}));
vi.mock("../../../src/components/ha-form/ha-form", () => {
if (!customElements.get("ha-selector")) {
customElements.define("ha-selector", class extends HTMLElement {});
}
customElements.define(
"ha-form",
class extends HTMLElement {
public reportValidity = vi.fn(() => true);
public connectedCallback(): void {
if (this.shadowRoot) {
return;
}
if (mockForm.delayedTag) {
const selector = document.createElement("ha-selector");
selector
.attachShadow({ mode: "open" })
.append(document.createElement(mockForm.delayedTag));
this.attachShadow({ mode: "open" }).append(selector);
return;
}
const selector = document.createElement("div");
selector
.attachShadow({ mode: "open" })
.append(document.createElement("input"));
this.attachShadow({ mode: "open" }).append(selector);
}
}
);
return {};
@@ -43,6 +69,31 @@ const getInternals = (dialog: DialogForm) =>
const getForms = (dialog: DialogForm): HTMLElement[] =>
Array.from(dialog.shadowRoot!.querySelectorAll("ha-form"));
const formControl = (form: HTMLElement): HTMLElement | null => {
const visit = (node: ParentNode): HTMLElement | null => {
if (node instanceof Element && node.shadowRoot) {
const inShadow = visit(node.shadowRoot);
if (inShadow) {
return inShadow;
}
}
for (const child of node.children) {
if (
child instanceof HTMLElement &&
child.matches("input, textarea, button")
) {
return child;
}
const found = visit(child);
if (found) {
return found;
}
}
return null;
};
return visit(form);
};
const outerParams = (data: FormDialogData = {}): FormDialogParams => ({
title: "Outer",
schema: [{ name: "value", selector: { text: {} } }],
@@ -96,6 +147,7 @@ const cancel = (dialog: DialogForm) =>
(getInternals(dialog)["_cancel"] as () => void)();
afterEach(() => {
mockForm.delayedTag = undefined;
document.body.replaceChildren();
vi.clearAllMocks();
});
@@ -130,6 +182,23 @@ describe("dialog-form mounted nested forms", () => {
expect(getForms(dialog)[0].hidden).toBe(false);
});
it("moves focus to the first nested form control when a level is pushed", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const opener = document.createElement("button");
parent.append(opener);
opener.focus();
expect(deepActiveElement()).toBe(opener);
await showNestedDialog(dialog, parent, nestedParams());
await vi.waitUntil(
() => deepActiveElement() === formControl(getForms(dialog)[1])
);
expect(getForms(dialog)[0].hidden).toBe(true);
});
it.each(["submit", "cancel"] as const)(
"restores focus to the opener after nested %s",
async (action) => {
@@ -140,6 +209,9 @@ describe("dialog-form mounted nested forms", () => {
opener.focus();
await showNestedDialog(dialog, parent, nestedParams());
await vi.waitUntil(
() => deepActiveElement() === formControl(getForms(dialog)[1])
);
const child = getForms(dialog)[1];
const childFocusTarget = document.createElement("button");
@@ -158,6 +230,72 @@ describe("dialog-form mounted nested forms", () => {
}
);
it("focuses the first parent form control when the opener is gone after nested submit", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const opener = document.createElement("button");
parent.append(opener);
opener.focus();
await showNestedDialog(dialog, parent, nestedParams());
await vi.waitUntil(
() => deepActiveElement() === formControl(getForms(dialog)[1])
);
opener.remove();
submit(dialog);
await vi.waitUntil(
() => deepActiveElement() === formControl(getForms(dialog)[0])
);
});
it("focuses a nested control after a cold selector chunk upgrades", async () => {
const tag = `ha-test-delayed-selector-${crypto.randomUUID()}`;
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const opener = document.createElement("button");
parent.append(opener);
opener.focus();
mockForm.delayedTag = tag;
await showNestedDialog(dialog, parent, nestedParams());
customElements.define(
tag,
class extends HTMLElement {
public connectedCallback(): void {
if (this.shadowRoot) {
return;
}
this.attachShadow({ mode: "open" }).append(
document.createElement("input")
);
}
}
);
await vi.waitUntil(
() => deepActiveElement() === formControl(getForms(dialog)[1])
);
});
it("does not steal focus after a nested level is immediately cancelled", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const opener = document.createElement("button");
parent.append(opener);
opener.focus();
await showNestedDialog(dialog, parent, nestedParams());
cancel(dialog);
await vi.waitUntil(() => deepActiveElement() === opener);
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
expect(deepActiveElement()).toBe(opener);
});
it("keeps the parent open after a custom object selector nested save", async () => {
const dialog = await openDialog();
const nested = {
+60 -60
View File
@@ -4697,22 +4697,22 @@ __metadata:
languageName: node
linkType: hard
"@rsdoctor/client@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/client@npm:1.6.2"
checksum: 10/7b57cf3a9c6e3dc1b64b0f07969330579fd0e84963843cb3517b7c1160bc9b2fa08e709eaf835139d8a7e8c32977af8aae050d100df80daed44371ced8272642
"@rsdoctor/client@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/client@npm:1.6.3"
checksum: 10/407d2509dc829e8f89aada7690b63c98a1d18ecffd3e23740be5f506a043cbc16d75e1f0a80f1299b60ca07d6151b7fae848568dd53b71e211dcefc163f55dd5
languageName: node
linkType: hard
"@rsdoctor/core@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/core@npm:1.6.2"
"@rsdoctor/core@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/core@npm:1.6.3"
dependencies:
"@rsbuild/plugin-check-syntax": "npm:^1.6.1"
"@rsdoctor/graph": "npm:1.6.2"
"@rsdoctor/sdk": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
"@rsdoctor/graph": "npm:1.6.3"
"@rsdoctor/sdk": "npm:1.6.3"
"@rsdoctor/types": "npm:1.6.3"
"@rsdoctor/utils": "npm:1.6.3"
"@rspack/resolver": "npm:^0.2.8"
browserslist-load-config: "npm:^1.0.2"
es-toolkit: "npm:^1.49.0"
@@ -4720,60 +4720,60 @@ __metadata:
fs-extra: "npm:^11.1.1"
semver: "npm:^7.8.5"
source-map: "npm:^0.7.6"
checksum: 10/803bb41042b2af07b096f2dde6383d516bb22b62705aeb9693959788e8a63df80e6d25514f1d92639a267e46e73732689abf5ba2c25679fbd80da9dd126f7ed9
checksum: 10/89da82b0cf60be5560c5f0b9ac2fd8695ca2d53b0bed698202ef12182b507c8021989e4a118f27a36d6791f1e0998e9d58e44f5f564cfe03b33e4b0288324780
languageName: node
linkType: hard
"@rsdoctor/graph@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/graph@npm:1.6.2"
"@rsdoctor/graph@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/graph@npm:1.6.3"
dependencies:
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.3"
"@rsdoctor/utils": "npm:1.6.3"
es-toolkit: "npm:^1.49.0"
path-browserify: "npm:1.0.1"
source-map: "npm:^0.7.6"
checksum: 10/f28aaedc529a6cff782f1813faa6d19d10f76ab8f529bbe2899385b5659d247f0543149e2fcd700264a00e9568605a85fde04cbf634e821f24e9bf239eb1b009
checksum: 10/b91aed0faed7771d74cd27015ee0e3c1bf81b8783d701b867328945f1533d90ac4590d0aa5da06b56126fa7caaaa0ea0dbbf66dcec2d64a1da98b436dab3f0dd
languageName: node
linkType: hard
"@rsdoctor/rspack-plugin@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/rspack-plugin@npm:1.6.2"
"@rsdoctor/rspack-plugin@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/rspack-plugin@npm:1.6.3"
dependencies:
"@rsdoctor/core": "npm:1.6.2"
"@rsdoctor/graph": "npm:1.6.2"
"@rsdoctor/sdk": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
"@rsdoctor/core": "npm:1.6.3"
"@rsdoctor/graph": "npm:1.6.3"
"@rsdoctor/sdk": "npm:1.6.3"
"@rsdoctor/types": "npm:1.6.3"
"@rsdoctor/utils": "npm:1.6.3"
peerDependencies:
"@rspack/core": "*"
peerDependenciesMeta:
"@rspack/core":
optional: true
checksum: 10/dec9f0424d14ec7ed03cd6cbc5e0b11cd74c29be12c3f79466bf8da0f317a4bdba858a58daf64e4a7c16d20a1d58433b1d2c038b62ff771c1d045bca3d71c3ad
checksum: 10/3b1cc8611ba2e2047a1ac8143aa8e5749e6a19fdb021dfa33ea06ad9a7ac78cb4a025baacce3a6d659107ee612d34c7bbabfeba15784003e0efa376f4dc00071
languageName: node
linkType: hard
"@rsdoctor/sdk@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/sdk@npm:1.6.2"
"@rsdoctor/sdk@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/sdk@npm:1.6.3"
dependencies:
"@rsdoctor/client": "npm:1.6.2"
"@rsdoctor/graph": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/utils": "npm:1.6.2"
"@rsdoctor/client": "npm:1.6.3"
"@rsdoctor/graph": "npm:1.6.3"
"@rsdoctor/types": "npm:1.6.3"
"@rsdoctor/utils": "npm:1.6.3"
launch-editor: "npm:^2.13.2"
safer-buffer: "npm:2.1.2"
socket.io: "npm:4.8.1"
tapable: "npm:2.3.3"
checksum: 10/89d5850145b490bbf3f43486176ebdd1f9eb67cdbe96a07549bb0b400ab76d85f3b6d5667be7cf8304434281f0db74fbaaab3360228eb1650f0a8ce640101208
checksum: 10/9363930b637869262c93913a0bc90e76c7662cebf869778300b6184e82fe41ca3af8b065c774327557b22f7e24a3ed517ff1db437f161297c11d03894e25c930
languageName: node
linkType: hard
"@rsdoctor/types@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/types@npm:1.6.2"
"@rsdoctor/types@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/types@npm:1.6.3"
dependencies:
"@types/connect": "npm:3.4.38"
"@types/estree": "npm:1.0.5"
@@ -4787,16 +4787,16 @@ __metadata:
optional: true
webpack:
optional: true
checksum: 10/39328739b31f7df46b663a9f6b5ac8c1e290c6b7ce0f68d9b45a423b89a1a296d32fbee0c26f555fc40d60b356a3605909f7bbe19c77d9c6bca3603d6928aef2
checksum: 10/b3ee6089b671ac0f2dc656f06a03ef475e982f44b34faa664bd7f9121a6ec7609d35cd814f093b7bf3326c8fce64998542fca0645fc4c0ad7e3c136c23e90a0d
languageName: node
linkType: hard
"@rsdoctor/utils@npm:1.6.2":
version: 1.6.2
resolution: "@rsdoctor/utils@npm:1.6.2"
"@rsdoctor/utils@npm:1.6.3":
version: 1.6.3
resolution: "@rsdoctor/utils@npm:1.6.3"
dependencies:
"@babel/code-frame": "npm:7.26.2"
"@rsdoctor/types": "npm:1.6.2"
"@rsdoctor/types": "npm:1.6.3"
"@types/estree": "npm:1.0.5"
acorn: "npm:^8.10.0"
acorn-import-attributes: "npm:^1.9.5"
@@ -4810,7 +4810,7 @@ __metadata:
picocolors: "npm:^1.1.1"
rslog: "npm:^2.1.2"
strip-ansi: "npm:^7.2.0"
checksum: 10/fb6a147e452756018e93789e07dbe015354fc2690699755ef411748a393c7db86fdff2614cdf9ce07822434abcac3eef362aea8c63cbca9fe3f96c958fde56dc
checksum: 10/06b18159d64809e9c764371a00e284d344c2912b17e6eebe71bd04fe6c6e5bc05bd4bd7dd5860351f3c9c434b96ff1acbaa7d0be02f0b44f6fed42ddb18748ae
languageName: node
linkType: hard
@@ -4996,9 +4996,9 @@ __metadata:
languageName: node
linkType: hard
"@rspack/dev-server@npm:2.2.0":
version: 2.2.0
resolution: "@rspack/dev-server@npm:2.2.0"
"@rspack/dev-server@npm:2.2.1":
version: 2.2.1
resolution: "@rspack/dev-server@npm:2.2.1"
dependencies:
"@rspack/dev-middleware": "npm:^2.0.3"
peerDependencies:
@@ -5007,7 +5007,7 @@ __metadata:
peerDependenciesMeta:
selfsigned:
optional: true
checksum: 10/edfc57223fad64c43c96be95b0b6650194ac2e02d929bef649d524faa9c618233afe8f970ac2e21c77e1b92f6c65dac20f06817e73a9bd661f43c73057ae6b6b
checksum: 10/4ae7a1a5e3ce1dc03c3fa43832fb01f9153a0674474445051917de6d3bf874a10377b4eb95ec9df33ad817a92110c2fa5eeb791d75bcf8130c1d57309f2a29db
languageName: node
linkType: hard
@@ -5735,10 +5735,10 @@ __metadata:
languageName: node
linkType: hard
"@types/luxon@npm:3.7.4":
version: 3.7.4
resolution: "@types/luxon@npm:3.7.4"
checksum: 10/f062cbf6cfac319b88abcedd253308c6dc98e0b76e1ab991fb9fb1573c602097f2d54a95ea874d3678f3963a73478bcce260c621deb6c779c83ff891611e46d7
"@types/luxon@npm:3.7.5":
version: 3.7.5
resolution: "@types/luxon@npm:3.7.5"
checksum: 10/656e6e5c057f5e9769d2f2c0d84795de3d68d461557dca432dbdf6b98d1e57b15f273d99781c1e8d786f1fd15dcc29eeb48496685aa77fc3f14e186b6230ab0e
languageName: node
linkType: hard
@@ -8970,9 +8970,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.8.1":
version: 10.8.1
resolution: "eslint@npm:10.8.1"
"eslint@npm:10.9.0":
version: 10.9.0
resolution: "eslint@npm:10.9.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -9011,7 +9011,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/7174710e740f95bfa97d7501419d717c03cf7760c3eea4a91cd10fb804538bbd8cdc0849b26afa789a56a89ee838b727e4b9329e347376f96b8e524727b90ba3
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
languageName: node
linkType: hard
@@ -10024,9 +10024,9 @@ __metadata:
"@octokit/rest": "npm:22.0.1"
"@playwright/test": "npm:1.62.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.2"
"@rsdoctor/rspack-plugin": "npm:1.6.3"
"@rspack/core": "npm:2.1.10"
"@rspack/dev-server": "npm:2.2.0"
"@rspack/dev-server": "npm:2.2.1"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
"@tsparticles/engine": "npm:4.3.2"
@@ -10041,7 +10041,7 @@ __metadata:
"@types/leaflet-draw": "npm:1.0.13"
"@types/leaflet.markercluster": "npm:1.5.6"
"@types/lodash.merge": "npm:4.6.9"
"@types/luxon": "npm:3.7.4"
"@types/luxon": "npm:3.7.5"
"@types/qrcode": "npm:1.5.6"
"@types/sortablejs": "npm:1.15.9"
"@types/tar": "npm:7.0.87"
@@ -10070,7 +10070,7 @@ __metadata:
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.8.1"
eslint: "npm:10.9.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"