Compare commits

..
Author SHA1 Message Date
Paul Bottein 692338459d Only add empty chart mask areas once a mask was shown 2026-11-12 18:03:50 +01:00
Paul Bottein a8ee519fcf Keep drag to zoom when the chart select mode is point 2026-11-12 18:03:50 +01:00
Paul Bottein fdbab9046c Make chart selection opt-in with select-mode and cache the mask color 2026-11-12 18:03:50 +01:00
Paul Bottein 9f19a66a51 Clear stale chart masks after a data update 2026-11-12 18:03:50 +01:00
Paul Bottein 7b5d02bdba Move chart selection and highlight into ha-chart-base 2026-11-12 18:03:50 +01:00
Paul Bottein 3a3228bf79 Unify chart selection with a fade mask and a clear button in the gallery 2026-11-12 18:03:50 +01:00
Paul Bottein a299f5f9a7 Add range selection examples to the statistics chart gallery page 2026-11-12 18:03:50 +01:00
Paul Bottein 27902ef586 Keep range selection on release outside the chart and simplify the highlight 2026-11-12 18:03:50 +01:00
Paul Bottein 2d0ab5349b Add range selection and highlight to charts 2026-11-12 18:03:50 +01:00
Paul Bottein 77170b447f Fix favorites centering in the more info dialog (#55884) 2026-11-12 17:26:25 +02:00
c0ffeeca7 e2bb744202 MCP: change AI agent to AI app (#55885) 2026-11-12 17:25:49 +02:00
Wendelin 0eec83448a Revert camera ICE server filter and WebRTC-first memory (#55881)
* Revert camera ICE server filter and WebRTC-first memory

Follow-up to #55861 after review feedback:

- Use the full ICE server list from core again. The slow starts come from
  go2rtc, which stalls while it sets up TURN relays with the cloud ICE
  servers, so filtering in the browser does not help.
- Start HLS and WebRTC together again, remove the per-camera WebRTC-first
  memory and its 12 second HLS delay.
- Retry a WebRTC stream at most once, only after it played or on a backend
  error. A new connection that gets no answer or finds no path fails
  directly, so HLS or MJPEG takes over.

* Ignore stream URL errors of a removed HLS player

Switching cameras while the old HLS player was still fetching its stream
URL could mark HLS as failed for the new camera.
2026-11-12 14:51:53 +01:00
WendelinandClaude Sonnet 5.5 1864f6f802 Move the media browser list item to its own file (#55877)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-11-12 13:26:28 +01:00
renovate[bot] 9a19cc3bf6 Update dependency vite to v8.3.7 (#55880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 13:24:58 +01:00
WendelinandClaude Fable 5.2 ec51fb4f8d Add attributes to wrap text in list rows (#55864)
The headline and supporting text of ha-row-item are truncated to one
line. Many pages overrode that with white-space rules on the headline
and supporting-text parts.

Add the wrap-headline and wrap-supporting-text attributes and use them
in place of those CSS overrides.

Co-authored-by: Claude Fable 5.2 <[email protected]>
2026-11-12 13:20:40 +02:00
Paul Bottein 6a1a9bfb1b Suggest area icons based on the area name (#55708)
* Suggest area icons from the room in the area name

* Fix stale icon list, closed dialog and AI icon override

* Keep an untouched area dialog clean after the icon prefill

* Keep the suggested section visible when a suggested icon is selected

* Move area icon keywords to the translations

* Move room keywords to a shared translation key

* Remove home cinema and gym from the room list

* Note the Lokalise draft effect of editing room keywords

* Ship room keyword drafts and group keywords under rooms.keywords

* Add more room types for area icon suggestions

* Fetch room keyword drafts through the Lokalise keys API
2026-11-12 10:51:12 +01:00
Maarten Lakerveld 16807d2765 Make "Don't show again" a checkbox on the browser warning (#55863)
As a second button it was easy to press by accident. The reminder now has a
checkbox, and "Continue anyway" applies it.
2026-11-12 09:48:17 +01:00
renovate[bot] 9caad65c91 Update dependency eslint-plugin-import-x to v4.18.1 (#55874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 08:16:21 +02:00
Bram KragtenandClaude Opus 5.5 af523f1087 Fix Matter add device dialog stuck on the spinner (#55869)
The dialog waited for the companion app's commission finish message on
every step, but only the app's own flow sends it. Steps that commission
through core or another app now advance as soon as the device appears.
A failed finish message after the device appeared also advances now.

The new device watcher unsubscribed twice after a find, which dropped
another subscriber from the device registry collection. After a few
adds the registry stopped updating and the dialog never saw the device.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-12 08:12:21 +02:00
renovate[bot] 349d5f7125 Update dependency intl-messageformat to v12.1.5 (#55872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 07:01:41 +01:00
67 changed files with 1533 additions and 1035 deletions

No files matched your search

+107 -42
View File
@@ -157,6 +157,99 @@ async function pollProcess(lokaliseApi, projectId, processId) {
}
/* eslint-enable no-await-in-loop */
const DRAFTS_ACCEPTED_KEYS = [
// Written per language rather than translated from English, so an English edit doesn't make them outdated
"ui.components.rooms.keywords",
];
async function exportTranslations(lokaliseApi, projectId) {
const exportProcess = await lokaliseApi.files().async_download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
});
const finishedProcess = await pollProcess(
lokaliseApi,
projectId,
exportProcess.process_id
);
const bundleUrl = finishedProcess.details.download_url;
console.log(`Downloading translations from: ${bundleUrl}`);
const response = await fetch(bundleUrl);
if (response.status !== 200 && response.status !== 0) {
throw new Error(response.statusText);
}
console.log(`Extracting translations...`);
const contents = await JSZip.loadAsync(await response.arrayBuffer());
const files = {};
await Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return;
}
files[filename.split("/").splice(-1)[0]] = await file.async("string");
})
);
return files;
}
async function fetchDrafts(lokaliseApi, projectId) {
const english = JSON.parse(
await fs.readFile("src/translations/en.json", { encoding })
);
const keyNames = DRAFTS_ACCEPTED_KEYS.flatMap((key) => {
const keys = key.split(".");
const drafts = keys.reduce((obj, part) => obj?.[part], english) ?? {};
return Object.keys(drafts).map((name) => [...keys, name].join("::"));
});
if (!keyNames.length) {
return [];
}
const { items } = await lokaliseApi.keys().list({
project_id: projectId,
filter_keys: keyNames.join(","),
include_translations: 1,
limit: keyNames.length,
});
return items.flatMap((key) =>
key.translations
.filter(({ translation }) => translation)
.map(({ language_iso, translation }) => ({
filename: `${language_iso}.json`,
path: key.key_name.web.split("::"),
translation,
}))
);
}
function acceptDrafts(files, drafts) {
const data = {};
drafts.forEach(({ filename, path: keyPath, translation }) => {
data[filename] ??= files[filename] ? JSON.parse(files[filename]) : {};
const parent = keyPath.slice(0, -1).reduce((obj, part) => {
obj[part] ??= {};
return obj[part];
}, data[filename]);
parent[keyPath.at(-1)] = translation;
});
Object.entries(data).forEach(([filename, content]) => {
files[filename] = JSON.stringify(content, null, 2);
});
}
gulp.task("fetch-lokalise", async function () {
let apiKey;
try {
@@ -193,52 +286,24 @@ gulp.task("fetch-lokalise", async function () {
await Promise.all(
projects.map(async ([project, projectId]) => {
try {
const exportProcess = await lokaliseApi
.files()
.async_download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
});
const finishedProcess = await pollProcess(
lokaliseApi,
projectId,
exportProcess.process_id
);
const bundleUrl = finishedProcess.details.download_url;
console.log(`Downloading translations from: ${bundleUrl}`);
const response = await fetch(bundleUrl);
if (response.status !== 200 && response.status !== 0) {
throw new Error(response.statusText);
const [files, drafts] = await Promise.all([
exportTranslations(lokaliseApi, projectId),
project === "frontend"
? fetchDrafts(lokaliseApi, projectId)
: undefined,
]);
if (drafts) {
acceptDrafts(files, drafts);
}
console.log(`Extracting translations...`);
const contents = await JSZip.loadAsync(await response.arrayBuffer());
await mkdirPromise;
await Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return;
}
const content = await file.async("nodebuffer");
await fs.writeFile(
path.join(inDir, project, filename.split("/").splice(-1)[0]),
content,
{ flag: "w", encoding }
);
})
Object.entries(files).map(([filename, content]) =>
fs.writeFile(path.join(inDir, project, filename), content, {
flag: "w",
encoding,
})
)
);
} catch (err) {
console.error(err);
+19
View File
@@ -0,0 +1,19 @@
import type { ReactiveElement } from "lit";
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
import { uiContext } from "../../../src/data/context";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// A demo that provides its own hass hides the gallery theme from its children,
// so pass the gallery light or dark mode on to it.
export const followGalleryTheme = (
host: ReactiveElement,
hass: MockHomeAssistant
) =>
new ContextSubscriptionController(host, uiContext, (ui) => {
if (ui) {
hass.updateHass({
themes: ui.themes,
selectedTheme: ui.selectedTheme,
});
}
});
+11 -5
View File
@@ -117,11 +117,17 @@ shared slots/attributes below.
**Attributes**
| Name | Type | Default | Description |
| ----------------- | ------- | ------- | --------------------------------------- |
| `headline` | String | — | Primary text. Overridden by the slot. |
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
| Name | Type | Default | Description |
| ---------------------- | ------- | ------- | -------------------------------------------------- |
| `headline` | String | — | Primary text. Overridden by the slot. |
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
| `wrap-headline` | Boolean | `false` | Wrap the headline instead of truncating it. |
| `wrap-supporting-text` | Boolean | `false` | Wrap the supporting text instead of truncating it. |
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
The headline and the supporting text stay on one line and are truncated with an
ellipsis by default. Set `wrap-headline` or `wrap-supporting-text` on rows whose
text has to stay readable in full, like settings with a description.
**CSS parts**
+14
View File
@@ -191,6 +191,20 @@ export class DemoHaList extends LitElement {
<ha-list-item-base headline="Disabled row" disabled>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base
headline="Truncated by default, a long headline stays on one line and ends with an ellipsis"
supporting-text="Long supporting text is truncated as well, so every row in the list keeps the same height"
>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base
wrap-headline
wrap-supporting-text
headline="With wrap-headline, a long headline wraps onto as many lines as it needs"
supporting-text="With wrap-supporting-text, long supporting text wraps too, which suits settings with a description"
>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
</ha-list-base>
</ha-card>
@@ -10,6 +10,7 @@ import type {
} from "../../../../src/data/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import { followGalleryTheme } from "../../data/follow-gallery-theme";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
@@ -166,6 +167,7 @@ export class DemoStateHistoryCharts extends LitElement {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
followGalleryTheme(this, hass);
hass.addEntities(ENTITIES);
}
@@ -1,7 +1,9 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import type {
Statistics,
@@ -11,7 +13,9 @@ import type {
} from "../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import type { HomeAssistant } from "../../../../src/types";
import { followGalleryTheme } from "../../data/follow-gallery-theme";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
@@ -108,6 +112,64 @@ interface Example {
height?: string;
}
interface TimeRange {
start: number;
end: number;
}
interface RangeExample {
id: string;
title: string;
description: string;
statistics: Statistics;
statTypes: StatisticType[];
chartType: "line" | "bar";
unit: string;
selectMode: ChartSelectMode;
hint: string;
}
const RANGE_EXAMPLES: RangeExample[] = [
{
id: "point",
title: "Point selection",
description:
"With select-mode set to point, a click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
selectMode: "point",
hint: "Click on a bar to select its hour.",
},
{
id: "range-bar",
title: "Range selection on bars",
description:
"With select-mode set to range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
selectMode: "range",
hint: "Click or drag on the chart to select one or more bars.",
},
{
id: "range",
title: "Range selection on lines",
description: "The same on a line chart.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
selectMode: "range",
hint: "Click or drag on the chart to select a period.",
},
];
const formatRange = (range: TimeRange) =>
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
const EXAMPLES: Example[] = [
{
title: "Sum as bars",
@@ -159,17 +221,20 @@ const EXAMPLES: Example[] = [
export class DemoStatisticsChart extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _selections: Record<string, TimeRange | undefined> = {};
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
followGalleryTheme(this, hass);
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
return html`${EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
@@ -191,7 +256,66 @@ export class DemoStatisticsChart extends LitElement {
</div>
</ha-card>
`
);
)}
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
}
private _renderRangeExample(example: RangeExample) {
const selection = this._selections[example.id];
return html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<statistics-chart
.statisticsData=${example.statistics}
.metadata=${METADATA}
.names=${NAMES}
.statTypes=${example.statTypes}
.chartType=${example.chartType}
period="hour"
.unit=${example.unit}
.startTime=${START}
.endTime=${END}
.highlight=${selection}
.clickForMoreInfo=${false}
data-example=${example.id}
.selectMode=${example.selectMode}
hide-legend
@chart-select=${this._rangeSelected}
></statistics-chart>
<div class="selection">
${
selection
? html`
<span>${formatRange(selection)}</span>
<ha-button
appearance="plain"
size="s"
data-example=${example.id}
@click=${this._clearSelection}
>
Clear
</ha-button>
`
: html`<span class="hint">${example.hint}</span>`
}
</div>
</div>
</ha-card>
`;
}
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
const id = (ev.currentTarget as HTMLElement).dataset.example!;
this._selections = {
...this._selections,
[id]: ev.detail,
};
}
private _clearSelection(ev: Event) {
const id = (ev.currentTarget as HTMLElement).dataset.example!;
this._selections = { ...this._selections, [id]: undefined };
}
static styles = css`
@@ -202,6 +326,20 @@ export class DemoStatisticsChart extends LitElement {
p {
margin-top: 0;
}
.selection {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
min-height: 48px;
margin-top: var(--ha-space-2);
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
border-radius: var(--ha-border-radius-lg);
background-color: var(--secondary-background-color);
}
.hint {
color: var(--secondary-text-color);
}
`;
}
+8 -8
View File
@@ -55,14 +55,14 @@
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "8.0.3",
"@formatjs/intl-displaynames": "8.0.3",
"@formatjs/intl-displaynames": "8.0.2",
"@formatjs/intl-durationformat": "0.12.4",
"@formatjs/intl-getcanonicallocales": "3.3.1",
"@formatjs/intl-listformat": "9.0.3",
"@formatjs/intl-listformat": "9.0.2",
"@formatjs/intl-locale": "5.4.3",
"@formatjs/intl-numberformat": "10.0.4",
"@formatjs/intl-pluralrules": "7.0.4",
"@formatjs/intl-relativetimeformat": "13.0.3",
"@formatjs/intl-numberformat": "10.0.3",
"@formatjs/intl-pluralrules": "7.0.3",
"@formatjs/intl-relativetimeformat": "13.0.2",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
"@lezer/highlight": "1.2.5",
@@ -102,7 +102,7 @@
"hls.js": "1.7.4",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"intl-messageformat": "12.1.5",
"js-yaml": "5.5.0",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
@@ -180,7 +180,7 @@
"eslint": "10.14.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.18.0",
"eslint-plugin-import-x": "4.18.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-unused-imports": "4.4.1",
@@ -215,7 +215,7 @@
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1",
"vite": "8.3.6",
"vite": "8.3.7",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.1.0",
"webpack-stats-plugin": "1.1.3",
+122 -122
View File
@@ -225,8 +225,8 @@ importers:
specifier: 8.0.3
version: 8.0.3
'@formatjs/intl-displaynames':
specifier: 8.0.3
version: 8.0.3
specifier: 8.0.2
version: 8.0.2
'@formatjs/intl-durationformat':
specifier: 0.12.4
version: 0.12.4
@@ -234,20 +234,20 @@ importers:
specifier: 3.3.1
version: 3.3.1
'@formatjs/intl-listformat':
specifier: 9.0.3
version: 9.0.3
specifier: 9.0.2
version: 9.0.2
'@formatjs/intl-locale':
specifier: 5.4.3
version: 5.4.3
'@formatjs/intl-numberformat':
specifier: 10.0.4
version: 10.0.4
specifier: 10.0.3
version: 10.0.3
'@formatjs/intl-pluralrules':
specifier: 7.0.4
version: 7.0.4
specifier: 7.0.3
version: 7.0.3
'@formatjs/intl-relativetimeformat':
specifier: 13.0.3
version: 13.0.3
specifier: 13.0.2
version: 13.0.2
'@home-assistant/webawesome':
specifier: 3.14.0-ha.2
version: 3.14.0-ha.2(@floating-ui/[email protected])(@types/[email protected])
@@ -366,8 +366,8 @@ importers:
specifier: 6.3.0
version: 6.3.0
intl-messageformat:
specifier: 12.1.4
version: 12.1.4
specifier: 12.1.5
version: 12.1.5
js-yaml:
specifier: 5.5.0
version: 5.5.0
@@ -595,8 +595,8 @@ importers:
specifier: 0.13.11
version: 0.13.11([email protected])
eslint-plugin-import-x:
specifier: 4.18.0
version: 4.18.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])
specifier: 4.18.1
version: 4.18.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])
eslint-plugin-lit:
specifier: 2.3.1
version: 2.3.1([email protected]([email protected]))
@@ -700,14 +700,14 @@ importers:
specifier: 8.72.1
version: 8.72.1([email protected]([email protected]))([email protected])([email protected])
vite:
specifier: 8.3.6
version: 8.3.6(@types/[email protected])([email protected])([email protected])
specifier: 8.3.7
version: 8.3.7(@types/[email protected])([email protected])([email protected])
vite-tsconfig-paths:
specifier: 6.1.1
version: 6.1.1([email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected]))
version: 6.1.1([email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected]))
vitest:
specifier: 5.1.0
version: 5.1.0(@types/[email protected])(@vitest/[email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected]))
version: 5.1.0(@types/[email protected])(@vitest/[email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected]))
webpack-stats-plugin:
specifier: 1.1.3
version: 1.1.3
@@ -2021,20 +2021,20 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==}
'@formatjs/[email protected]2':
resolution: {integrity: sha512-YuwN+dDKgssfuYwtOyGQD0CSn7K46g9cv1Fp0VgKpjZDDFvv8b8crzP990IcJhRPWHhKzCGsbUSJVIguyyFA5A==}
'@formatjs/[email protected]3':
resolution: {integrity: sha512-iB1fYfhtSGd8uH9A8b1m01Ujcv6QtQnJasefP3jPApmz72L/zNipzf3BdoinTMd1xuviVChjUJWL60iTwjUQ+w==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==}
'@formatjs/[email protected]2':
resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==}
'@formatjs/[email protected]3':
resolution: {integrity: sha512-IdAJ9NrHn7AF0zqncD9UpxQozTYyJtbwwrZnJMEKVxTcOOXtVkUb2XYTpNOGTAv58NCsk0rjRB9G+i2SvBmGAQ==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-LQed+WhJBlceMJ4Zbe7dDe7Wn15le9t5HXfOFGdJYM/MGDyn56LTHlSzPzFjsJA0rvyp4FjN28jRQiMwtsji6A==}
'@formatjs/[email protected].3':
resolution: {integrity: sha512-UhKG1OwrbQzZNKIcmZu+/JR5SOxE25RWmUzxaNfQ04AJ4OwxNkTvAbVX0ojnkm0i8Qs4E8ElIoBdA6F8XTkJXw==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-9gayLdxgPhzw2UT2nbWBhrRN+7T2xy/cvvrEVFClRLzg1DSJJdXtdYRcbbQ8cdCRlC1O3dQag+bUeW+Kt9bRWA==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-EjUHL8isAQOqXuLxGllgsfIQdLrLNo8iCOQHMqUNY4Fvl52hw6TEP0n9tPnOmQWb5wTUa92NeqyGmwhjQ2lvHA==}
@@ -2042,8 +2042,8 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-GkXdVwb/4Y9qJaZ19BguPv2i9Vr+z5MrlmFa/9HR7WK2tMcCWZathuGhn7W/V3sFqUdAVB3uvmGuOxXwc61DJQ==}
'@formatjs/[email protected].3':
resolution: {integrity: sha512-XamK+7ie16gbBp51AH+K97NfwCoECk36jEbBO9m7HaInoLbG1F3P8M22enKtkifsi/cH9gjp4igFaYtY23h0uA==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-Qfsm6eyPyD6y7AgRyiOTJVaq0IBClDXQCm0/6vT3CRiwn89npdoUv3kTuwDykGDs1igLoTPWLBdMR81HIP4qQg==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-YdxSbfDifRIew7mukcwITPlhkomfHCcSzzbjEJqOGbod4wrfdUN7SmiZQU2Lr8zs/q/15ntZsthJ8NsW3P30mA==}
@@ -2054,14 +2054,14 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
'@formatjs/[email protected].4':
resolution: {integrity: sha512-rQzbAgJcG/RPMR4xbFxMA33HnvXJtStYR0gGY1vcb/6JGmhZcauGE7g5Vch+EOtZkiitloSkHgU5JXmQpTTggg==}
'@formatjs/[email protected].3':
resolution: {integrity: sha512-Hi/3S+Iru5RIYNkiTJwqCmERsNwNacu3k+anfCcp7Nzn9hxhCpAuhjr6I9Y6CVUs/1D3fGSgfnJTXGukot5aBg==}
'@formatjs/[email protected].4':
resolution: {integrity: sha512-EeQ8YUs1QOpYLUiNHkHfR1rsOMtQUBQXEQwR9WmJRWfzvVXeYZ7vBeMARtlb2KuHUgdmfXjoin5oNn68hjyrEQ==}
'@formatjs/[email protected].3':
resolution: {integrity: sha512-moK0Ee0ddD8d8tZnoZ1JDUdNLBURXT5NMG4cnvYQY2bTYLphPhZk2KxgD78y6+lL69J4ihpQJleI+K5KlE39gw==}
'@formatjs/[email protected].3':
resolution: {integrity: sha512-5nyIp4F/59A5hcfU6lZphSslpildVSirqudWfygBENGdGSHmggZVjhoad458XWEgtUd6d0JoiwsiUBFLfbqJ9Q==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-g7AVCP/0r49+h4R1qnQcNpq78JoEIthwp+QtHv9VkHw1c0bEegmhuz1t03DEr2u5nqP4HE//yAFjQtrv66b67A==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-+9TU+8tqsUqobFzQJEvfQ8Yukt1PYgL5WxYi/yW+hWVNx422wEo/TlVRLvIfdYqlVS048jfyu8piNagfhOV+2A==}
@@ -2694,98 +2694,98 @@ packages:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@rolldown/[email protected]6':
resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-ubTG+4KlidvioxjOFdsdEa2F6JVGZb8m2ByJUqQ7fYzUeT1cEn194POcsQ10PKH4p//q0YQn4SB3YpSuzcA1Yg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-2Xa1t/JeFGTZn5TbzgPppcCftF8sCVnTNvrIvoSdcZhoFzAuVy1L3xxQUQ4sfWDtwVzg1u6Yi1R3SlJugRNV2Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-iUzw9HshVvrWci9gVXjxWS7/M/IcLvNo67vu2c/w9zKBz4hIvPRixdUpIW/iCfhldLaq9ER2hecsI9UY5z0/yA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-XuDHFzbFEtZl0QGJi92+fmG67tfnp1iZ9VOeDkwasGyTq2VG3/5PnJnvAr+MTzFh/rNScJ3VhN+XpZyyGsN7Jw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-MM8opLfsLK15oEXDz6iBc0t3ahu+ew9jsAmha+ovZ33hSwYgqo7YmpRITqFTfW7xA71BLEQ3jJdTsaCmhB1c9w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-J6EoCWiy5RnFQzHrKyHXXevZ9lukQfGpnchX/zG/k/WC2nc9O9XyS1JgA8kI9VxMFggMbCPjpv7LcikriXfehw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-HIkFuLF7CdDeki+1aCZLLbLq0hk/PHntb+z4/YCTo47JbksTD6jJEELIz92tYROmDmvMrNqUMqBi71BkJLDR+A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-4oqWItLLYt7IceOnElCct8T2ek5p7PmD7LN04TRJRXGo5f3HtKTgGH6PhYRFK4C2ICkq/LMUU0BVPvvlAKhm8g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-+AkRly5SrCEgnPhGRfub7pucASYEC2fTsxZg7VCcnozpA2+Wn/gCYy1t7LuJS67/xyJu/7noYBJncDUkbwgw2A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-Ttg1r9vVykMnQrE/GwYO9icKtXiuiMfRe/w74xJkeoVuvhRG+WtT6LEMOf58Yl63yd77lhHzO8Y55R8CC3XALA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-xX1Tt4o5jUMma0vJsXp5eRZA9nOuyigb2dKJMMzZRmzMxLYGE9PaKKSqHPzPvSQgM8ZOEacSTQwDQcwWfh0xmQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-4VVlGI5K2kociZlW3lSN8VjG3TW5yQIlAcfCdBXMgGjJxZll15Fa4ocRHXHvIVVnwlibCw/NQajJrNDiqqpiiw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-PApTsgH1jLYuyy3tSCz9De82Gq5qXwi7vImVZhEzTgX1C4150Cp5M2ZfL3pLvbCvbEHL0oJeC6pd6podtv8Y5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-J2XjWjx/SP+4x0H4r6dz3Vzb5WVkg7re40Do9K7HJ3sK2/xcmZrWwakXlSwaMvOkvruLJCD4CL/3wqsIR/thgg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/[email protected]6':
resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==}
'@rolldown/[email protected]8':
resolution: {integrity: sha512-XwRJxRQvkuEtS0hIIt29QrqPJ+JzMJSNYHJw4ae/mc6uEarfRG1ceyg4HmmWiBdL/YCb6g7N5BLbSvmRJRYZHw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -4994,8 +4994,8 @@ packages:
eslint-plugin-import: '>=1.4.0'
webpack: '>=1.11.0'
[email protected].0:
resolution: {integrity: sha512-UfxHFmV+f9vwybrNn6LQ1rPzfeDMPGi/dRbqQnCFO/jBzYrujSOyX7jxqBSYr/HwXs/V4XJdEGPvC3Re46nzKg==}
[email protected].1:
resolution: {integrity: sha512-D1h++38NS2Z40zf8jbd2OHqDrnIFRKpdp8sL0jP4xSDGZj6MNwEQua8F+r3tOWjcHrsWuhHOJ7wASWioJ31tfg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/utils': ^8.56.0
@@ -5602,8 +5602,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
[email protected].4:
resolution: {integrity: sha512-JUkmv3cAEyijN3oiRiV3ctel7yVMuUCgfh7Pu8pj1/z6L1DSEPRyhuMaLjVw3pGmGJ5RDH54WVFxfMHZWTwyHw==}
[email protected].5:
resolution: {integrity: sha512-quhcDFAxy4wr/GrgWgmMjuegoedNUIxGNLORfXgDgdjuv8cBM88ClLTLXmfYC1roMSR1PPc55yUEkpJ5m67W2g==}
[email protected]:
resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==}
@@ -6945,8 +6945,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-OlwfYEgA2RdboZohpldlvJ1xngOins5d7ejqnIBWr9KaMxsnBqotpptRXTyfNRLnFpqzX6sTDt+X+a+6udnU8g==}
[email protected]6:
resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==}
[email protected]8:
resolution: {integrity: sha512-C1uuT3UR60SShuCaPQFAAZyefJCWZlAKB02BV3BuPE2Rs4fevRQltEKoFO2tV5W9/eJhU5RieV6vJj3euulfNQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -7754,8 +7754,8 @@ packages:
peerDependencies:
vite: '*'
[email protected].6:
resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==}
[email protected].7:
resolution: {integrity: sha512-oxh0dMxrayBr33ScJvvBJ2HBQ2jc1TBrXKR2NyL/MJ9WPuX19mN2DXWUmywKdyw5RFVQBo8hmtb2FMC0LHrnng==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -9781,23 +9781,23 @@ snapshots:
'@formatjs/icu-skeleton-parser': 1.8.14
tslib: 2.9.1
'@formatjs/[email protected]2':
'@formatjs/[email protected]3':
dependencies:
'@formatjs/icu-skeleton-parser': 2.1.12
'@formatjs/icu-skeleton-parser': 2.1.13
'@formatjs/[email protected]':
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
tslib: 2.9.1
'@formatjs/[email protected]2': {}
'@formatjs/[email protected]3': {}
'@formatjs/[email protected]':
dependencies:
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected].3':
'@formatjs/[email protected].2':
dependencies:
'@formatjs/intl-localematcher': 0.10.1
@@ -9808,7 +9808,7 @@ snapshots:
'@formatjs/[email protected]': {}
'@formatjs/[email protected].3':
'@formatjs/[email protected].2':
dependencies:
'@formatjs/intl-localematcher': 0.10.1
@@ -9825,17 +9825,17 @@ snapshots:
dependencies:
tslib: 2.9.1
'@formatjs/[email protected].4':
'@formatjs/[email protected].3':
dependencies:
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected].4':
'@formatjs/[email protected].3':
dependencies:
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected].3':
'@formatjs/[email protected].2':
dependencies:
'@formatjs/intl-localematcher': 0.10.1
@@ -10658,49 +10658,49 @@ snapshots:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.15
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]6':
'@rolldown/[email protected]8':
optional: true
'@rolldown/[email protected]': {}
@@ -11697,7 +11697,7 @@ snapshots:
obug: 3.0.0
std-env: 4.3.0
tinyrainbow: 3.2.0
vitest: 5.1.0(@types/[email protected])(@vitest/[email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected]))
vitest: 5.1.0(@types/[email protected])(@vitest/[email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected]))
'@vitest/[email protected]': {}
@@ -11705,14 +11705,14 @@ snapshots:
dependencies:
'@vitest/istanbul-lib-coverage': 2.1.0
'@vitest/[email protected]([email protected].6(@types/[email protected])([email protected])([email protected]))':
'@vitest/[email protected]([email protected].7(@types/[email protected])([email protected])([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.1.0
estree-walker: 3.0.3
magic-string: 1.4.3
optionalDependencies:
vite: 8.3.6(@types/[email protected])([email protected])([email protected])
vite: 8.3.7(@types/[email protected])([email protected])([email protected])
'@vitest/[email protected]': {}
@@ -12823,7 +12823,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected].0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected]):
[email protected].1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected]):
dependencies:
'@typescript-eslint/types': 8.72.1
cjs-module-lexer: 2.3.0
@@ -13543,10 +13543,10 @@ snapshots:
'@formatjs/icu-messageformat-parser': 2.11.2
tslib: 2.9.1
[email protected].4:
[email protected].5:
dependencies:
'@formatjs/fast-memoize': 3.1.7
'@formatjs/icu-messageformat-parser': 3.5.22
'@formatjs/icu-messageformat-parser': 3.5.23
[email protected]: {}
@@ -14942,26 +14942,26 @@ snapshots:
[email protected]: {}
[email protected]6:
[email protected]8:
dependencies:
'@oxc-project/types': 0.156.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.16
'@rolldown/binding-android-arm64': 1.2.16
'@rolldown/binding-darwin-arm64': 1.2.16
'@rolldown/binding-darwin-x64': 1.2.16
'@rolldown/binding-freebsd-x64': 1.2.16
'@rolldown/binding-linux-arm-gnueabihf': 1.2.16
'@rolldown/binding-linux-arm64-gnu': 1.2.16
'@rolldown/binding-linux-arm64-musl': 1.2.16
'@rolldown/binding-linux-ppc64-gnu': 1.2.16
'@rolldown/binding-linux-s390x-gnu': 1.2.16
'@rolldown/binding-linux-x64-gnu': 1.2.16
'@rolldown/binding-linux-x64-musl': 1.2.16
'@rolldown/binding-openharmony-arm64': 1.2.16
'@rolldown/binding-win32-arm64-msvc': 1.2.16
'@rolldown/binding-win32-x64-msvc': 1.2.16
'@rolldown/binding-android-arm-eabi': 1.2.18
'@rolldown/binding-android-arm64': 1.2.18
'@rolldown/binding-darwin-arm64': 1.2.18
'@rolldown/binding-darwin-x64': 1.2.18
'@rolldown/binding-freebsd-x64': 1.2.18
'@rolldown/binding-linux-arm-gnueabihf': 1.2.18
'@rolldown/binding-linux-arm64-gnu': 1.2.18
'@rolldown/binding-linux-arm64-musl': 1.2.18
'@rolldown/binding-linux-ppc64-gnu': 1.2.18
'@rolldown/binding-linux-s390x-gnu': 1.2.18
'@rolldown/binding-linux-x64-gnu': 1.2.18
'@rolldown/binding-linux-x64-musl': 1.2.18
'@rolldown/binding-openharmony-arm64': 1.2.18
'@rolldown/binding-win32-arm64-msvc': 1.2.18
'@rolldown/binding-win32-x64-msvc': 1.2.18
[email protected]:
dependencies:
@@ -15909,22 +15909,22 @@ snapshots:
- bare-abort-controller
- react-native-b4a
[email protected]([email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected])):
[email protected]([email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected])):
dependencies:
debug: 4.4.3([email protected])
globrex: 0.1.2
tsconfck: 3.1.6([email protected])
vite: 8.3.6(@types/[email protected])([email protected])([email protected])
vite: 8.3.7(@types/[email protected])([email protected])([email protected])
transitivePeerDependencies:
- supports-color
- typescript
[email protected].6(@types/[email protected])([email protected])([email protected]):
[email protected].7(@types/[email protected])([email protected])([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.32
rolldown: 1.2.16
rolldown: 1.2.18
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 24.19.2
@@ -15932,10 +15932,10 @@ snapshots:
terser: 5.50.0
yaml: 2.9.1
[email protected](@types/[email protected])(@vitest/[email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected])):
[email protected](@types/[email protected])(@vitest/[email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.1.0([email protected].6(@types/[email protected])([email protected])([email protected]))
'@vitest/mocker': 5.1.0([email protected].7(@types/[email protected])([email protected])([email protected]))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -15946,7 +15946,7 @@ snapshots:
tinybench: 6.2.2
tinyexec: 1.3.1
tinyglobby: 0.2.17
vite: 8.3.6(@types/[email protected])([email protected])([email protected])
vite: 8.3.7(@types/[email protected])([email protected])([email protected])
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 24.19.2
-54
View File
@@ -1,54 +0,0 @@
import type { IcePath } from "./limit-ice-servers";
// The fields of the transport, candidate pair and local candidate stats
interface IceStats {
id: string;
type: string;
selectedCandidatePairId?: string;
selected?: boolean;
localCandidateId?: string;
candidateType?: string;
relayProtocol?: string;
url?: string;
}
/**
* Reads the network path a connected peer connection uses. Returns
* `undefined` if the browser doesn't report it.
*/
export const getIcePath = async (
peerConnection: RTCPeerConnection
): Promise<IcePath | undefined> => {
const stats: Record<string, IceStats> = {};
try {
(await peerConnection.getStats()).forEach((report: IceStats) => {
stats[report.id] = report;
});
} catch (_err: unknown) {
return undefined;
}
const reports = Object.values(stats);
const pairId = reports.find(
(report) => report.type === "transport" && report.selectedCandidatePairId
)?.selectedCandidatePairId;
const pair = pairId
? stats[pairId]
: // Firefox has no transport stats
reports.find(
(report) => report.type === "candidate-pair" && report.selected
);
const candidate = pair?.localCandidateId
? stats[pair.localCandidateId]
: undefined;
if (!candidate?.candidateType) {
return undefined;
}
if (candidate.url?.startsWith("turn")) {
return { relayUrl: candidate.url };
}
if (candidate.candidateType === "relay" || candidate.relayProtocol) {
// Not all browsers report which TURN URL a relayed candidate came from
return undefined;
}
return {};
};
-103
View File
@@ -1,103 +0,0 @@
import { ensureArray } from "../array/ensure-array";
const isTurn = (url: string) => url.startsWith("turn");
const isTcpTurn = (url: string) =>
url.startsWith("turns:") || url.includes("transport=tcp");
const isTls443 = (url: string) =>
url.startsWith("turns:") && /:443(\?|$)/.test(url);
// "turn:host:3478?transport=udp" -> "host"
const getHost = (url: string) =>
url
.replace(/^[a-z]+:/i, "")
.replace(/\?.*$/, "")
.replace(/:\d+$/, "")
.toLowerCase();
/**
* Reduces the URLs of every ICE server to one STUN URL, one UDP TURN URL and
* one TCP TURN URL (preferring TLS on port 443, which passes most firewalls)
* per host.
*
* The browser gathers a candidate for every URL on every network interface and
* checks each of them against every remote candidate. Redundant URLs of the
* same host do not add a new network path, but multiply the number of checks
* and delay the connection by many seconds.
*/
export const limitIceServers = (
iceServers: RTCIceServer[] | undefined
): RTCIceServer[] | undefined =>
iceServers?.map((server) => {
const urls = ensureArray(server.urls);
if (urls.length < 2) {
return server;
}
const hosts = [...new Set(urls.map(getHost))];
return {
...server,
urls: hosts.flatMap((host) => {
const hostUrls = urls.filter((url) => getHost(url) === host);
const turnUrls = hostUrls.filter(isTurn);
const tcpTurnUrls = turnUrls.filter(isTcpTurn);
return [
hostUrls.find((url) => !isTurn(url)),
turnUrls.find((url) => !isTcpTurn(url)),
tcpTurnUrls.find(isTls443) ??
tcpTurnUrls.find((url) => url.startsWith("turns:")) ??
tcpTurnUrls[0],
].filter((url) => url !== undefined);
}),
};
});
// "turns:host" -> "turns:host:5349?transport=tcp", browsers report the URL of
// a candidate with the port and transport that were left out
const normalizeUrl = (url: string) => {
const scheme = url.slice(0, url.indexOf(":")).toLowerCase();
const port =
/:(\d+)(\?|$)/.exec(url)?.[1] ?? (scheme.endsWith("s") ? "5349" : "3478");
const transport =
/[?&]transport=(\w+)/i.exec(url)?.[1].toLowerCase() ??
(scheme === "turns" ? "tcp" : "udp");
return `${scheme}:${getHost(url)}:${port}?transport=${transport}`;
};
/** The network path a connection uses, without `relayUrl` it is direct. */
export interface IcePath {
relayUrl?: string;
}
/**
* Reduces the ICE servers to what a new connection needs to take the same
* path as a connection that is already running: only the TURN URL it relays
* over, or no TCP TURN URLs when it is direct. The UDP TURN URLs stay, as
* browsers also use them to find their public address.
*
* Returns `undefined` if the relay is not part of the ICE servers.
*/
export const limitIceServersToPath = (
iceServers: RTCIceServer[] | undefined,
path: IcePath
): RTCIceServer[] | undefined => {
const { relayUrl } = path;
if (relayUrl) {
const normalizedUrl = normalizeUrl(relayUrl);
for (const server of iceServers ?? []) {
const url = ensureArray(server.urls).find(
(serverUrl) => normalizeUrl(serverUrl) === normalizedUrl
);
if (url) {
return [{ ...server, urls: [url] }];
}
}
return undefined;
}
return limitIceServers(iceServers)
?.map((server) => ({
...server,
urls: ensureArray(server.urls).filter((url) => !isTcpTurn(url)),
}))
.filter((server) => server.urls.length);
};
+189 -4
View File
@@ -7,7 +7,10 @@ import {
mdiRestart,
} from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components";
import type {
DataZoomComponentOption,
MarkAreaComponentOption,
} from "echarts/components";
import type { EChartsType } from "echarts/core";
import type {
ECElementEvent,
@@ -90,14 +93,29 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
const CLICK_DRAG_TOLERANCE = 4;
// The selection is shown by the mask, not by the brush
const BRUSH_STYLE = {
color: "rgba(0, 0, 0, 0)",
borderColor: "rgba(0, 0, 0, 0)",
};
export type ZoomSliderMode = "auto" | "always" | "never";
export type ChartSelectMode = "none" | "point" | "range";
const DEFERRED_PROPS = [
"options",
"data",
"_hiddenDatasets",
"_isZoomed",
"highlight",
] as const;
const MASK_OPACITY = 0.7;
export interface ChartTimeRange {
start: number;
end: number;
}
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -176,6 +194,18 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
public growForZoomSlider = false;
// "point" selects on click, "range" also selects on drag instead of zooming
@property({ attribute: "select-mode" })
public selectMode: ChartSelectMode = "none";
// Everything outside this time range fades
@property({ attribute: false }) public highlight?: ChartTimeRange;
// Rounds a selection to the periods of the data, or drops it
@property({ attribute: false }) public snapSelection?: (
range: ChartTimeRange
) => ChartTimeRange | undefined;
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@@ -201,6 +231,14 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@state() private _isZoomed = false;
private _pointerDownX?: number;
private _selectingRange?: ChartTimeRange;
private _maskColor = "";
private _maskShown = false;
// Separate from _isZoomed, whose touch handling reruns graph layouts
@state() private _isGraphRoamed = false;
@@ -454,6 +492,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this.chart) {
return;
}
if (changedProps.has("selectMode")) {
this._updateSelectMode();
}
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
return;
@@ -499,6 +540,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
) {
this._disposeSonification();
}
} else if (changedProps.has("highlight")) {
this._updateMask();
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -888,6 +931,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const style = getComputedStyle(this);
echarts.registerTheme("custom", this._createTheme(style));
this._maskColor =
style.getPropertyValue("--card-background-color").trim() ||
style.getPropertyValue("--primary-background-color").trim();
this.chart = echarts.init(this._chartContainer!, "custom");
this._dragToZoom = false;
@@ -970,6 +1016,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
this._updateDragToZoom();
if (this.selectMode !== "none") {
this._updateSelectMode();
}
if (zoomStart !== 0 || zoomEnd !== 100) {
this.zoom(zoomStart, zoomEnd, true);
}
@@ -1021,7 +1070,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// A chart can switch type (e.g. pie and bar) without being rebuilt
private _updateDragToZoom() {
const active = !this._touchInput && this._hasZoomableXAxis();
const active =
!this._touchInput &&
this._hasZoomableXAxis() &&
this.selectMode !== "range";
if (!this.chart || active === this._dragToZoom) {
return;
}
@@ -1033,6 +1085,127 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
});
}
private _snapRange(range: ChartTimeRange) {
return this.snapSelection ? this.snapSelection(range) : range;
}
private _getMaskArea(): MarkAreaComponentOption | undefined {
const range = this._selectingRange ?? this.highlight;
if (!range) {
return undefined;
}
this._maskShown = true;
return {
silent: true,
z: 10,
itemStyle: {
color: this._maskColor,
opacity: MASK_OPACITY,
},
data: [
[{ xAxis: -Infinity }, { xAxis: range.start }],
[{ xAxis: range.end }, { xAxis: Infinity }],
],
};
}
// Only the first series carries the mask, merged in without resending data
private _updateMask() {
this.chart?.setOption({
series: [{ markArea: this._getMaskArea() ?? { data: [] } }],
});
}
private _handleSelectPointerDown = (e: ECElementEvent) => {
this._pointerDownX = e.offsetX;
};
private _handleSelectClick = (e: ECElementEvent) => {
const point = [e.offsetX, e.offsetY];
// A drag that ends on the plot is not a click
const dragged =
this._pointerDownX !== undefined &&
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
if (dragged || !this.chart?.containPixel("grid", point)) {
return;
}
const [x] = this.chart.convertFromPixel("grid", point) as number[];
const selection = this._snapRange({ start: x, end: x });
if (selection) {
fireEvent(this, "chart-select", selection);
}
};
private _handleBrush = (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
if (range) {
this._selectingRange = this._snapRange({
start: range[0],
end: range[1],
});
this._updateMask();
}
};
private _handleBrushEnd = (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
this.chart?.dispatchAction({ type: "brush", areas: [] });
this._selectingRange = undefined;
this._updateMask();
const selection =
range && this._snapRange({ start: range[0], end: range[1] });
if (selection) {
fireEvent(this, "chart-select", selection);
}
};
private _updateSelectMode() {
if (!this.chart) {
return;
}
const zr = this.chart.getZr();
zr.off("mousedown", this._handleSelectPointerDown);
zr.off("click", this._handleSelectClick);
this.chart.off("brush", this._handleBrush);
this.chart.off("brushEnd", this._handleBrushEnd);
if (this.selectMode !== "none") {
zr.on("mousedown", this._handleSelectPointerDown);
zr.on("click", this._handleSelectClick);
}
const selectRange = this.selectMode === "range";
if (selectRange) {
this.chart.on("brush", this._handleBrush);
this.chart.on("brushEnd", this._handleBrushEnd);
this.chart.setOption({
brush: {
xAxisIndex: 0,
// Only measures the range, the mask fades what is outside
seriesIndex: [],
transformable: false,
brushStyle: BRUSH_STYLE,
},
});
}
const brushCursor = {
type: "takeGlobalCursor",
key: "brush",
brushOption: {
brushType: selectRange ? "lineX" : false,
brushMode: "single",
brushStyle: BRUSH_STYLE,
},
};
// Brush and drag to zoom share the global cursor, the last one taken wins
if (selectRange) {
this._updateDragToZoom();
this.chart.dispatchAction(brushCursor);
} else {
this.chart.dispatchAction(brushCursor);
this._dragToZoom = false;
this._updateDragToZoom();
}
}
private _hasZoomableXAxis() {
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
XAXisOption | undefined;
@@ -1475,7 +1648,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _getSeries() {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
XAXisOption | undefined;
const series = ensureArray(this.data).map((s) => {
const maskArea = this._getMaskArea();
const series = ensureArray(this.data).map((s, index) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
@@ -1513,6 +1687,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
} as HaECSeriesItem;
}
}
// ECharts merges series by id and would keep a mask left out here
if (this._maskShown && (s.type === "line" || s.type === "bar")) {
result = {
...result,
markArea:
index === 0 && maskArea
? maskArea
: ((s as LineSeriesOption).markArea ?? { data: [] }),
} as HaECSeriesItem;
}
return processSeriesTooltipFormatter(result);
});
return series as ECOption["series"];
@@ -1762,7 +1946,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// it, so a quick drag out would lose the release and its selection
if (
ev.type === "pointerdown" &&
this._dragToZoom &&
(this._dragToZoom || this.selectMode === "range") &&
this._isZoomPress(ev)
) {
this.chart
@@ -2337,6 +2521,7 @@ declare global {
"dataset-hidden": { id: string };
"dataset-unhidden": { id: string };
"chart-click": ECElementEvent;
"chart-select": ChartTimeRange;
"legend-label-click": { id: string };
"chart-zoom": {
start: number;
+50 -2
View File
@@ -5,7 +5,7 @@ import type {
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
@@ -49,7 +49,13 @@ import type {
HomeAssistantUI,
} from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import type {
ChartSelectMode,
ChartTimeRange,
CustomLegendOption,
HaChartBase,
ZoomSliderMode,
} from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
@@ -128,6 +134,16 @@ export class StatisticsChart extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "select-mode" })
public selectMode: ChartSelectMode = "none";
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@property({ attribute: false }) public highlight?: ChartTimeRange;
@query("ha-chart-base") private _chartBase?: HaChartBase;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: CustomLegendOption["data"];
@@ -244,8 +260,12 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
.data=${this._chartData}
.highlight=${this.highlight}
.snapSelection=${this._snapSelection}
.options=${this._chartOptions}
.height=${this.height}
.selectMode=${this.selectMode}
.zoomSlider=${this.zoomSlider}
style=${styleMap({ height: this.height })}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@@ -259,6 +279,34 @@ export class StatisticsChart extends LitElement {
`;
}
public resetZoom() {
this._chartBase?.zoom(0, 100);
}
// A click selects the period that contains it, a drag every period it
// touches
private _snapSelection = (
range: ChartTimeRange
): ChartTimeRange | undefined => {
let start: number | undefined;
let end: number | undefined;
Object.values(this.statisticsData ?? {}).forEach((values) =>
values.forEach((value) => {
const touched =
range.start === range.end
? value.start <= range.start && range.start < value.end
: value.start < range.end && value.end > range.start;
if (touched) {
start = Math.min(start ?? value.start, value.start);
end = Math.max(end ?? value.end, value.end);
}
})
);
return start === undefined || end === undefined
? undefined
: { start, end };
};
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
this._hiddenStats.add(ev.detail.id);
this.requestUpdate("_hiddenStats");
+8 -98
View File
@@ -19,10 +19,8 @@ import {
type CameraEntity,
} from "../data/camera";
import {
hasWebRtcWorked,
MJPEG_STREAM,
selectCameraStreams,
setWebRtcWorked,
type CameraStream,
type CameraStreamTracks,
type CameraStreamType,
@@ -39,9 +37,6 @@ import type { HaWebRtcPlayer } from "./ha-web-rtc-player";
export type { CameraStreamType } from "../data/camera-streams";
// How long WebRTC gets on its own before HLS is started as well
const WEB_RTC_FIRST_TIMEOUT = 12000;
@customElement("ha-camera-stream")
export class HaCameraStream extends LitElement {
@state()
@@ -86,22 +81,12 @@ export class HaCameraStream extends LitElement {
@state() private _webRtcStreams?: CameraStreamTracks;
// WebRTC worked before for this camera, so HLS is not started with it
@state() private _webRtcFirst = false;
@query("ha-web-rtc-player") private _webRtcPlayer?: HaWebRtcPlayer;
private _visibleStreamType?: CameraStreamType;
private _webRtcFirstTimeout?: number;
private _capabilitiesRequest = 0;
private _streamsStartedAt = 0;
// If WebRTC loaded fast enough to be worth waiting for without HLS
private _webRtcLoadedInTime?: boolean;
private _thumbnailApi = memoizeOne(
(
api: ContextType<typeof apiContext>,
@@ -148,16 +133,15 @@ export class HaCameraStream extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._connected = true;
if (this.hasUpdated && this._capabilities && this.stateObj) {
if (this.hasUpdated) {
// The players start again when reattached, so their results do as well
this._startStreams(this.stateObj.entity_id, this._capabilities);
this._resetStreams();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._connected = false;
this._stopWebRtcFirst();
}
protected updated(changedProps: PropertyValues<this>): void {
@@ -173,28 +157,6 @@ export class HaCameraStream extends LitElement {
this._visibleStreamType = visibleStreamType;
fireEvent(this, "stream-type-changed", { type: visibleStreamType });
}
this._rememberWebRtc(streams);
}
/**
* Remembers per camera if WebRTC is the stream that ends up playing in this
* browser, to start it without HLS the next time.
*/
private _rememberWebRtc(streams: CameraStream[]) {
if (
!this.stateObj ||
!this._webRtcStreams ||
(this._capabilities?.frontend_stream_types.length ?? 0) < 2 ||
streams.length !== 1
) {
// No choice between streams, or the choice is not final yet
return;
}
setWebRtcWorked(
this.stateObj.entity_id,
streams[0].type === STREAM_TYPE_WEB_RTC &&
this._webRtcLoadedInTime === true
);
}
protected render() {
@@ -271,8 +233,7 @@ export class HaCameraStream extends LitElement {
return this._streams(
this._capabilities?.frontend_stream_types,
this._hlsStreams,
this._webRtcStreams,
this._webRtcFirst
this._webRtcStreams
);
}
@@ -287,62 +248,19 @@ export class HaCameraStream extends LitElement {
};
return;
}
const entityId = this.stateObj!.entity_id;
const capabilities = await fetchCameraCapabilities(this._api, entityId);
const capabilities = await fetchCameraCapabilities(
this._api,
this.stateObj!.entity_id
);
if (request !== this._capabilitiesRequest) {
return;
}
if (this.isConnected) {
// Otherwise started when connected
this._startStreams(entityId, capabilities);
}
this._capabilities = capabilities;
}
private _resetStreams() {
this._hlsStreams = undefined;
this._webRtcStreams = undefined;
this._webRtcLoadedInTime = undefined;
this._stopWebRtcFirst();
}
private _startStreams(entityId: string, capabilities: CameraCapabilities) {
this._resetStreams();
this._streamsStartedAt = performance.now();
if (
capabilities.frontend_stream_types.length > 1 &&
hasWebRtcWorked(entityId)
) {
this._webRtcFirst = true;
this._webRtcFirstTimeout = window.setTimeout(
this._webRtcFirstTimedOut,
WEB_RTC_FIRST_TIMEOUT
);
}
}
private _webRtcFirstTimedOut = () => {
if (document.hidden) {
// A player started in the background is not stopped again, so wait
this._webRtcFirstTimeout = window.setTimeout(
this._webRtcFirstTimedOut,
WEB_RTC_FIRST_TIMEOUT
);
return;
}
this._webRtcFirstTimeout = undefined;
// Start HLS as well, WebRTC keeps connecting and takes over when it loads
this._webRtcFirst = false;
if (this.stateObj) {
// Don't wait for it the next time, also when it never reports a result
setWebRtcWorked(this.stateObj.entity_id, false);
}
};
private _stopWebRtcFirst() {
clearTimeout(this._webRtcFirstTimeout);
this._webRtcFirstTimeout = undefined;
this._webRtcFirst = false;
}
private async _getPosterUrl(): Promise<void> {
@@ -368,8 +286,6 @@ export class HaCameraStream extends LitElement {
}
private _handleWebRtcStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
clearTimeout(this._webRtcFirstTimeout);
this._webRtcFirstTimeout = undefined;
if (
!ev.detail.hasVideo &&
!ev.detail.hasAudio &&
@@ -380,10 +296,6 @@ export class HaCameraStream extends LitElement {
// Only a browser without WebRTC falls back to MJPEG.
return;
}
if (ev.detail.hasVideo && this._webRtcLoadedInTime === undefined) {
this._webRtcLoadedInTime =
performance.now() - this._streamsStartedAt <= WEB_RTC_FIRST_TIMEOUT;
}
this._webRtcStreams = ev.detail;
}
@@ -391,8 +303,7 @@ export class HaCameraStream extends LitElement {
(
supportedTypes?: CameraCapabilities["frontend_stream_types"],
hlsStreams?: CameraStreamTracks,
webRtcStreams?: CameraStreamTracks,
webRtcFirst?: boolean
webRtcStreams?: CameraStreamTracks
): CameraStream[] => {
if (__DEMO__) {
return [{ type: MJPEG_STREAM, visible: true }];
@@ -401,7 +312,6 @@ export class HaCameraStream extends LitElement {
supportedTypes,
hlsStreams,
webRtcStreams,
webRtcFirst,
});
}
);
+4
View File
@@ -112,6 +112,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@property({ attribute: "pin-block" })
public pinBlock?: "start" | "center" | "end" | "nearest";
// Skip the "unknown value" highlight and note for a value that is not in the
// list but that the value renderer presents on its own.
@property({ type: Boolean, attribute: "no-unknown-state" })
@@ -347,6 +350,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
.noSort=${this.noSort}
.pinBlock=${this.pinBlock}
></ha-picker-combo-box>
`;
}
+7 -2
View File
@@ -180,13 +180,14 @@ class HaHLSPlayer extends LitElement {
return;
}
if (!this.entityid) {
const entityId = this.entityid;
if (!entityId) {
return;
}
try {
const { url } = await fetchStreamUrl(
{ callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
this.entityid
entityId
);
this._url = this._connection.hassUrl(url);
@@ -198,6 +199,10 @@ class HaHLSPlayer extends LitElement {
// eslint-disable-next-line
console.error(err);
if (!this.isConnected || entityId !== this.entityid) {
// Removed or switched camera while fetching, the result is outdated
return;
}
fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
}
}
+62 -18
View File
@@ -1,13 +1,17 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { TemplateResult } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { customIcons } from "../data/custom_icons";
import type { ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
@@ -50,16 +54,17 @@ const createIconItem = (icon: IconData, prefix: string): PickerComboBoxItem => {
const loadIcons = async () => {
ICONS_LOADED = true;
const iconList = await import("../../build/mdi/iconList.json");
ICONS = iconList.default.map((icon) => createIconItem(icon, "mdi"));
const sortIcons = (icons: PickerComboBoxItem[]) =>
icons.sort((a, b) => caseInsensitiveStringCompare(a.id, b.id));
const customIconLoads: Promise<PickerComboBoxItem[]>[] = [];
Object.keys(customIcons).forEach((iconSet) => {
customIconLoads.push(loadCustomIconItems(iconSet));
});
(await Promise.all(customIconLoads)).forEach((customIconItems) => {
ICONS.push(...customIconItems);
});
const iconList = await import("../../build/mdi/iconList.json");
const mdiIcons = iconList.default.map((icon) => createIconItem(icon, "mdi"));
ICONS = sortIcons([...mdiIcons]);
const customIconItems = await Promise.all(
Object.keys(customIcons).map(loadCustomIconItems)
);
ICONS = sortIcons([...mdiIcons, ...customIconItems.flat()]);
};
const loadCustomIconItems = async (
@@ -81,8 +86,8 @@ const loadCustomIconItems = async (
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item>
<ha-icon .icon=${item.id} slot="start"></ha-icon>
${item.id}
<ha-icon .icon=${item.icon ?? item.id} slot="start"></ha-icon>
${item.icon ?? item.id}
</ha-combo-box-item>
`;
@@ -104,12 +109,32 @@ export class HaIconPicker extends LitElement {
@property({ type: Boolean }) public invalid = false;
private _getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
@property({ attribute: false }) public suggestedIcons?: string[];
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _getIconPickerItems = () =>
this._itemsWithSuggestions(ICONS, this.suggestedIcons, this._localize);
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("suggestedIcons") || changedProps.has("_localize")) {
this._picker?.refreshItems();
}
}
protected render(): TemplateResult {
return html`
<ha-generic-picker
allow-custom-value
no-sort
.pinBlock=${
this.value && this.suggestedIcons?.includes(this.value)
? "end"
: undefined
}
.getItems=${this._getIconPickerItems}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -128,15 +153,35 @@ export class HaIconPicker extends LitElement {
`;
}
private _itemsWithSuggestions = memoizeOne(
(
icons: PickerComboBoxItem[],
suggestedIcons: string[] | undefined,
localize: LocalizeFunc
): (PickerComboBoxItem | string)[] => {
if (!suggestedIcons?.length) {
return icons;
}
const suggested = new Set(suggestedIcons);
return [
localize("ui.components.icon_picker.suggested"),
...suggestedIcons.map((icon) => ({ id: icon, primary: icon, icon })),
localize("ui.components.icon_picker.all_icons"),
...icons.filter((item) => !suggested.has(item.id)),
];
}
);
// Filter can take a significant chunk of frame (up to 3-5 ms)
private _filterIcons = memoizeOne(
(
filter: string,
filteredItems: PickerComboBoxItem[],
allItems: PickerComboBoxItem[]
_allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
const normalizedFilter = filter.toLowerCase().replace(/\s+/g, "-");
const iconItems = allItems?.length ? allItems : filteredItems;
// The full list also holds the suggestions and their section titles
const iconItems = ICONS.length ? ICONS : filteredItems;
if (!normalizedFilter.length) {
return iconItems;
@@ -175,8 +220,7 @@ export class HaIconPicker extends LitElement {
protected firstUpdated() {
if (!ICONS_LOADED) {
loadIcons().then(() => {
this._getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
this.requestUpdate();
this._picker?.refreshItems();
});
}
}
+12 -2
View File
@@ -201,6 +201,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@property({ attribute: "pin-block" })
public pinBlock?: "start" | "center" | "end" | "nearest";
@query(".list") private _list?: HaListBase;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@@ -453,6 +456,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.value
)}
.pinIndex=${this.value ? this._getInitialSelectedIndex() : undefined}
.pinBlock=${this.pinBlock ?? "center"}
@mousedown=${this._preventBlur}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@@ -685,6 +689,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "index-selected", { index, item, newTab });
}
// Fuse indexes every row as text when the first one is a section title
private _searchableItems = memoizeOne((items: PickerComboBoxItem[]) =>
items.filter((item) => typeof item !== "string")
);
private _fuseIndex = memoizeOne(
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) =>
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
@@ -708,10 +717,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return;
}
const index = this._fuseIndex(this._allItems, this.searchKeys);
const searchableItems = this._searchableItems(this._allItems);
const index = this._fuseIndex(searchableItems, this.searchKeys);
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems,
searchableItems,
searchString,
(item) => item.id,
index
+24 -44
View File
@@ -9,12 +9,6 @@ import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { getIcePath } from "../common/webrtc/get-ice-path";
import type { IcePath } from "../common/webrtc/limit-ice-servers";
import {
limitIceServers,
limitIceServersToPath,
} from "../common/webrtc/limit-ice-servers";
import {
addWebRtcCandidate,
fetchWebRtcClientConfiguration,
@@ -37,8 +31,10 @@ const ANSWER_TIMEOUT = 20000;
// minutes to give up on their own, or never do.
const ICE_TIMEOUT = 30000;
// How often a stream that lost or never got its connection is started again
const MAX_RETRIES = 2;
// How often a stream that lost its connection or got a backend error is
// started again. A new connection that gets no answer or finds no path is not
// retried, as a retry would most likely end the same way.
const MAX_RETRIES = 1;
// Gives the backend time to tear down the failed session before the next one
const RETRY_DELAY = 1000;
@@ -192,16 +188,8 @@ export class HaWebRtcPlayer extends LitElement {
// Renegotiating the existing connection is not supported by all providers
// (go2rtc answers with a new DTLS fingerprint, which Firefox rejects), so
// start a new connection and session with the microphone track instead.
// The new connection only looks for the path the running stream uses
const icePath = this._peerConnection
? await getIcePath(this._peerConnection)
: undefined;
if (request !== this._microphoneRequest || !this.isConnected) {
track.stop();
return;
}
this._captureReconnectFrame();
await this._startWebRtc(track, icePath);
await this._startWebRtc(track);
}
private _captureReconnectFrame() {
@@ -238,8 +226,9 @@ export class HaWebRtcPlayer extends LitElement {
private _retries = 0;
// The stream only loaded with all ICE servers, so keep using them
private _allIceServers = false;
// The stream showed video since it was started, so a lost connection is
// worth a retry
private _hasPlayed = false;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
@@ -256,8 +245,7 @@ export class HaWebRtcPlayer extends LitElement {
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
} else {
this._retries = 0;
this._startWebRtc();
this._restartWebRtc();
}
};
@@ -298,8 +286,7 @@ export class HaWebRtcPlayer extends LitElement {
public override connectedCallback() {
super.connectedCallback();
if (this.hasUpdated && this.entityid) {
this._retries = 0;
this._startWebRtc();
this._restartWebRtc();
}
document.addEventListener("visibilitychange", this._handleVisibilityChange);
}
@@ -320,14 +307,18 @@ export class HaWebRtcPlayer extends LitElement {
if (!changedProperties.has("entityid")) {
return;
}
this._restartWebRtc();
}
/** Starts a new stream, not a retry of the current one. */
private _restartWebRtc() {
this._retries = 0;
this._allIceServers = false;
this._hasPlayed = false;
this._startWebRtc();
}
private async _startWebRtc(
localReturnAudioTrack?: MediaStreamTrack,
icePath?: IcePath
localReturnAudioTrack?: MediaStreamTrack
): Promise<void> {
this._cleanUp();
@@ -382,21 +373,9 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("end clientConfig", this._clientConfig);
const { iceServers } = this._clientConfig.configuration;
const allIceServers = this._allIceServers || this._retries >= MAX_RETRIES;
this._peerConnection = new RTCPeerConnection({
...this._clientConfig.configuration,
// The last attempt uses all ICE servers, in case a removed URL is the
// only one that works in this network
// A known relay is taken from all of them. A direct path is not, as
// the URL it needs can be one that is removed.
iceServers:
(icePath &&
(icePath.relayUrl || !allIceServers) &&
limitIceServersToPath(iceServers, icePath)) ||
(allIceServers ? iceServers : limitIceServers(iceServers)),
});
this._peerConnection = new RTCPeerConnection(
this._clientConfig.configuration
);
if (this._clientConfig.dataChannel) {
// Some cameras (such as nest) require a data channel to establish a stream
@@ -529,6 +508,9 @@ export class HaWebRtcPlayer extends LitElement {
private _retryOrFail(error: WebRtcPlayerError) {
if (
this._retries >= MAX_RETRIES ||
// No answer or no path for a stream that never played, a retry would
// most likely end the same way
(error.type === "no_connection" && !this._hasPlayed) ||
// A microphone connect the user started is handled by starting again
// without the microphone. A retry keeps the microphone of the stream.
(this._localReturnAudioTrack &&
@@ -799,10 +781,8 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("loadedData", data);
this._stopTimer();
if (this._retries >= MAX_RETRIES) {
this._allIceServers = true;
}
this._retries = 0;
this._hasPlayed = true;
// Without a track the microphone is still waiting for permission
if (this._microphoneConnecting && this._localReturnAudioTrack) {
+12
View File
@@ -34,6 +34,8 @@ import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
*
* @attr {string} headline - Primary text. Overridden by the `headline` slot.
* @attr {string} supporting-text - Secondary text. Overridden by the `supporting-text` slot.
* @attr {boolean} wrap-headline - Wraps the headline onto multiple lines instead of truncating it.
* @attr {boolean} wrap-supporting-text - Wraps the supporting text onto multiple lines instead of truncating it.
* @attr {boolean} disabled - Dims the row and blocks pointer events.
*/
@customElement("ha-row-item")
@@ -43,6 +45,12 @@ export class HaRowItem extends LitElement {
@property({ type: String, attribute: "supporting-text" })
public supportingText?: string;
@property({ type: Boolean, reflect: true, attribute: "wrap-headline" })
public wrapHeadline = false;
@property({ type: Boolean, reflect: true, attribute: "wrap-supporting-text" })
public wrapSupportingText = false;
@property({ type: Boolean, reflect: true }) public disabled = false;
protected readonly _slotController = new HasSlotController(
@@ -192,6 +200,10 @@ export class HaRowItem extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
}
:host([wrap-headline]) .headline,
:host([wrap-supporting-text]) .supporting {
white-space: normal;
}
`;
}
@@ -0,0 +1,128 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { HaListItemButton } from "../item/ha-list-item-button";
import "./ha-media-browser-thumbnail";
/**
* @element ha-media-player-browse-list-item
* @extends {HaListItemButton}
*
* @summary
* Row of the media browser list. Always reserves a graphic next to the title
* so titles line up, and shows a thumbnail with an optional play button over
* it. Without `interactive` the row is plain text, used for the "not shown"
* row. The row is rendered inside the shadow root of `ha-list-virtualized`, so
* it carries its own styles.
*
* @slot start - Play or pick button, shown over the thumbnail.
* @slot headline - Title of the row.
*
* @csspart start - The graphic container.
*
* @attr {string} thumbnail - URL of the thumbnail. Shown inside the graphic.
*/
@customElement("ha-media-player-browse-list-item")
export class HaMediaPlayerBrowseListItem extends HaListItemButton {
@property() public thumbnail?: string;
// The "not shown" row is plain text, not a button
protected override _renderControl(content: TemplateResult): TemplateResult {
if (!this.interactive) {
return content;
}
return super._renderControl(content);
}
// Always reserve the graphic, so titles line up across rows
protected override _renderStart(): TemplateResult {
return html`<div part="start" class="start">
<div class="graphic">
${
this.thumbnail
? html`<ha-media-browser-thumbnail
.url=${this.thumbnail}
></ha-media-browser-thumbnail>`
: nothing
}
<slot name="start"></slot>
</div>
</div>`;
}
static styles: CSSResultGroup = [
HaListItemButton.styles,
css`
:host {
direction: var(--direction);
--ha-row-item-padding-block: 0;
--ha-row-item-min-height: 48px;
}
:host(.show-images) {
--ha-row-item-padding-block: var(--ha-space-1);
--ha-row-item-min-height: 64px;
}
:host(.not-shown) {
font-style: italic;
color: var(--secondary-text-color);
cursor: default;
}
::slotted(ha-svg-icon) {
color: var(--secondary-text-color);
}
.graphic {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
}
:host(.show-images) .graphic {
width: 56px;
height: 56px;
}
ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
::slotted(.play) {
position: absolute;
inset: 0;
margin: auto;
width: 40px;
height: 40px;
opacity: 0;
transition: all 0.5s;
background-color: rgba(var(--rgb-card-background-color), 0.5);
border-radius: var(--ha-border-radius-circle);
--ha-icon-button-size: 40px;
}
:host(:hover) ::slotted(.play) {
opacity: 1;
color: var(--primary-text-color);
}
::slotted(.play.show) {
opacity: 1;
background-color: transparent;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-player-browse-list-item": HaMediaPlayerBrowseListItem;
}
}
@@ -77,7 +77,6 @@ import "../ha-svg-icon";
import "../ha-tooltip";
import "../input/ha-input-search";
import type { HaInputSearch } from "../input/ha-input-search";
import { HaListItemButton } from "../item/ha-list-item-button";
import "../list/ha-list-virtualized";
import type {
HaListVirtualized,
@@ -88,6 +87,7 @@ import type { ManualMediaPickedEvent } from "./ha-browse-media-manual";
import "./ha-browse-media-tts";
import type { TtsMediaPickedEvent } from "./ha-browse-media-tts";
import "./ha-media-browser-thumbnail";
import "./ha-media-player-browse-list-item";
declare global {
interface HASSDomEvents {
@@ -120,105 +120,6 @@ type MediaBrowseListRow =
count: number;
});
@customElement("ha-media-player-browse-list-item")
export class HaMediaPlayerBrowseListItem extends HaListItemButton {
@property() public thumbnail?: string;
// The "not shown" row is plain text, not a button
protected override _renderControl(content: TemplateResult): TemplateResult {
if (!this.interactive) {
return content;
}
return super._renderControl(content);
}
// Always reserve the graphic, so titles line up across rows
protected override _renderStart(): TemplateResult {
return html`<div part="start" class="start">
<div class="graphic">
${
this.thumbnail
? html`<ha-media-browser-thumbnail
.url=${this.thumbnail}
></ha-media-browser-thumbnail>`
: nothing
}
<slot name="start"></slot>
</div>
</div>`;
}
static styles: CSSResultGroup = [
HaListItemButton.styles,
css`
:host {
direction: var(--direction);
--ha-row-item-padding-block: 0;
--ha-row-item-min-height: 48px;
}
:host(.show-images) {
--ha-row-item-padding-block: var(--ha-space-1);
--ha-row-item-min-height: 64px;
}
:host(.not-shown) {
font-style: italic;
color: var(--secondary-text-color);
cursor: default;
}
::slotted(ha-svg-icon) {
color: var(--secondary-text-color);
}
.graphic {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
}
:host(.show-images) .graphic {
width: 56px;
height: 56px;
}
ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
::slotted(.play) {
position: absolute;
inset: 0;
margin: auto;
width: 40px;
height: 40px;
opacity: 0;
transition: all 0.5s;
background-color: rgba(var(--rgb-card-background-color), 0.5);
border-radius: var(--ha-border-radius-circle);
--ha-icon-button-size: 40px;
}
:host(:hover) ::slotted(.play) {
opacity: 1;
color: var(--primary-text-color);
}
::slotted(.play.show) {
opacity: 1;
background-color: transparent;
}
`,
];
}
/** Folders with at least this many items get a filter field when their source cannot search. */
const FILTER_MIN_CHILDREN = 10;
@@ -1945,6 +1846,5 @@ export class HaMediaPlayerBrowse extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-media-player-browse": HaMediaPlayerBrowse;
"ha-media-player-browse-list-item": HaMediaPlayerBrowseListItem;
}
}
+201
View File
@@ -0,0 +1,201 @@
import type { LocalizeFunc } from "../common/translations/localize";
export const ROOM_ICONS = {
living_room: [
"mdi:sofa",
"mdi:sofa-single",
"mdi:television",
"mdi:fireplace",
"mdi:floor-lamp",
],
bedroom: ["mdi:bed", "mdi:bed-king", "mdi:bed-double", "mdi:lamp"],
kids_room: ["mdi:teddy-bear", "mdi:toy-brick", "mdi:bunk-bed", "mdi:cradle"],
kitchen: [
"mdi:stove",
"mdi:fridge",
"mdi:countertop",
"mdi:chef-hat",
"mdi:pot-steam",
"mdi:coffee-maker",
],
dining_room: [
"mdi:silverware-fork-knife",
"mdi:table-chair",
"mdi:table-furniture",
"mdi:glass-wine",
],
bathroom: [
"mdi:shower",
"mdi:bathtub",
"mdi:shower-head",
"mdi:toothbrush",
"mdi:hair-dryer",
],
toilet: ["mdi:toilet", "mdi:paper-roll"],
office: [
"mdi:desk",
"mdi:laptop",
"mdi:monitor",
"mdi:chair-rolling",
"mdi:bookshelf",
],
laundry_room: [
"mdi:washing-machine",
"mdi:tumble-dryer",
"mdi:iron",
"mdi:hanger",
],
hallway: ["mdi:door", "mdi:door-open", "mdi:coat-rack", "mdi:key"],
stairs: ["mdi:stairs", "mdi:stairs-up", "mdi:stairs-down"],
garage: ["mdi:garage", "mdi:garage-variant", "mdi:car", "mdi:tools"],
garden: [
"mdi:flower",
"mdi:tree",
"mdi:grass",
"mdi:sprout",
"mdi:watering-can",
"mdi:greenhouse",
],
terrace: [
"mdi:balcony",
"mdi:grill",
"mdi:umbrella-outline",
"mdi:coach-lamp",
"mdi:patio-heater",
],
basement: [
"mdi:home-floor-negative-1",
"mdi:stairs-down",
"mdi:water-boiler",
],
attic: ["mdi:home-roof", "mdi:package-variant-closed"],
storage: ["mdi:archive", "mdi:package-variant-closed", "mdi:warehouse"],
utility_room: [
"mdi:water-boiler",
"mdi:heat-pump",
"mdi:server-network",
"mdi:flash",
],
pool: ["mdi:pool", "mdi:swim"],
media_room: [
"mdi:theater",
"mdi:projector",
"mdi:television",
"mdi:movie-open",
],
game_room: [
"mdi:gamepad-variant",
"mdi:billiards",
"mdi:cards-playing",
"mdi:dice-multiple",
],
music_room: ["mdi:piano", "mdi:guitar-acoustic", "mdi:music"],
library: ["mdi:bookshelf", "mdi:book-open-variant", "mdi:library"],
bar: [
"mdi:glass-cocktail",
"mdi:glass-mug-variant",
"mdi:bottle-wine",
"mdi:beer",
],
dressing_room: [
"mdi:hanger",
"mdi:wardrobe",
"mdi:tshirt-crew",
"mdi:shoe-heel",
],
workshop: ["mdi:tools", "mdi:hammer-wrench", "mdi:toolbox", "mdi:saw-blade"],
server_room: ["mdi:server-network", "mdi:server", "mdi:router-network"],
safe_room: ["mdi:shield-home", "mdi:shield-lock", "mdi:safe"],
prayer_room: ["mdi:hands-pray", "mdi:meditation", "mdi:candle"],
sauna: ["mdi:spa", "mdi:hot-tub", "mdi:thermometer-high"],
outdoor_kitchen: ["mdi:grill", "mdi:grill-outline", "mdi:pizza"],
driveway: ["mdi:road-variant", "mdi:car", "mdi:gate"],
} as const satisfies Record<string, readonly string[]>;
export type RoomType = keyof typeof ROOM_ICONS;
type RoomKeywords = Partial<Record<RoomType, string[]>>;
export const ROOM_TYPES = Object.keys(ROOM_ICONS) as RoomType[];
export const isRoomType = (value: string): value is RoomType =>
(ROOM_TYPES as string[]).includes(value);
// Languages written without spaces between words
const NO_WORD_SPACING =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}]/u;
const normalizeRoomText = (text: string): string =>
text
.normalize("NFD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/['’]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
interface RoomMatch {
room: RoomType;
start: number;
end: number;
}
const findMatches = (name: string, keywords: RoomKeywords): RoomMatch[] => {
const text = ` ${normalizeRoomText(name)} `;
const candidates: RoomMatch[] = [];
for (const room of ROOM_TYPES) {
for (const keyword of keywords[room] ?? []) {
const phrase = normalizeRoomText(keyword);
if (!phrase) {
continue;
}
const needle = NO_WORD_SPACING.test(phrase) ? phrase : ` ${phrase} `;
const start = text.indexOf(needle);
if (start !== -1) {
candidates.push({ room, start, end: start + needle.length });
}
}
}
// The longest phrase wins, so "guest bedroom" is not also read as "bedroom"
candidates.sort((a, b) => b.end - b.start - (a.end - a.start));
const kept: RoomMatch[] = [];
for (const match of candidates) {
const overlaps = kept.some(
(other) => match.start < other.end - 1 && other.start < match.end - 1
);
if (!overlaps) {
kept.push(match);
}
}
return kept.sort((a, b) => a.start - b.start);
};
const matchRoomTypes = (
names: string[],
keywords: RoomKeywords
): RoomType[] => {
const rooms = new Set<RoomType>();
for (const name of names) {
for (const match of findMatches(name, keywords)) {
rooms.add(match.room);
}
}
return [...rooms];
};
const roomIconSuggestions = (rooms: RoomType[]): string[] => [
...new Set(rooms.flatMap((room) => ROOM_ICONS[room])),
];
export const getAreaIconSuggestions = (
names: string[],
localize: LocalizeFunc
): string[] => {
const keywords: RoomKeywords = Object.fromEntries(
ROOM_TYPES.map((room) => [
room,
localize(`ui.components.rooms.keywords.${room}`).split("|"),
])
);
return roomIconSuggestions(matchRoomTypes(names, keywords));
};
+1 -36
View File
@@ -1,6 +1,3 @@
import { addRecent } from "../common/array/add-recent";
import { getStorage } from "../common/decorators/storage";
export const STREAM_TYPE_HLS = "hls";
export const STREAM_TYPE_WEB_RTC = "web_rtc";
@@ -25,8 +22,6 @@ export interface CameraStreamSelectionOptions {
supportedTypes?: StreamType[];
hlsStreams?: CameraStreamTracks;
webRtcStreams?: CameraStreamTracks;
/** Only start WebRTC and keep HLS off until WebRTC fails or takes too long. */
webRtcFirst?: boolean;
}
/**
@@ -37,7 +32,6 @@ export const selectCameraStreams = ({
supportedTypes,
hlsStreams,
webRtcStreams,
webRtcFirst,
}: CameraStreamSelectionOptions): CameraStream[] => {
if (!supportedTypes) {
return [];
@@ -76,7 +70,7 @@ export const selectCameraStreams = ({
}
// WebRTC is still loading
if (hlsFailed || (webRtcFirst && !hlsStreams)) {
if (hlsFailed) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
return [
@@ -84,32 +78,3 @@ export const selectCameraStreams = ({
{ type: STREAM_TYPE_WEB_RTC, visible: false },
];
};
const WEB_RTC_ENTITIES_KEY = "cameraWebRtcEntities";
const MAX_WEB_RTC_ENTITIES = 100;
const getWebRtcEntities = (): string[] => {
const entities = getStorage().getValue<string[]>(WEB_RTC_ENTITIES_KEY);
return Array.isArray(entities) ? entities : [];
};
/**
* If WebRTC was the stream that ended up playing for this camera in this
* browser. Stored per browser, as it depends on the network of the client.
*/
export const hasWebRtcWorked = (entityId: string): boolean =>
getWebRtcEntities().includes(entityId);
export const setWebRtcWorked = (entityId: string, worked: boolean): void => {
const entities = getWebRtcEntities();
if (entities.includes(entityId) === worked) {
return;
}
getStorage().setValue(
WEB_RTC_ENTITIES_KEY,
worked
? addRecent(entities, entityId, MAX_WEB_RTC_ENTITIES)
: entities.filter((entity) => entity !== entityId)
);
};
+11 -7
View File
@@ -209,7 +209,15 @@ export const watchForNewMatterDevice = (
callback: (device: DeviceRegistryEntry) => void
): UnsubscribeFunc => {
let curMatterDevices: Set<string> | undefined;
const unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
let unsubDeviceReg: UnsubscribeFunc | undefined;
// Callers also unsubscribe after a find. The registry collection counts its
// subscribers, so a second unsubscribe would stop its updates for everyone.
const unsubscribe = () => {
unsubDeviceReg?.();
unsubDeviceReg = undefined;
curMatterDevices = undefined;
};
unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
if (!curMatterDevices) {
curMatterDevices = new Set(
Object.values(entries)
@@ -226,15 +234,11 @@ export const watchForNewMatterDevice = (
!curMatterDevices!.has(device.id)
);
if (newMatterDevices.length) {
unsubDeviceReg();
curMatterDevices = undefined;
unsubscribe();
callback(newMatterDevices[0]);
}
});
return () => {
unsubDeviceReg();
curMatterDevices = undefined;
};
return unsubscribe;
};
export const redirectOnNewMatterDevice = (
@@ -167,16 +167,12 @@ export class HaMoreInfoFavorites extends LitElement {
display: flex;
align-items: center;
justify-content: center;
margin: calc(var(--ha-space-2) * -1);
gap: var(--ha-space-4);
flex-wrap: wrap;
max-width: var(--favorite-items-max-width, 250px);
user-select: none;
}
.container > * {
margin: var(--ha-space-2);
}
.favorite {
display: block;
}
+16 -8
View File
@@ -60,14 +60,25 @@
/* Margins, not flex `gap` (Safari 14.1). */
#ha-launch-screen .ha-launch-screen-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin: -4px 16px;
flex-direction: column;
align-items: center;
margin: 0 16px;
}
#ha-launch-screen .ha-launch-screen-actions label {
display: flex;
align-items: center;
margin-top: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
#ha-launch-screen .ha-launch-screen-actions input {
margin: 0 8px 0 0;
accent-color: #009ac7;
}
#ha-launch-screen .ha-launch-screen-actions button {
margin: 4px;
padding: 8px 16px;
border: 1px solid transparent;
border: 1px solid currentColor;
border-radius: 18px;
background: none;
color: #009ac7;
@@ -76,9 +87,6 @@
font-weight: 500;
cursor: pointer;
}
#ha-launch-screen .ha-launch-screen-actions .ha-launch-screen-continue {
border-color: currentColor;
}
.ohf-logo {
margin: 48px 0;
display: flex;
+25 -22
View File
@@ -116,30 +116,33 @@
actions.className = "ha-launch-screen-actions";
box.appendChild(actions);
function addButton(label, value) {
var button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", function () {
try {
localStorage.setItem(ACCEPTED_KEY, value());
} catch (_err) {
// Continue this time only.
}
box.removeChild(actions);
loadApp();
});
actions.appendChild(button);
return button;
}
var always = null;
var button = document.createElement("button");
button.type = "button";
button.textContent = "Continue anyway";
button.addEventListener("click", function () {
try {
localStorage.setItem(
ACCEPTED_KEY,
always && always.checked ? "always" : String(Date.now())
);
} catch (_err) {
// Continue this time only.
}
box.removeChild(actions);
loadApp();
});
actions.appendChild(button);
addButton("Continue anyway", function () {
return String(Date.now());
}).className = "ha-launch-screen-continue";
// From the first reminder on. A checkbox, so it is not chosen by
// accident.
if (acceptedAt) {
addButton("Don't show again", function () {
return "always";
});
var label = document.createElement("label");
always = document.createElement("input");
always.type = "checkbox";
label.appendChild(always);
label.appendChild(document.createTextNode("Don't show again"));
actions.appendChild(label);
}
})();
}
@@ -816,7 +816,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
this._currentAddon.version
? html`
<wa-divider></wa-divider>
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.boot.title"
@@ -842,7 +842,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
${
this._currentAddon.startup !== "once"
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.watchdog.title"
@@ -867,7 +867,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
`
: nothing
}
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.auto_update.title"
@@ -893,7 +893,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
!this._computeCannotIngressSidebar &&
this._currentAddon.ingress
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.ingress_panel.title"
@@ -923,7 +923,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
${
this._computeUsesProtectedOptions
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.protected.title"
@@ -954,7 +954,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
${
this._currentAddon.version && this._currentAddon.state === "started"
? html`<wa-divider></wa-divider>
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="supporting-text">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.hostname"
@@ -1630,10 +1630,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--ha-color-text-secondary);
}
.controls ha-row-item::part(supporting-text) {
white-space: normal;
}
.controls .content {
display: flex;
flex-direction: column;
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -29,6 +29,10 @@ import type {
AreaRegistryEntryMutableParams,
} from "../../../data/area/area_registry";
import { deleteAreaRegistryEntry } from "../../../data/area/area_registry";
import {
getAreaIconSuggestions,
ROOM_ICONS,
} from "../../../data/area_icon_suggestions";
import {
SENSOR_DEVICE_CLASS_HUMIDITY,
SENSOR_DEVICE_CLASS_TEMPERATURE,
@@ -96,6 +100,10 @@ class DialogAreaDetail
@state() private _submitting = false;
@state() private _suggestedIcons?: string[];
private _iconFollowsName = false;
@state() private _open = false;
@state() private _suggestionInclude: MetadataSuggestionInclude = {
@@ -128,6 +136,7 @@ class DialogAreaDetail
this._temperatureEntity = null;
this._humidityEntity = null;
}
this._iconFollowsName = !this._params.entry;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
@@ -151,6 +160,30 @@ class DialogAreaDetail
return true;
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.has("_name") || changedProps.has("_aliases")) {
this._updateSuggestedIcons();
}
}
private _updateSuggestedIcons() {
const icons = getAreaIconSuggestions(
[this._name ?? "", ...(this._aliases ?? [])],
this.hass.localize
);
this._suggestedIcons = icons;
if (this._iconFollowsName) {
const untouched = !this.isDirtyState;
this._icon = icons[0] ?? null;
if (untouched) {
this._initDirtyTracking({ type: "deep" }, this._currentState());
} else {
this._updateDirtyState(this._currentState());
}
}
}
private _entityName(entityId: string | null): string | null {
const stateObj = entityId ? this.hass.states[entityId] : undefined;
return stateObj ? this.hass.formatEntityName(stateObj) : null;
@@ -193,6 +226,7 @@ class DialogAreaDetail
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize("ui.panel.config.areas.editor.icon")}
.suggestedIcons=${this._suggestedIcons}
></ha-icon-picker>
<ha-floor-picker
@@ -300,6 +334,7 @@ class DialogAreaDetail
this._suggestionInclude = {
...this._suggestionInclude,
name: this._name.trim() === "",
room: !this._icon || this._iconFollowsName,
};
return generateMetadataSuggestionTask<{
@@ -349,6 +384,11 @@ class DialogAreaDetail
this._floor = processed.floor;
}
if (processed.room && (!this._icon || this._iconFollowsName)) {
this._icon = ROOM_ICONS[processed.room][0];
this._iconFollowsName = false;
}
this._updateDirtyState(this._currentState());
}
@@ -464,6 +504,7 @@ class DialogAreaDetail
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
this._iconFollowsName = false;
this._updateDirtyState(this._currentState());
}
@@ -21,7 +21,7 @@ class HaBackupConfigAddon extends LitElement {
protected render() {
return html`
<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.update_preference.label`
@@ -55,7 +55,7 @@ class HaBackupConfigAddon extends LitElement {
]}
></ha-select>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(`ui.panel.config.backup.schedule.retention`)}
</span>
@@ -113,10 +113,6 @@ class HaBackupConfigAddon extends LitElement {
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-select {
min-width: 210px;
}
@@ -197,7 +197,7 @@ class HaBackupConfigAgents extends LitElement {
!this.cloudStatus.active_subscription;
return html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div>
${
@@ -248,7 +248,7 @@ class HaBackupConfigAgents extends LitElement {
);
return html`
<ha-list-item-base>
<ha-list-item-base wrap-headline>
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div>
<ha-icon-button
@@ -317,10 +317,6 @@ class HaBackupConfigAgents extends LitElement {
--mdc-icon-size: 48px;
color: var(--primary-text-color);
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-list-item-base::part(end) {
gap: var(--ha-space-2);
}
@@ -239,7 +239,7 @@ class HaBackupConfigData extends LitElement {
return html`
${this._renderSizeEstimate()}
<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.panel.config.backup.data.ha_settings")}
@@ -266,7 +266,7 @@ class HaBackupConfigData extends LitElement {
${
this._showDbOption
? html`<ha-list-item-base>
? html`<ha-list-item-base wrap-headline wrap-supporting-text>
<ha-svg-icon slot="start" .path=${mdiChartBox}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.panel.config.backup.data.history")}
@@ -288,7 +288,7 @@ class HaBackupConfigData extends LitElement {
${
isHassio
? html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<ha-svg-icon
slot="start"
.path=${mdiPlayBoxMultiple}
@@ -309,7 +309,7 @@ class HaBackupConfigData extends LitElement {
></ha-switch>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<ha-svg-icon slot="start" .path=${mdiFolder}></ha-svg-icon>
<span slot="headline">
${this.hass.localize(
@@ -332,7 +332,7 @@ class HaBackupConfigData extends LitElement {
${
this._hasLocalAddons(this._addons)
? html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<ha-svg-icon
slot="start"
.path=${mdiFolder}
@@ -360,7 +360,7 @@ class HaBackupConfigData extends LitElement {
${
this._addons.length
? html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<ha-svg-icon
slot="start"
.path=${mdiPuzzle}
@@ -566,10 +566,6 @@ class HaBackupConfigData extends LitElement {
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-list-item-base::part(start) {
color: var(--ha-color-text-secondary);
}
@@ -25,7 +25,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
if (this._value) {
return html`
<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
@@ -48,7 +48,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
)}
</ha-button>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.show_encryption_key"
@@ -70,7 +70,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
)}
</ha-button>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.change_encryption_key"
@@ -99,7 +99,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
return html`
<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.set_encryption_key"
@@ -152,10 +152,6 @@ class HaBackupConfigEncryptionKey extends LitElement {
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-button[size="s"] ha-svg-icon {
--mdc-icon-size: 16px;
}
@@ -105,7 +105,7 @@ class HaBackupConfigRetention extends LitElement {
}
return html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${
this.headline ??
@@ -256,11 +256,6 @@ class HaBackupConfigRetention extends LitElement {
ha-row-item::part(end) {
align-items: flex-start;
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-select {
min-width: 210px;
}
@@ -121,7 +121,7 @@ class HaBackupConfigSchedule extends LitElement {
return html`
<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.schedule"
@@ -154,7 +154,7 @@ class HaBackupConfigSchedule extends LitElement {
)}
outlined
>
<ha-row-item class="days">
<ha-row-item wrap-headline class="days">
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.backup_every"
@@ -186,7 +186,7 @@ class HaBackupConfigSchedule extends LitElement {
(data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS &&
data.days.length > 0)
? html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.time"
@@ -231,7 +231,7 @@ class HaBackupConfigSchedule extends LitElement {
)}
outlined
>
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_time_label"
@@ -266,7 +266,7 @@ class HaBackupConfigSchedule extends LitElement {
${
this.supervisor
? html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.update_preference.label`
@@ -415,12 +415,6 @@ class HaBackupConfigSchedule extends LitElement {
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-row-item::part(headline),
ha-row-item::part(supporting-text),
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: wrap;
}
ha-select {
min-width: 210px;
}
@@ -65,7 +65,7 @@ class HaBackupOverviewBackups extends LitElement {
return html`
<ha-backup-summary-card .heading=${heading} .status=${status}>
<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline>
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span slot="headline" class=${headline === null ? "skeleton" : ""}
>${headline}</span
@@ -74,7 +74,7 @@ class HaBackupOverviewBackups extends LitElement {
${
description || description === null
? html`
<ha-list-item-base>
<ha-list-item-base wrap-headline>
<ha-svg-icon
slot="start"
.path=${mdiCalendar}
@@ -367,9 +367,6 @@ class HaBackupOverviewBackups extends LitElement {
ha-list-item-base::part(start) {
color: var(--ha-color-text-secondary);
}
ha-list-item-base::part(headline) {
white-space: wrap;
}
span.skeleton {
position: relative;
display: block;
@@ -152,7 +152,7 @@ class HaConfigBackupDetails extends LitElement {
${
CLOUD_AGENT === this.agentId
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted"
@@ -179,7 +179,7 @@ class HaConfigBackupDetails extends LitElement {
`
: encrypted
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted"
@@ -213,7 +213,7 @@ class HaConfigBackupDetails extends LitElement {
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off_description"
)}
</ha-alert>
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_unencrypted"
@@ -364,9 +364,6 @@ class HaConfigBackupDetails extends LitElement {
ha-backup-data-picker {
display: block;
}
ha-row-item::part(supporting-text) {
white-space: wrap;
}
.dot {
display: block;
position: relative;
@@ -170,7 +170,7 @@ export class CloudRemotePref extends LitElement {
>
<div class="card-content">
<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.external_activation"
@@ -187,7 +187,7 @@ export class CloudRemotePref extends LitElement {
@change=${this._toggleAllowRemoteEnabledChanged}
></ha-switch>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.certificate_info"
@@ -311,9 +311,6 @@ export class CloudRemotePref extends LitElement {
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
ha-tip {
max-width: 600px;
margin: 0 auto;
@@ -199,7 +199,7 @@ export class CloudWebhooks extends LitElement {
private _renderHookRow(entry: Webhook) {
return html`
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline"
>${entry.name}
${
@@ -374,7 +374,6 @@ export class CloudWebhooks extends LitElement {
}
ha-row-item::part(headline),
ha-row-item::part(supporting-text) {
white-space: wrap;
word-break: break-all;
}
`,
@@ -1,5 +1,7 @@
import { dump } from "js-yaml";
import type { AITaskStructure, GenDataTaskResult } from "../../../data/ai_task";
import type { RoomType } from "../../../data/area_icon_suggestions";
import { isRoomType, ROOM_TYPES } from "../../../data/area_icon_suggestions";
import type { HomeAssistant } from "../../../types";
import type { SuggestWithAIGenerateTask } from "../../../components/ha-suggest-with-ai-button";
import {
@@ -14,6 +16,7 @@ export interface MetadataSuggestionResult {
category?: string;
labels?: string[];
floor?: string;
room?: RoomType;
}
export type MetadataSuggestionDomain =
@@ -25,6 +28,7 @@ export interface MetadataSuggestionInclude {
categories?: boolean;
labels?: boolean;
floor?: boolean;
room?: boolean;
}
export const SUGGESTION_INCLUDE_DEFAULT: MetadataSuggestionInclude = {
@@ -86,6 +90,7 @@ export async function generateMetadataSuggestionTask<T>(
// providers reject a select/enum schema with an empty options list.
const includeCategories = categoryOptions.length > 0;
const includeFloor = floorOptions.length > 0;
const includeRoom = Boolean(include.room) && domain === "area";
const structure: AITaskStructure = {
...(include.name && {
@@ -139,6 +144,20 @@ export async function generateMetadataSuggestionTask<T>(
},
},
}),
...(includeRoom && {
room: {
description: `The type of room that best matches the ${domain}`,
required: false,
selector: {
select: {
options: ROOM_TYPES.map((room) => ({
value: room,
label: room.replace(/_/g, " "),
})),
},
},
},
}),
};
const requestedParts = [
@@ -147,6 +166,7 @@ export async function generateMetadataSuggestionTask<T>(
includeCategories ? "a category" : null,
include.labels ? "labels" : null,
includeFloor ? "a floor" : null,
includeRoom ? "a room type" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabels: string[] = [
@@ -233,6 +253,10 @@ export async function processMetadataSuggestion(
const processed: MetadataSuggestionResult = {
name: include.name ? result.data.name : undefined,
description: include.description ? result.data.description : undefined,
room:
include.room && result.data.room && isRoomType(result.data.room)
? result.data.room
: undefined,
};
// Convert category name to ID
@@ -542,7 +542,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
return html`
${
!this.hideName && this._device
? html`<ha-row-item class="main-entity">
? html`<ha-row-item wrap-supporting-text class="main-entity">
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.main_entity",
@@ -740,7 +740,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
${
SWITCH_AS_DOMAINS_INVERT.includes(this._switchAsDomain)
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.invert.label"
@@ -1017,7 +1017,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
${
this._cameraPrefs
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.stream.preload_stream"
@@ -1035,7 +1035,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
@change=${this._handleCameraPrefsChanged}
></ha-switch>
</ha-row-item>
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.stream.stream_orientation"
@@ -1074,6 +1074,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
this.helperConfigEntry.domain !== "switch_as_x"
? html`
<ha-list-item-button
wrap-supporting-text
class="menu-item"
.disabled=${this.disabled}
@click=${this._showOptionsFlow}
@@ -1106,6 +1107,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
: nothing
}
<ha-list-item-button
wrap-supporting-text
class="menu-item"
.disabled=${this.disabled}
@click=${this._handleVoiceAssistantsClicked}
@@ -1134,6 +1136,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
supportsFeature(stateObj, VacuumEntityFeature.CLEAN_AREA)
? html`
<ha-list-item-button
wrap-supporting-text
class="menu-item"
.disabled=${this.disabled}
@click=${this._handleVacuumSegmentMappingClicked}
@@ -1171,7 +1174,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
: nothing
}
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.enabled_label"
@@ -1211,7 +1214,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
: nothing
}
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.visible_label"
@@ -1233,7 +1236,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
${
this.entry.device_id
? html`
<ha-row-item class="device-area">
<ha-row-item wrap-supporting-text class="device-area">
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.use_device_area"
@@ -2013,11 +2016,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
ha-input.entityId ha-icon-button:last-child {
margin-inline-start: 0;
}
ha-row-item::part(supporting-text),
ha-list-item-button::part(supporting-text) {
white-space: normal;
}
ha-row-item {
--ha-row-item-padding-inline: 0;
}
@@ -127,7 +127,8 @@ class DialogMatterAddDevice extends LitElement {
private get _waitForCommissioningFinish(): boolean {
// When the app supports reporting Matter commissioning status, defer
// advancing past the spinner until we receive matter/commission/finish.
// advancing past the spinner of its own flow until we receive
// matter/commission/finish.
return !!this.hass.auth.external?.config.hasMatterStatusReport;
}
@@ -135,7 +136,13 @@ class DialogMatterAddDevice extends LitElement {
if (!this._newDevice) {
return;
}
if (this._waitForCommissioningFinish && !this._commissioningFinished) {
// Only the app's own flow, started from the "new" step, reports back.
// The other steps commission through core or another app.
if (
this._step === "new" &&
this._waitForCommissioningFinish &&
!this._commissioningFinished
) {
return;
}
this._step = "device_added";
@@ -149,6 +156,8 @@ class DialogMatterAddDevice extends LitElement {
if (this._newDevice) {
// Device already showed up in the registry — ignore the failure signal
// and let the user finish the rename flow.
this._commissioningFinished = true;
this._maybeShowDeviceAdded();
return;
}
showToast(this, {
@@ -178,6 +178,7 @@ export class MatterConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
wrap-headline
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
@@ -190,6 +191,7 @@ export class MatterConfigDashboard extends LitElement {
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
wrap-headline
href=${`/config/entities?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
@@ -216,7 +218,11 @@ export class MatterConfigDashboard extends LitElement {
"ui.panel.config.matter.panel.options_title"
)}
>
<ha-list-item-button href="/config/matter/options">
<ha-list-item-button
wrap-headline
wrap-supporting-text
href="/config/matter/options"
>
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
@@ -232,7 +238,11 @@ export class MatterConfigDashboard extends LitElement {
</ha-list-item-button>
${
isComponentLoaded(this.hass.config, "thread")
? html`<ha-list-item-button href="/config/thread">
? html`<ha-list-item-button
wrap-headline
wrap-supporting-text
href="/config/thread"
>
<ha-svg-icon
slot="start"
.path=${THREAD_ICON}
@@ -299,11 +309,6 @@ export class MatterConfigDashboard extends LitElement {
max-width: var(--ha-content-width-narrow);
}
ha-list-item-button::part(headline),
ha-list-item-button::part(supporting-text) {
white-space: normal;
}
.nav-card {
overflow: hidden;
}
@@ -56,7 +56,7 @@ class MatterOptionsPage extends LitElement {
}
${
canCommissionMatterExternal(this.hass)
? html`<ha-row-item>
? html`<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning"
@@ -80,7 +80,7 @@ class MatterOptionsPage extends LitElement {
</ha-row-item>`
: nothing
}
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device"
@@ -102,7 +102,7 @@ class MatterOptionsPage extends LitElement {
)}
</ha-button>
</ha-row-item>
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device"
@@ -124,7 +124,7 @@ class MatterOptionsPage extends LitElement {
)}
</ha-button>
</ha-row-item>
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials"
@@ -146,7 +146,7 @@ class MatterOptionsPage extends LitElement {
)}
</ha-button>
</ha-row-item>
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials"
@@ -320,11 +320,6 @@ class MatterOptionsPage extends LitElement {
max-width: var(--ha-content-width-narrow);
margin: auto;
}
ha-row-item::part(headline),
ha-row-item::part(supporting-text) {
white-space: normal;
}
`,
];
}
@@ -221,6 +221,7 @@ export class MQTTConfigPanel extends LitElement {
)}
>
<ha-list-item-button
wrap-headline
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
@@ -232,6 +233,7 @@ export class MQTTConfigPanel extends LitElement {
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
wrap-headline
href=${`/config/entities?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
@@ -253,7 +255,11 @@ export class MQTTConfigPanel extends LitElement {
<ha-card class="nav-card">
<div class="card-content">
<ha-list-base>
<ha-list-item-button @click=${this._openOptionFlow}>
<ha-list-item-button
wrap-headline
wrap-supporting-text
@click=${this._openOptionFlow}
>
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.option_flow")}
@@ -265,7 +271,11 @@ export class MQTTConfigPanel extends LitElement {
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button @click=${this._openConfigFlow}>
<ha-list-item-button
wrap-headline
wrap-supporting-text
@click=${this._openConfigFlow}
>
<ha-svg-icon slot="start" .path=${mdiMqttLogo}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.config_flow")}
@@ -440,10 +450,6 @@ export class MQTTConfigPanel extends LitElement {
margin: 0 auto var(--ha-space-4);
max-width: var(--ha-content-width-narrow);
}
ha-list-item-button::part(headline),
ha-list-item-button::part(supporting-text) {
white-space: normal;
}
ha-select {
width: 96px;
margin: 0 8px;
@@ -159,7 +159,7 @@ class ThreadNetworkInfoPage extends LitElement {
if (value === null) {
return nothing;
}
return html`<ha-list-item-base>
return html`<ha-list-item-base wrap-supporting-text>
<span slot="headline">${label}</span>
<span slot="supporting-text">${value}</span>
</ha-list-item-base>`;
@@ -180,7 +180,6 @@ class ThreadNetworkInfoPage extends LitElement {
ha-list-item-base::part(supporting-text) {
font-size: var(--ha-font-size-m);
white-space: normal;
overflow-wrap: anywhere;
}
`,
@@ -237,6 +237,7 @@ class ZHAConfigDashboard extends LitElement {
)}
>
<ha-list-item-button
wrap-headline
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
@@ -249,6 +250,7 @@ class ZHAConfigDashboard extends LitElement {
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
wrap-headline
href=${`/config/entities?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
@@ -260,7 +262,7 @@ class ZHAConfigDashboard extends LitElement {
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button href="/config/zha/groups">
<ha-list-item-button wrap-headline href="/config/zha/groups">
<ha-svg-icon
slot="start"
.path=${mdiFolderMultipleOutline}
@@ -307,7 +309,11 @@ class ZHAConfigDashboard extends LitElement {
"ui.panel.config.zha.configuration_page.options_title"
)}
>
<ha-list-item-button href="/config/zha/options">
<ha-list-item-button
wrap-headline
wrap-supporting-text
href="/config/zha/options"
>
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
@@ -321,7 +327,11 @@ class ZHAConfigDashboard extends LitElement {
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button href="/config/zha/network-info">
<ha-list-item-button
wrap-headline
wrap-supporting-text
href="/config/zha/network-info"
>
<ha-svg-icon
slot="start"
.path=${mdiInformationOutline}
@@ -340,7 +350,10 @@ class ZHAConfigDashboard extends LitElement {
</ha-list-item-button>
${dynamicSections.map(
(section) => html`
<ha-list-item-button href=${`/config/zha/section/${section}`}>
<ha-list-item-button
wrap-headline
href=${`/config/zha/section/${section}`}
>
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${
@@ -364,7 +377,7 @@ class ZHAConfigDashboard extends LitElement {
<ha-card class="nav-card">
<div class="card-content">
<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup"
@@ -389,7 +402,7 @@ class ZHAConfigDashboard extends LitElement {
)}
</ha-progress-button>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.migrate_radio"
@@ -523,13 +536,6 @@ class ZHAConfigDashboard extends LitElement {
max-width: var(--ha-content-width-narrow);
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text),
ha-list-item-button::part(headline),
ha-list-item-button::part(supporting-text) {
white-space: normal;
}
.nav-card .card-header {
display: flex;
align-items: center;
@@ -54,7 +54,7 @@ class ZHANetworkInfoPage extends LitElement {
${
this._networkSettings
? html`<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.channel_label"
@@ -74,7 +74,7 @@ class ZHANetworkInfoPage extends LitElement {
@click=${this._showChannelMigrationDialog}
></ha-icon-button>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline">PAN ID</span>
<span slot="supporting-text"
>${
@@ -82,7 +82,7 @@ class ZHANetworkInfoPage extends LitElement {
}</span
>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline">Extended PAN ID</span>
<span slot="supporting-text"
>${
@@ -91,13 +91,13 @@ class ZHANetworkInfoPage extends LitElement {
}</span
>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline">Coordinator IEEE</span>
<span slot="supporting-text"
>${this._networkSettings.settings.node_info.ieee}</span
>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.radio_type"
@@ -107,7 +107,7 @@ class ZHANetworkInfoPage extends LitElement {
>${this._networkSettings.radio_type}</span
>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.serial_port"
@@ -121,7 +121,7 @@ class ZHANetworkInfoPage extends LitElement {
this._networkSettings.device.baudrate &&
!this._networkSettings.device.path.startsWith("socket://")
? html`
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.baudrate"
@@ -177,7 +177,6 @@ class ZHANetworkInfoPage extends LitElement {
ha-list-item-base::part(supporting-text) {
font-size: var(--ha-font-size-m);
white-space: normal;
}
`,
];
@@ -116,7 +116,7 @@ class ZHAOptionsPage extends LitElement {
this._configuration
? html`
<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_identify_on_join_label"
@@ -136,7 +136,7 @@ class ZHAOptionsPage extends LitElement {
@change=${this._enableIdentifyOnJoinChanged}
></ha-switch>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.default_light_transition_label"
@@ -161,7 +161,7 @@ class ZHAOptionsPage extends LitElement {
<span slot="end">s</span>
</ha-input>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enhanced_light_transition_label"
@@ -181,7 +181,7 @@ class ZHAOptionsPage extends LitElement {
@change=${this._enhancedLightTransitionChanged}
></ha-switch>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.light_transitioning_flag_label"
@@ -201,7 +201,7 @@ class ZHAOptionsPage extends LitElement {
@change=${this._lightTransitioningFlagChanged}
></ha-switch>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.group_members_assume_state_label"
@@ -221,7 +221,7 @@ class ZHAOptionsPage extends LitElement {
@change=${this._groupMembersAssumeStateChanged}
></ha-switch>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_mains_label"
@@ -265,7 +265,7 @@ class ZHAOptionsPage extends LitElement {
`
: nothing
}
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_battery_label"
@@ -309,7 +309,7 @@ class ZHAOptionsPage extends LitElement {
`
: nothing
}
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_mains_startup_polling_label"
@@ -463,11 +463,6 @@ class ZHAOptionsPage extends LitElement {
margin: auto;
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
ha-select,
ha-input {
min-width: 210px;
@@ -78,7 +78,7 @@ class ZWaveJSControllerStatistics extends SubscribeMixin(LitElement) {
private _renderStat(key: string) {
return html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
`ui.panel.config.zwave_js.dashboard.statistics.${key}.label`
@@ -104,11 +104,6 @@ class ZWaveJSControllerStatistics extends SubscribeMixin(LitElement) {
max-width: var(--ha-content-width-narrow);
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
span[slot="end"] {
font-size: 0.95em;
color: var(--primary-text-color);
@@ -50,7 +50,7 @@ class ZWaveJSNetworkInfoPage extends LitElement {
${
this._network
? html`<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.home_id"
@@ -66,7 +66,7 @@ class ZWaveJSNetworkInfoPage extends LitElement {
}
</span>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.driver_version"
@@ -76,7 +76,7 @@ class ZWaveJSNetworkInfoPage extends LitElement {
${this._network.client.driver_version}
</span>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.server_version"
@@ -86,7 +86,7 @@ class ZWaveJSNetworkInfoPage extends LitElement {
${this._network.client.server_version}
</span>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.server_url"
@@ -120,7 +120,6 @@ class ZWaveJSNetworkInfoPage extends LitElement {
ha-list-item-base::part(supporting-text) {
font-size: var(--ha-font-size-m);
white-space: normal;
}
`,
];
@@ -62,7 +62,7 @@ class ZWaveJSOptionsPage extends LitElement {
${
this._network
? html`<ha-list-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.common.rebuild_network_routes"
@@ -85,7 +85,7 @@ class ZWaveJSOptionsPage extends LitElement {
)}
</ha-button>
</ha-list-item-base>
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.common.remove_node"
@@ -149,11 +149,6 @@ class ZWaveJSOptionsPage extends LitElement {
max-width: var(--ha-content-width-narrow);
margin: auto;
}
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`,
];
}
@@ -64,7 +64,11 @@ class HaConfigSectionNetwork extends LitElement {
>
${NETWORK_BROWSERS.map(
(domain) => html`
<ha-list-item-button href="/config/${domain}">
<ha-list-item-button
wrap-headline
wrap-supporting-text
href="/config/${domain}"
>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.network.discovery.${domain}`
@@ -104,10 +108,6 @@ class HaConfigSectionNetwork extends LitElement {
display: block;
margin-bottom: 24px;
}
.discovery-card ha-list-item-button::part(headline),
.discovery-card ha-list-item-button::part(supporting-text) {
white-space: normal;
}
`;
}
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/item/ha-list-item-base";
import "../../../../components/ha-switch";
@@ -14,7 +14,7 @@ class HaDebugConnectionRow extends LitElement {
protected render(): TemplateResult {
return html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.tools.tabs.debug.debug_connection.title"
@@ -43,13 +43,6 @@ class HaDebugConnectionRow extends LitElement {
setDebugConnection(debugConnection);
storeState(this.hass);
}
static styles = css`
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`;
}
declare global {
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { storage } from "../../../../common/decorators/storage";
import { setViewTransitionDisabled } from "../../../../common/util/view-transition";
@@ -17,7 +17,7 @@ class HaDebugDisableViewTransitionRow extends LitElement {
protected render(): TemplateResult {
return html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.tools.tabs.debug.disable_view_transition.title"
@@ -41,13 +41,6 @@ class HaDebugDisableViewTransitionRow extends LitElement {
this._disabled = (ev.target as HaSwitch).checked;
setViewTransitionDisabled(this._disabled);
}
static styles = css`
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`;
}
declare global {
@@ -1,4 +1,4 @@
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { storage } from "../../../../common/decorators/storage";
@@ -19,7 +19,7 @@ class HaDebugSafeAreaToolRow extends LitElement {
protected render() {
return html`
<ha-list-item-base>
<ha-list-item-base wrap-headline wrap-supporting-text>
<span slot="headline"
>${this._localize(
"ui.panel.config.tools.tabs.debug.safe_area_tool.title"
@@ -42,13 +42,6 @@ class HaDebugSafeAreaToolRow extends LitElement {
private _checkedChanged(ev: Event) {
this._enabled = (ev.target as HaSwitch).checked;
}
static styles = css`
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`;
}
declare global {
@@ -212,7 +212,7 @@ export class AssistPref extends LitElement {
)}
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
</ha-button>
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass!.localize(
"ui.panel.config.voice_assistants.expose.expose_new_entities"
@@ -415,10 +415,6 @@ export class AssistPref extends LitElement {
a {
color: var(--primary-color);
}
ha-row-item::part(headline),
ha-row-item::part(supporting-text) {
white-space: normal;
}
.header-actions {
position: absolute;
right: 0px;
@@ -139,7 +139,7 @@ export class CloudAlexaPref extends LitElement {
</ha-alert>`
: nothing
}
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.expose_new_entities"
@@ -160,7 +160,7 @@ export class CloudAlexaPref extends LitElement {
${
alexa_registered
? html`
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.enable_state_reporting"
@@ -265,10 +265,6 @@ export class CloudAlexaPref extends LitElement {
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-row-item::part(headline),
ha-row-item::part(supporting-text) {
white-space: normal;
}
.header-actions {
position: absolute;
right: 24px;
@@ -138,7 +138,7 @@ export class CloudGooglePref extends LitElement {
`
: nothing
}
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass!.localize(
"ui.panel.config.cloud.account.google.expose_new_entities"
@@ -183,7 +183,7 @@ export class CloudGooglePref extends LitElement {
`
: nothing
}
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass!.localize(
"ui.panel.config.cloud.account.google.enable_state_reporting"
@@ -201,7 +201,7 @@ export class CloudGooglePref extends LitElement {
></ha-switch>
</ha-row-item>
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.google.security_devices"
@@ -347,10 +347,6 @@ export class CloudGooglePref extends LitElement {
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-row-item::part(headline),
ha-row-item::part(supporting-text) {
white-space: normal;
}
ha-input {
width: 250px;
margin-top: 8px;
@@ -193,7 +193,7 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
const exposedToAssist = this.exposed.conversation;
return html`
<ha-row-item>
<ha-row-item wrap-headline>
<h3 slot="headline">
${this.hass.localize("ui.dialogs.voice-settings.expose_header")}
</h3>
@@ -227,7 +227,7 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
this._googleEntity?.might_2fa;
return html`
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<voice-assistant-brand-icon
slot="start"
.voiceAssistantId=${key}
@@ -324,7 +324,7 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
)}
</ha-alert>`
: html`
<ha-row-item>
<ha-row-item wrap-headline wrap-supporting-text>
<span slot="headline">${this._computedName(this.entry)}</span>
<span slot="supporting-text">
${this.hass.localize(
@@ -457,10 +457,6 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-row-item::part(headline),
ha-row-item::part(supporting-text) {
white-space: normal;
}
img {
height: 32px;
width: 32px;
@@ -118,7 +118,7 @@ export class HuiCardLayoutEditor extends LitElement {
`
: nothing
}
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.layout.recommended_size"
@@ -151,7 +151,7 @@ export class HuiCardLayoutEditor extends LitElement {
.columnMax=${gridOptions.max_columns}
.step=${this._preciseMode ? 1 : GRID_COLUMN_MULTIPLIER}
></ha-grid-size-picker>
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.layout.fit_content"
@@ -169,7 +169,7 @@ export class HuiCardLayoutEditor extends LitElement {
name="auto-height"
></ha-switch>
</ha-row-item>
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline"
>${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.layout.full_width"
@@ -425,9 +425,6 @@ export class HuiCardLayoutEditor extends LitElement {
opacity: 0.5;
pointer-events: none;
}
ha-row-item::part(supporting-text) {
white-space: normal;
}
ha-grid-size-picker {
display: block;
margin: 16px auto;
+8
View File
@@ -15,6 +15,8 @@ import {
DataZoomComponent,
VisualMapComponent,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
} from "echarts/components";
// Features like Universal Transition and Label Layout
@@ -48,6 +50,8 @@ import type {
GridComponentOption,
DataZoomComponentOption,
VisualMapComponentOption,
MarkAreaComponentOption,
BrushComponentOption,
} from "echarts/components";
import type { ComposeOption } from "echarts/core";
@@ -62,6 +66,8 @@ export type ECOption = ComposeOption<
| GridComponentOption
| DataZoomComponentOption
| VisualMapComponentOption
| MarkAreaComponentOption
| BrushComponentOption
| SankeySeriesOption
| GraphSeriesOption
| SunburstSeriesOption
@@ -91,6 +97,8 @@ echarts.use([
UniversalTransition,
CanvasRenderer,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
]);
export { LinearGradient };
+43 -3
View File
@@ -1106,6 +1106,46 @@
"failed_create_floor": "Failed to create floor.",
"unknown": "Unknown floor selected"
},
"rooms": {
"keywords": {
"living_room": "living room|lounge|family room|sitting room|den|living",
"bedroom": "bedroom|guest room|master bedroom|master suite|bed room",
"kids_room": "kids room|kids' room|kid's room|children's room|nursery|playroom|baby room",
"kitchen": "kitchen|kitchenette",
"dining_room": "dining room|dining area|dining",
"bathroom": "bathroom|bath|shower room|ensuite|washroom",
"toilet": "toilet|wc|restroom|powder room|loo|lavatory",
"office": "office|study|home office|workspace|workroom",
"laundry_room": "laundry|laundry room",
"hallway": "hallway|hall|corridor|entrance|entryway|foyer|mudroom",
"stairs": "stairs|staircase|stairway|stairwell|landing",
"garage": "garage|carport",
"garden": "garden|yard|backyard|front yard|lawn|greenhouse",
"terrace": "terrace|patio|balcony|deck|porch|veranda",
"basement": "basement|cellar",
"attic": "attic|loft",
"storage": "storage|storage room|pantry|closet|shed|storeroom",
"utility_room": "utility room|boiler room|technical room|plant room",
"pool": "pool|swimming pool|pool house",
"media_room": "media room|home cinema|home theater|home theatre|cinema room|movie room|tv room",
"game_room": "game room|games room|gaming room|recreation room|rec room|billiard room",
"music_room": "music room|music studio|piano room|practice room",
"library": "library|reading room|book room",
"bar": "bar|home bar|pub|wine room|wine cellar",
"dressing_room": "dressing room|walk-in closet|walk-in wardrobe",
"workshop": "workshop|tool room|craft room|hobby room",
"server_room": "server room|network room|rack room",
"safe_room": "safe room|panic room|shelter|storm shelter|bunker",
"prayer_room": "prayer room|chapel|meditation room",
"sauna": "sauna|steam room",
"outdoor_kitchen": "outdoor kitchen|summer kitchen|barbecue area|bbq area|grill area",
"driveway": "driveway|parking|parking space"
}
},
"icon_picker": {
"suggested": "Suggested",
"all_icons": "All icons"
},
"area-filter": {
"title": "Areas",
"no_areas": "No areas",
@@ -3245,13 +3285,13 @@
},
"mcp": {
"header": "Model Context Protocol",
"description": "Add one of the URLs below to your AI agent. See the {documentation_link} for agent-specific instructions.",
"description_disabled": "See the {documentation_link} to learn how to connect your AI agent to Home Assistant.",
"description": "Add one of the URLs below to your AI app. See the {documentation_link} for app-specific instructions.",
"description_disabled": "See the {documentation_link} to learn how to connect your AI app to Home Assistant.",
"documentation": "documentation",
"enable": "[%key:ui::common::enable%]",
"disable": "[%key:ui::common::disable%]",
"disable_confirm_title": "Disable the Model Context Protocol server?",
"disable_confirm_text": "AI agents connected to the server will lose access to Home Assistant.",
"disable_confirm_text": "AI apps connected to the server will lose access to Home Assistant.",
"your_api_header": "Main URL",
"apis_header": "Individual URLs",
"copy_url": "Copy URL",
@@ -1,93 +0,0 @@
import { describe, expect, it } from "vitest";
import {
limitIceServers,
limitIceServersToPath,
} from "../../../src/common/webrtc/limit-ice-servers";
// Redundant URLs cost seconds of connection time, while dropping the wrong
// one (the UDP or the firewall friendly TLS relay) breaks remote access.
describe("limitIceServers", () => {
it("works", () => {
expect(limitIceServers(undefined)).toBeUndefined();
expect(
limitIceServers([
{ urls: "stun:stun.example.com:3478" },
{ urls: ["stun:a.example.com:3478", "stun:a.example.com:80"] },
{
urls: [
"turn:t.example.com:3478?transport=udp",
"turn:t.example.com:3478?transport=tcp",
"turns:t.example.com:5349?transport=tcp",
"turn:t.example.com:443?transport=udp",
"turn:t.example.com:80?transport=tcp",
"turns:t.example.com:443?transport=tcp",
],
username: "user",
credential: "secret",
},
{
urls: [
"turn:u.example.com:80?transport=tcp",
"turn:u.example.com:443?transport=tcp",
],
},
// Different hosts are different network paths, all are kept
{
urls: [
"stun:v.example.com",
"turn:v.example.com:3478",
"turn:v.example.com:3479",
"turn:w.example.com:3478",
],
},
])
).toEqual([
{ urls: "stun:stun.example.com:3478" },
{ urls: ["stun:a.example.com:3478"] },
{
urls: [
"turn:t.example.com:3478?transport=udp",
"turns:t.example.com:443?transport=tcp",
],
username: "user",
credential: "secret",
},
{ urls: ["turn:u.example.com:80?transport=tcp"] },
{
urls: [
"stun:v.example.com",
"turn:v.example.com:3478",
"turn:w.example.com:3478",
],
},
]);
const iceServers = [
{ urls: ["stun:a.example.com:3478", "stun:a.example.com:80"] },
{
urls: ["turn:t.example.com", "turns:t.example.com:443"],
username: "user",
},
];
// A direct path keeps the UDP relay, which is also used to find the
// public address. A relayed one only needs its own relay.
expect(limitIceServersToPath(iceServers, {})).toEqual([
{ urls: ["stun:a.example.com:3478"] },
{ urls: ["turn:t.example.com"], username: "user" },
]);
// Browsers report the URL with the port and transport that were left out
expect(
limitIceServersToPath(iceServers, {
relayUrl: "turns:t.example.com:443?transport=tcp",
})
).toEqual([{ urls: ["turns:t.example.com:443"], username: "user" }]);
expect(
limitIceServersToPath(iceServers, {
relayUrl: "turn:t.example.com:3478?transport=udp",
})
).toEqual([{ urls: ["turn:t.example.com"], username: "user" }]);
expect(
limitIceServersToPath(iceServers, { relayUrl: "turn:other.example.com" })
).toBeUndefined();
});
});
+1 -3
View File
@@ -27,11 +27,9 @@ describe("selectCameraStreams", () => {
selectCameraStreams({ supportedTypes: ["hls"], hlsStreams: failed })
).toEqual([{ type: "mjpeg", visible: true }]);
// Loading: HLS is shown until WebRTC is ready, unless WebRTC goes first
// Loading: HLS is shown until WebRTC is ready
expect(select({})).toBe("hls (web_rtc)");
expect(select({ hlsStreams: ok })).toBe("hls (web_rtc)");
expect(select({ webRtcFirst: true })).toBe("web_rtc");
expect(select({ webRtcFirst: true, hlsStreams: ok })).toBe("hls (web_rtc)");
expect(select({ hlsStreams: failed })).toBe("web_rtc");
// WebRTC loaded: always preferred, also when only HLS has audio
+69 -1
View File
@@ -1,12 +1,80 @@
import { describe, expect, it } from "vitest";
import type { Connection } from "home-assistant-js-websocket";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatterCommissioningParameters } from "../../src/data/matter";
import {
matterShareRemainingSeconds,
matterShareTargetExternal,
watchForNewMatterDevice,
} from "../../src/data/matter";
import { subscribeDeviceRegistry } from "../../src/data/ws-device_registry";
import type { ExternalConfig } from "../../src/external_app/external_messaging";
import type { HomeAssistant } from "../../src/types";
// The device registry collection unsubscribes from its updates once no
// subscriber is left, so an extra unsubscribe silently breaks every other one.
describe("watchForNewMatterDevice", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const fakeRegistry = () => {
const devices: string[] = ["existing"];
const eventCallbacks = new Set<() => void>();
const connection = {
connected: true,
sendMessagePromise: vi.fn(async () =>
devices.map((id) => ({
id,
identifiers: [["matter", id]],
connections: [],
}))
),
subscribeEvents: vi.fn(async (callback: () => void) => {
eventCallbacks.add(callback);
return async () => {
eventCallbacks.delete(callback);
};
}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Connection;
return {
connection,
eventCallbacks,
addDevice: (id: string) => {
devices.push(id);
eventCallbacks.forEach((callback) => callback());
},
};
};
it("keeps the registry subscribed when the caller unsubscribes after a find", async () => {
const registry = fakeRegistry();
const hass = { connection: registry.connection } as HomeAssistant;
subscribeDeviceRegistry(registry.connection, () => undefined);
const found = vi.fn();
const unsub = watchForNewMatterDevice(hass, found);
await vi.advanceTimersByTimeAsync(10);
registry.addDevice("new");
await vi.advanceTimersByTimeAsync(600);
expect(found).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: "new" })
);
// The add device dialog unsubscribes again when it closes.
unsub();
// Past the collection's grace period for unsubscribing.
await vi.advanceTimersByTimeAsync(6000);
expect(registry.eventCallbacks.size).toBe(1);
});
});
const codesOnly: MatterCommissioningParameters = {
setup_pin_code: 20202021,
setup_manual_code: "34970112332",
+21 -19
View File
@@ -105,6 +105,7 @@ const run = ({
tag === "dialog"
? { showModal: dialog ? () => undefined : undefined }
: fakeElement(tag),
createTextNode: (text) => ({ tag: "#text", textContent: text }),
getElementById: (id) => (id === "ha-launch-screen-info-box" ? box : null),
};
// eslint-disable-next-line no-new-func
@@ -124,13 +125,15 @@ const run = ({
{ now: () => now },
(src) => started.push(src)
);
const buttons = Object.fromEntries(
(box.children.find((child) => child.tag === "div")?.children ?? []).map(
(button) => [button.textContent, button.listeners.click]
)
);
const actions =
box.children.find((child) => child.tag === "div")?.children ?? [];
const continueAnyway = actions.find((child) => child.tag === "button")
?.listeners.click;
const checkbox = actions
.find((child) => child.tag === "label")
?.children.find((child) => child.tag === "input");
const shown = box.children.map((child) => child.tag);
return { started, box, store, buttons, shown };
return { started, box, store, continueAnyway, checkbox, shown };
};
describe("index.html legacy browser check", () => {
@@ -144,11 +147,11 @@ describe("index.html legacy browser check", () => {
["BigInt", { bigInt: false, dialog: true }],
["native dialog", { bigInt: true, dialog: false }],
])("offers to continue without %s", (_, browser) => {
const { started, box, store, buttons } = run(browser);
const { started, box, store, continueAnyway, checkbox } = run(browser);
expect(started).toEqual([]);
expect(Object.keys(buttons)).toEqual(["Continue anyway"]);
expect(checkbox).toBeUndefined();
buttons["Continue anyway"]();
continueAnyway();
expect(started).toEqual([ENTRY]);
expect(store[ACCEPTED_KEY]).toBe(String(NOW));
// The message stays up while the app loads.
@@ -165,17 +168,16 @@ describe("index.html legacy browser check", () => {
const stored = String(NOW - 30 * DAY);
const reminder = run({ stored });
expect(reminder.started).toEqual([]);
expect(Object.keys(reminder.buttons)).toEqual([
"Continue anyway",
"Don't show again",
]);
expect(reminder.checkbox.type).toBe("checkbox");
// Unchecked: starts the next month.
const continued = run({ stored });
continued.buttons["Continue anyway"]();
continued.continueAnyway();
expect(continued.started).toEqual([ENTRY]);
expect(continued.store[ACCEPTED_KEY]).toBe(String(NOW));
reminder.buttons["Don't show again"]();
reminder.checkbox.checked = true;
reminder.continueAnyway();
expect(reminder.started).toEqual([ENTRY]);
expect(reminder.store[ACCEPTED_KEY]).toBe("always");
expect(run({ stored: "always", now: NOW + 3650 * DAY }).started).toEqual([
@@ -185,15 +187,15 @@ describe("index.html legacy browser check", () => {
// A tablet that lost its clock.
it("asks again when the clock is behind the stored time", () => {
const { started, buttons } = run({ stored: String(NOW + DAY) });
const { started, continueAnyway } = run({ stored: String(NOW + DAY) });
expect(started).toEqual([]);
expect(buttons["Continue anyway"]).toBeDefined();
expect(continueAnyway).toBeDefined();
});
it("still continues when storage is unavailable", () => {
const { started, buttons } = run({ storageThrows: true });
const { started, continueAnyway } = run({ storageThrows: true });
expect(started).toEqual([]);
buttons["Continue anyway"]();
continueAnyway();
expect(started).toEqual([ENTRY]);
});
});