Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld 8731a5682b Edit security favorites, alerts and visibility inline on the page
Give the security page a hidden_entities list like the other pages and replace its edit dialog with the inline edit mode. In edit mode every tile, favorites included, carries an eye, a star and, for alert-capable domains, a bell that cycles through none, warning and alert. Each tap saves at once. The Home dashboard summary badge, overview check and area views take the same hidden list.

Hidden, favorite and alert flags stay independent, so a hidden entity can still be a favorite or raise alerts, and the Home security summary stays while alerts are configured.

In edit mode the sidebar lists every configured alert, active or not, in its configured color, so a saved alert can always be changed or cleared there, including one whose entity no longer matches the page or was deleted. These entries pick the level with a three-way control instead of the cycling bell, so clearing is an explicit choice and an entry does not vanish on the way from alert back to warning. Changing a level keeps the alert in its place in the list. Favorites and alert entries show regardless of the hidden list, so they carry the star and the alert control but no eye. A deleted entity shows its ID in place of its card, so two stale entries stay distinguishable.

Toggling the eye only flips the wrapper's own flag and saves, like on the other pages. A star or bell tap moves the entity between the grid and the sidebar lists, so those regenerate the view, and the wrapper card's focus restoration covers the star and the alert controls and tells the grid tile and the sidebar entry of one entity apart, so the recreated wrapper focuses the control that fired. When a tap removes the entry itself, focus moves to the same control on the next entry in that list. When the list runs out, the last favorite hands focus to the first tile of the section that takes its place at the top of the page, and the last alert entry to the entity's own page tile if it has one; the strategy puts that fallback on the wrapper. When there is no fallback, or the recreated tile cannot take focus because the narrow layout shows the Activity tab, the card tells the panel and the panel focuses its exit-edit-mode button instead, so keyboard focus never drops to the page. Entering edit mode drops any request left over from an entry without either. ha-control-select gains a focus() that lands on its selected option for this. A failed save reloads the configuration and forces a rebuild so the wrappers show the stored state again.

The dialog, its alerts editor, the entity filter helper and the dialog test go away. Strategy tests pin down that a hidden entity keeps its favorite and alert entries, that stale favorites and alerts only show in edit mode, and the shape of the edit-mode wrappers.
2026-11-09 15:47:22 +01:00
Maarten Lakerveld a7a09af3be Resolve climate area sensors once and label them by slot
Build the visible area sensor map a single time with the hidden list applied, so the page and the Home badge share one rule and the separate showAreaSensor check goes away. Label the temperature and humidity tiles from the area slot they fill instead of reading device_class back off the state, which produced an empty name for sensors set through the API without a device class. Drop the trend-graph branch for non-area entities, which the climate filters never admit.
2026-11-09 15:47:22 +01:00
Maarten Lakerveld 50678a9039 Let users hide entities from the climate page
Give the climate page the same hidden_entities system data, config
loading and inline visibility edit mode the maintenance page has. The
climate resolver also covers the area temperature and humidity
sensors, which the page adds outside the entity filters, so they can
be hidden too and the Home climate badge skips them.
2026-11-09 15:47:22 +01:00
325 changed files with 7571 additions and 14187 deletions

No files matched your search

-8
View File
@@ -28,18 +28,10 @@ AI can be a helpful tool for writing code and documentation. However, due to the
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
## Design contributions
AI can be a helpful tool to explore ideas and build mockups and prototypes. But AI tends to give the most common answer, not the one that fits Home Assistant and its users. So we require a human who brings the point of view and owns the design decisions.
You should be able to explain the problem your design solves and why you made your choices. If a mockup or prototype was mostly generated with AI, say so when you share it. Check your work against the [design guidelines](https://contributors.home-assistant.io/design/), as AI tools often make up components, colors, or spacing that don't exist in Home Assistant. Design proposals that look like unreviewed AI output may be closed or hidden.
## Our use of AI
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
We also use AI to help keep the design system, the Figma kit, and the code in sync. The design decisions behind them are made by people.
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
## Enforcement
+42 -107
View File
@@ -157,99 +157,6 @@ 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 {
@@ -286,24 +193,52 @@ gulp.task("fetch-lokalise", async function () {
await Promise.all(
projects.map(async ([project, projectId]) => {
try {
const [files, drafts] = await Promise.all([
exportTranslations(lokaliseApi, projectId),
project === "frontend"
? fetchDrafts(lokaliseApi, projectId)
: undefined,
]);
if (drafts) {
acceptDrafts(files, drafts);
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());
await mkdirPromise;
await Promise.all(
Object.entries(files).map(([filename, content]) =>
fs.writeFile(path.join(inDir, project, filename), content, {
flag: "w",
encoding,
})
)
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 }
);
})
);
} catch (err) {
console.error(err);
@@ -44,21 +44,14 @@ export const lorawanFixtures: ConnectivityFixtures = {
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", {
config_flow: false,
integration_type: "service",
}),
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", {
integration_type: "hub",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
dependencies: ["lorawan"],
}),
],
configEntries: [
@@ -10,26 +10,19 @@ export const FRONIUS_ENTRY_ID = "mock-fronius";
export const RS485_PORT = "/dev/ttyUSB1";
export const modbusFixtures: ConnectivityFixtures = {
components: ["modbus", "flexit", "solaredge_modbus", "fronius"],
components: ["modbus"],
commands: ["modbus/"],
manifests: [
manifest("modbus", "Modbus", {
config_flow: false,
iot_class: "local_polling",
}),
manifest("flexit", "Flexit", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
manifest("solaredge_modbus", "SolarEdge Modbus", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
manifest("fronius", "Fronius", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
],
+2
View File
@@ -21,6 +21,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid",
cost_adjustment_day: 0,
},
{
type: "grid",
@@ -33,6 +34,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid_return",
cost_adjustment_day: 0,
},
{
type: "solar",
+1
View File
@@ -25,6 +25,7 @@ class DemoMoreInfo extends LitElement {
!computeShowNewMoreInfo(state)
? html`<state-card-content
.stateObj=${state}
.hass=${this.hass}
in-dialog
></state-card-content>`
: nothing
+5 -24
View File
@@ -117,17 +117,11 @@ 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. |
| `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.
| 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. |
**CSS parts**
@@ -171,18 +165,6 @@ Interactive row. Renders an inner `<a>` when `href` is set, otherwise a
roving tabindex, the host is the tab stop and the inner element carries
`tabindex="-1"`.
Only the headline and supporting text are inside the `<a>` or `<button>`; its
hit area is stretched over the whole row. The `start` and `end` slots sit next
to it, so controls placed there, like an `ha-icon-button` or `ha-dropdown`,
stay separate controls for screen readers. Slotted elements are raised above
the hit area so they can be clicked, except icons, badges, `span`s and empty
`div`s (dividers), which let clicks and hover through to the row. A focusable
element (with `tabindex`) is always raised. To keep another element clickable
on its own, like a drag handle, give it `z-index: 1` in the consumer's styles.
Subclasses customize the row by overriding `_renderStart()`,
`_renderContent()` and `_renderEnd()`. `_renderInner()` is not used.
**Attributes**
- `href` (String) — when set, renders an `<a>` instead of a `<button>`.
@@ -192,7 +174,6 @@ Subclasses customize the row by overriding `_renderStart()`,
**CSS parts**
- `control` — the inner `<a>` or `<button>`.
- `ripple` — the ripple effect element.
### `<ha-list-item-option>`
-80
View File
@@ -2,12 +2,10 @@ import {
mdiAccount,
mdiChevronRight,
mdiCog,
mdiDelete,
mdiHome,
mdiInformationOutline,
mdiMapMarker,
mdiOpenInNew,
mdiPencil,
mdiViewDashboard,
mdiWifi,
} from "@mdi/js";
@@ -15,8 +13,6 @@ import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-icon-next";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-base";
import "../../../../src/components/item/ha-list-item-button";
@@ -81,8 +77,6 @@ const treeKey = (group: TreeGroup, child: TreeChild) =>
export class DemoHaList extends LitElement {
@state() private _buttonClicks = 0;
@state() private _lastAction = "none";
@state() private _single: number | Set<number> = -1;
@state() private _multiLine: number | Set<number> = new Set();
@@ -191,20 +185,6 @@ 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>
@@ -240,51 +220,6 @@ export class DemoHaList extends LitElement {
</ha-list-base>
</ha-card>
<ha-card header="Row with actions in the end slot">
<p class="card-text">
Last action: <code>${this._lastAction}</code>. The action buttons are
separate controls; icons and the divider activate the row.
</p>
<ha-list-base aria-label="Rows with actions">
<ha-list-item-button data-action="row" @click=${this._onActionClick}>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<span slot="headline">Button row</span>
<span slot="supporting-text">Click the row or an action</span>
<ha-icon-next slot="end"></ha-icon-next>
<div slot="end" class="vertical-divider"></div>
<ha-icon-button
slot="end"
data-action="edit"
.path=${mdiPencil}
label="Edit"
@click=${this._onActionClick}
></ha-icon-button>
<ha-icon-button
slot="end"
data-action="delete"
.path=${mdiDelete}
label="Delete"
@click=${this._onActionClick}
></ha-icon-button>
</ha-list-item-button>
<ha-list-item-button href="#components/ha-list">
<ha-svg-icon slot="start" .path=${mdiMapMarker}></ha-svg-icon>
<span slot="headline">Link row</span>
<span slot="supporting-text"
>Cmd/Ctrl-click on the icon or chevron opens a new tab</span
>
<ha-icon-button
slot="end"
data-action="link-edit"
.path=${mdiPencil}
label="Edit"
@click=${this._onActionClick}
></ha-icon-button>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
</ha-list-base>
</ha-card>
<h2>ha-list-selectable + ha-list-item-option</h2>
<p>
Selectable list (<code>role="listbox"</code>). Items must be
@@ -493,12 +428,6 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
return value instanceof Set ? [...value] : value;
}
private _onActionClick = (ev: MouseEvent) => {
ev.stopPropagation();
this._lastAction =
(ev.currentTarget as HTMLElement).dataset.action ?? "none";
};
private _onButtonClick = () => {
this._buttonClicks++;
};
@@ -613,15 +542,6 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
ha-card {
max-width: 560px;
}
.card-text {
margin: 0;
padding: 0 var(--ha-space-4) var(--ha-space-2);
}
.vertical-divider {
align-self: stretch;
width: 1px;
background: var(--divider-color);
}
pre {
padding: var(--ha-space-4);
background: var(--secondary-background-color);
+20 -20
View File
@@ -54,15 +54,15 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "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.2",
"@formatjs/intl-locale": "5.4.3",
"@formatjs/intl-numberformat": "10.0.3",
"@formatjs/intl-pluralrules": "7.0.3",
"@formatjs/intl-relativetimeformat": "13.0.2",
"@formatjs/intl-datetimeformat": "8.0.2",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.3",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.2",
"@formatjs/intl-pluralrules": "7.0.2",
"@formatjs/intl-relativetimeformat": "13.0.1",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
"@lezer/highlight": "1.2.5",
@@ -77,18 +77,18 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.24",
"@tsparticles/engine": "4.5.3",
"@tsparticles/preset-links": "4.5.3",
"@versatiles/style": "9.0.0",
"@tsparticles/engine": "4.5.2",
"@tsparticles/preset-links": "4.5.2",
"@versatiles/style": "8.1.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.204.0",
"@vvo/tzdb": "6.202.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.3",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.51.0",
"cropperjs": "2.3.1",
"cropperjs": "1.7.0",
"culori": "4.0.2",
"date-fns": "4.4.0",
"decimal.js": "10.6.0",
@@ -102,13 +102,13 @@
"hls.js": "1.7.4",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.5",
"intl-messageformat": "12.1.4",
"js-yaml": "5.5.0",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.17.0",
"maplibre-gl": "6.16.0",
"marked": "18.1.3",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
@@ -147,7 +147,7 @@
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@home-assistant/components": "workspace:*",
"@html-eslint/eslint-plugin": "0.68.0",
"@html-eslint/eslint-plugin": "0.67.0",
"@lokalise/node-api": "17.0.1",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
@@ -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.1",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-unused-imports": "4.4.1",
@@ -208,14 +208,14 @@
"pinst": "3.0.0",
"prettier": "3.9.10",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.7",
"serve": "14.2.6",
"sinon": "22.1.0",
"tar": "7.5.22",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1",
"vite": "8.3.7",
"vite": "8.3.6",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.1.0",
"webpack-stats-plugin": "1.1.3",
+382 -430
View File
File diff suppressed because it is too large. Load diff
-29
View File
@@ -1,29 +0,0 @@
const INTERACTIVE_SELECTOR = [
"a[href]",
"button",
"input",
"select",
"textarea",
"summary",
"label",
"[contenteditable]:not([contenteditable='false'])",
"[tabindex]:not([tabindex='-1'])",
"[role='button']",
"[role='link']",
"[role='checkbox']",
"[role='radio']",
"[role='switch']",
"[role='slider']",
"[role='tab']",
"[role='menuitem']",
"[role='option']",
"[role='combobox']",
"[role='textbox']",
].join(",");
/**
* Whether `node` is an element that handles clicks or keyboard input on its
* own, such as a button, link, form control or a focusable widget.
*/
export const isInteractiveElement = (node: EventTarget): boolean =>
node instanceof Element && node.matches(INTERACTIVE_SELECTOR);
+5 -5
View File
@@ -1,12 +1,12 @@
import type { HassServices } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
import { getToggleAction } from "./get_toggle_action";
export const canToggleDomain = (services: HassServices, domain: string) => {
const domainServices = services[domain];
if (!domainServices) {
export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
const services = hass.services[domain];
if (!services) {
return false;
}
const actionOn = getToggleAction(domain, true);
const actionOff = getToggleAction(domain, false);
return actionOn in domainServices && actionOff in domainServices;
return actionOn in services && actionOff in services;
};
+6 -13
View File
@@ -1,30 +1,23 @@
import type {
HassEntities,
HassEntity,
HassServices,
} from "home-assistant-js-websocket";
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
import { canToggleDomain } from "./can_toggle_domain";
import { computeStateDomain } from "./compute_state_domain";
import { supportsFeature } from "./supports-feature";
import { SPECIAL_TOGGLE_ACTIONS } from "./get_toggle_action";
export const canToggleState = (
states: HassEntities,
services: HassServices,
stateObj: HassEntity
) => {
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
const domain = computeStateDomain(stateObj);
if (domain === "group") {
if (
stateObj.attributes?.entity_id?.some((entity) => {
const entityStateObj = states[entity];
const entityStateObj = hass.states[entity];
if (!entityStateObj) {
return false;
}
const entityDomain = computeStateDomain(entityStateObj);
return canToggleDomain(services, entityDomain);
return canToggleDomain(hass, entityDomain);
})
) {
return stateObj.state === "on" || stateObj.state === "off";
@@ -42,5 +35,5 @@ export const canToggleState = (
);
}
return canToggleDomain(services, domain);
return canToggleDomain(hass, domain);
};
+4 -11
View File
@@ -1,18 +1,11 @@
import type {
HassEntities,
HassEntity,
HassServices,
} from "home-assistant-js-websocket";
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { DOMAINS_WITH_CARD } from "../const";
import { canToggleState } from "./can_toggle_state";
import { computeStateDomain } from "./compute_state_domain";
export const stateCardType = (
states: HassEntities,
services: HassServices,
stateObj: HassEntity
) => {
export const stateCardType = (hass: HomeAssistant, stateObj: HassEntity) => {
if (stateObj.state === UNAVAILABLE) {
return "display";
}
@@ -23,7 +16,7 @@ export const stateCardType = (
return domain;
}
if (
canToggleState(states, services, stateObj) &&
canToggleState(hass, stateObj) &&
stateObj.attributes.control !== "hidden"
) {
return "toggle";
@@ -1,33 +0,0 @@
import type { LocalizeFunc } from "./localize";
const YAML_ERROR_REASON_KEYS = new Map(
Object.entries({
"duplicated mapping key": "duplicated_mapping_key",
"bad indentation of a mapping entry": "bad_mapping_indentation",
"bad indentation of a sequence entry": "bad_sequence_indentation",
"deficient indentation": "deficient_indentation",
"tab characters must not be used in indentation": "tab_indentation",
"can not read a block mapping entry; a multiline key may not be an implicit key":
"multiline_implicit_key",
"a whitespace character is expected after the key-value separator within a block mapping":
"missing_key_value_space",
"expected ':' after a mapping key": "missing_colon",
"missed comma between flow collection entries": "missing_comma",
"unexpected end of the stream within a flow collection":
"unterminated_flow_collection",
"unexpected end of the stream within a single quoted scalar":
"unterminated_single_quote",
"unexpected end of the stream within a double quoted scalar":
"unterminated_double_quote",
"end of the stream or a document separator is expected":
"unexpected_content",
} as const)
);
export const localizeYamlErrorReason = (
localize: LocalizeFunc,
reason: string | undefined
): string | undefined => {
const key = reason ? YAML_ERROR_REASON_KEYS.get(reason) : undefined;
return key ? localize(`ui.components.yaml-editor.reasons.${key}`) : reason;
};
-7
View File
@@ -1,7 +0,0 @@
/** Where a value is in a config, e.g. `["actions", 0, "then", 1]`. */
export type ConfigPath = (string | number)[];
export const stringifyPath = (path: ConfigPath): string => path.join("/");
export const pathEquals = (a: ConfigPath, b: ConfigPath): boolean =>
a.length === b.length && a.every((segment, index) => segment === b[index]);
-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);
};
+12 -56
View File
@@ -30,40 +30,10 @@ export class HaAutomationRow extends LitElement {
@property({ type: Boolean, reflect: true })
public dim = false;
/** Makes the row a link, with the icons slot outside of it */
@property() public href?: string;
@query(".row")
private _rowElement?: HTMLDivElement;
@query(".link")
private _linkElement?: HTMLAnchorElement;
protected render(): TemplateResult {
const content = html`
<div class="leading-icon-wrapper">
<slot name="leading-icon"></slot>
</div>
<div class="header">
<slot name="header"></slot>
<slot name="event"></slot>
</div>
`;
const icons = html`
<div class="icons">
<slot name="icons"></slot>
</div>
`;
if (this.href) {
return html`
<div class="row has-link">
<a class="link" href=${this.href}>${content}</a>
${icons}
</div>
`;
}
return html`
<div
class="row"
@@ -83,7 +53,17 @@ export class HaAutomationRow extends LitElement {
`
: nothing
}
${content} ${icons}
<div class="leading-icon-wrapper">
<slot name="leading-icon"></slot>
</div>
<div class="header">
<slot name="header"></slot>
<slot name="event"></slot>
</div>
<div class="icons">
<slot name="icons"></slot>
</div>
</div>
`;
}
@@ -139,7 +119,7 @@ export class HaAutomationRow extends LitElement {
public focus() {
requestAnimationFrame(() => {
(this._linkElement ?? this._rowElement)?.focus();
this._rowElement?.focus();
});
}
@@ -164,30 +144,6 @@ export class HaAutomationRow extends LitElement {
outline: var(--wa-focus-ring);
outline-offset: -2px;
}
.row.has-link {
padding-left: 0;
padding-inline-start: 0;
cursor: default;
}
.link {
display: flex;
align-items: flex-start;
align-self: stretch;
flex: 1;
min-width: 0;
padding-left: var(--ha-space-3);
padding-inline-start: var(--ha-space-3);
padding-inline-end: initial;
color: inherit;
text-decoration: none;
cursor: pointer;
outline: none;
border-radius: inherit;
}
.link:focus-visible {
outline: var(--wa-focus-ring);
outline-offset: -2px;
}
.expand-button {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
+2 -6
View File
@@ -401,13 +401,9 @@ export class HaDataTable extends LitElement {
}
if (properties.has("data")) {
// Clean up checked rows that no longer exist or can no longer be selected
// Clean up checked rows that no longer exist in the data
if (this._checkedRows.length) {
const validIds = new Set(
this.data
.filter((row) => row.selectable !== false)
.map((row) => String(row[this.id]))
);
const validIds = new Set(this.data.map((row) => String(row[this.id])));
const validCheckedRows = this._checkedRows.filter((id) =>
validIds.has(id)
);
@@ -97,8 +97,6 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
@state() private _dateValue?: string;
@state() private _rangeIncomplete = false;
@state() private _timeValue = {
from: { hours: 0, minutes: 0 },
to: { hours: 23, minutes: 59 },
@@ -197,7 +195,6 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
.locale=${this._i18n.locale.language}
.focusedDate=${this._focusDate}
@focusday=${this._focusChanged}
@rangestart=${this._handleRangeStart}
@change=${this._handleChange}
show-outside-days
.firstDayOfWeek=${firstWeekdayIndex(this._i18n.locale)}
@@ -256,9 +253,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<ha-button appearance="plain" @click=${this._cancel}
>${this._i18n.localize("ui.common.cancel")}</ha-button
>
<ha-button
.disabled=${!this._dateValue || this._rangeIncomplete}
@click=${this._save}
<ha-button .disabled=${!this._dateValue} @click=${this._save}
>${this._i18n.localize(
"ui.components.date-range-picker.select"
)}</ha-button
@@ -285,7 +280,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
private _save() {
if (!this._dateValue || this._rangeIncomplete) {
if (!this._dateValue) {
return;
}
@@ -336,21 +331,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._focusDate = ev.target.focusedDate;
}
private _handleRangeStart(
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._rangeIncomplete = matchMedia("(hover: hover)").matches;
if (this._rangeIncomplete) {
return;
}
const day = ev.target.tentative;
this._dateValue = `${day}/${day}`;
}
private _handleChange(
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._rangeIncomplete = false;
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = dateElement.focusedDate;
+19 -18
View File
@@ -371,24 +371,25 @@ export class HaEntityPicker extends LitElement {
excludeEntities?: string[],
value?: string
) =>
getEntities({
states,
entities: registries.entities,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
localize: i18n.localize,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
})
getEntities(
{
states,
...registries,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
localize: i18n.localize,
},
{
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
}
)
);
private _sortByRelatedContext = memoizeOne(
+95 -146
View File
@@ -6,7 +6,7 @@ import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { supportsFeature } from "../common/entity/supports-feature";
import {
CameraEntityFeature,
@@ -17,16 +17,8 @@ import {
STREAM_TYPE_WEB_RTC,
type CameraCapabilities,
type CameraEntity,
type StreamType,
} from "../data/camera";
import {
hasWebRtcWorked,
MJPEG_STREAM,
selectCameraStreams,
setWebRtcWorked,
type CameraStream,
type CameraStreamTracks,
type CameraStreamType,
} from "../data/camera-streams";
import {
apiContext,
configContext,
@@ -37,10 +29,14 @@ import "./ha-hls-player";
import "./ha-web-rtc-player";
import type { HaWebRtcPlayer } from "./ha-web-rtc-player";
export type { CameraStreamType } from "../data/camera-streams";
const MJPEG_STREAM = "mjpeg";
// How long WebRTC gets on its own before HLS is started as well
const WEB_RTC_FIRST_TIMEOUT = 12000;
export type CameraStreamType = StreamType | typeof MJPEG_STREAM;
interface Stream {
type: CameraStreamType;
visible: boolean;
}
@customElement("ha-camera-stream")
export class HaCameraStream extends LitElement {
@@ -82,25 +78,13 @@ export class HaCameraStream extends LitElement {
@state() private _capabilities?: CameraCapabilities;
@state() private _hlsStreams?: CameraStreamTracks;
@state() private _hlsStreams?: { hasAudio: boolean; hasVideo: boolean };
@state() private _webRtcStreams?: CameraStreamTracks;
// WebRTC worked before for this camera, so HLS is not started with it
@state() private _webRtcFirst = false;
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
@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 _visibleStreamType?: Stream["type"];
private _thumbnailApi = memoizeOne(
(
@@ -148,16 +132,11 @@ export class HaCameraStream extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._connected = true;
if (this.hasUpdated && this._capabilities && this.stateObj) {
// The players start again when reattached, so their results do as well
this._startStreams(this.stateObj.entity_id, this._capabilities);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._connected = false;
this._stopWebRtcFirst();
}
protected updated(changedProps: PropertyValues<this>): void {
@@ -167,34 +146,13 @@ export class HaCameraStream extends LitElement {
capabilities: this._capabilities,
});
}
const streams = this._currentStreams();
const visibleStreamType = streams.find((stream) => stream.visible)?.type;
const visibleStreamType = this._currentStreams().find(
(stream) => stream.visible
)?.type;
if (visibleStreamType !== this._visibleStreamType) {
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() {
@@ -209,7 +167,7 @@ export class HaCameraStream extends LitElement {
)}`;
}
private _renderStream(stream: CameraStream) {
private _renderStream(stream: Stream) {
if (!this.stateObj) {
return nothing;
}
@@ -272,14 +230,14 @@ export class HaCameraStream extends LitElement {
this._capabilities?.frontend_stream_types,
this._hlsStreams,
this._webRtcStreams,
this._webRtcFirst
this.muted
);
}
private async _getCapabilities() {
this._capabilities = undefined;
this._resetStreams();
const request = ++this._capabilitiesRequest;
this._hlsStreams = undefined;
this._webRtcStreams = undefined;
if (!supportsFeature(this.stateObj!, CameraEntityFeature.STREAM)) {
this._capabilities = {
frontend_stream_types: [],
@@ -287,62 +245,10 @@ export class HaCameraStream extends LitElement {
};
return;
}
const entityId = this.stateObj!.entity_id;
const capabilities = await fetchCameraCapabilities(this._api, entityId);
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;
this._capabilities = await fetchCameraCapabilities(
this._api,
this.stateObj!.entity_id
);
}
private async _getPosterUrl(): Promise<void> {
@@ -363,46 +269,89 @@ export class HaCameraStream extends LitElement {
fireEvent(this, "load");
}
private _handleHlsStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
private _handleHlsStreams(ev: CustomEvent) {
this._hlsStreams = ev.detail;
}
private _handleWebRtcStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
clearTimeout(this._webRtcFirstTimeout);
this._webRtcFirstTimeout = undefined;
if (
!ev.detail.hasVideo &&
!ev.detail.hasAudio &&
this._capabilities?.frontend_stream_types.length === 1 &&
typeof RTCPeerConnection !== "undefined"
) {
// Without another stream type, keep the player to show why it failed.
// 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;
}
private _handleWebRtcStreams(ev: CustomEvent) {
this._webRtcStreams = ev.detail;
}
private _streams = memoizeOne(
(
supportedTypes?: CameraCapabilities["frontend_stream_types"],
hlsStreams?: CameraStreamTracks,
webRtcStreams?: CameraStreamTracks,
webRtcFirst?: boolean
): CameraStream[] => {
supportedTypes?: StreamType[],
hlsStreams?: { hasAudio: boolean; hasVideo: boolean },
webRtcStreams?: { hasAudio: boolean; hasVideo: boolean },
muted?: boolean
): Stream[] => {
if (__DEMO__) {
return [{ type: MJPEG_STREAM, visible: true }];
}
return selectCameraStreams({
supportedTypes,
hlsStreams,
webRtcStreams,
webRtcFirst,
});
if (!supportedTypes) {
return [];
}
if (supportedTypes.length === 0) {
// doesn't support any stream type, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
if (supportedTypes.length === 1) {
// only 1 stream type, no need to choose
if (
(supportedTypes[0] === STREAM_TYPE_HLS &&
hlsStreams?.hasVideo === false) ||
(supportedTypes[0] === STREAM_TYPE_WEB_RTC &&
webRtcStreams?.hasVideo === false)
) {
// stream failed to load, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
return [{ type: supportedTypes[0], visible: true }];
}
if (hlsStreams && webRtcStreams) {
// fully loaded
if (
hlsStreams.hasVideo &&
hlsStreams.hasAudio &&
!webRtcStreams.hasAudio &&
!muted
) {
// webRTC stream is missing audio and audio is not muted, use HLS
return [{ type: STREAM_TYPE_HLS, visible: true }];
}
if (webRtcStreams.hasVideo) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
// both streams failed to load, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
if (hlsStreams?.hasVideo !== webRtcStreams?.hasVideo) {
// one of the two streams is loaded, or errored
// choose the one that has video or is still loading
if (hlsStreams?.hasVideo) {
return [
{ type: STREAM_TYPE_HLS, visible: true },
{ type: STREAM_TYPE_WEB_RTC, visible: false },
];
}
if (hlsStreams?.hasVideo === false) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
if (webRtcStreams?.hasVideo) {
return [
{ type: STREAM_TYPE_WEB_RTC, visible: true },
{ type: STREAM_TYPE_HLS, visible: false },
];
}
if (webRtcStreams?.hasVideo === false) {
return [{ type: STREAM_TYPE_HLS, visible: true }];
}
}
return [
{ type: STREAM_TYPE_HLS, visible: true },
{ type: STREAM_TYPE_WEB_RTC, visible: false },
];
}
);
+11 -7
View File
@@ -2,7 +2,6 @@ import type { ContextType } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../common/decorators/consume";
import { configContext } from "../data/context";
import "./ha-icon-next";
@@ -35,14 +34,17 @@ class HaConfigNavigationList extends LitElement {
.href=${externalApp ? undefined : page.path}
@click=${externalApp ? this._handleExternalApp : undefined}
>
<ha-svg-icon
<div
slot="start"
class=${page.iconColor ? "icon-background" : ""}
style=${styleMap({ backgroundColor: page.iconColor })}
.path=${page.iconPath}
.secondaryPath=${page.iconSecondaryPath}
.viewBox=${page.iconViewBox}
></ha-svg-icon>
.style="background-color: ${page.iconColor || "undefined"}"
>
<ha-svg-icon
.path=${page.iconPath}
.secondaryPath=${page.iconSecondaryPath}
.viewBox=${page.iconViewBox}
></ha-svg-icon>
</div>
<span slot="headline">${page.name}</span>
${
this.hasSecondary
@@ -80,6 +82,8 @@ class HaConfigNavigationList extends LitElement {
}
.icon-background {
border-radius: var(--ha-border-radius-circle);
}
.icon-background ha-svg-icon {
color: #fff;
}
`;
+4
View File
@@ -62,6 +62,10 @@ export class HaControlSelect extends LitElement {
});
}
public override focus(): void {
this._focusOption(this._tabbableIndex);
}
private _handleBlur(ev: FocusEvent) {
// Only reset if focus is leaving the entire component
if (!this.contains(ev.relatedTarget as Node)) {
-1
View File
@@ -219,7 +219,6 @@ export class HaControlSlider extends LitElement {
this.pressed = false;
this._hideTooltip();
this.value = savedValue;
fireEvent(this, "slider-moved", { value: undefined });
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
-4
View File
@@ -112,9 +112,6 @@ 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" })
@@ -350,7 +347,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
.noSort=${this.noSort}
.pinBlock=${this.pinBlock}
></ha-picker-combo-box>
`;
}
-4
View File
@@ -214,19 +214,15 @@ class HaHsColorPicker extends LitElement {
this._mc.add(new Tap({ event: "singletap" }));
let savedPosition;
let savedValue: [number, number] | undefined;
this._mc.on("panstart", (e) => {
if (this.disabled) return;
this._pressed = e.pointerType;
savedPosition = this._cursorPosition;
savedValue = this._localValue;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = savedPosition;
this._localValue = savedValue;
fireEvent(this, "cursor-moved", { value: undefined });
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
+17 -61
View File
@@ -1,17 +1,13 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { PropertyValues, TemplateResult } from "lit";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property } 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";
@@ -54,17 +50,16 @@ const createIconItem = (icon: IconData, prefix: string): PickerComboBoxItem => {
const loadIcons = async () => {
ICONS_LOADED = true;
const sortIcons = (icons: PickerComboBoxItem[]) =>
icons.sort((a, b) => caseInsensitiveStringCompare(a.id, b.id));
const iconList = await import("../../build/mdi/iconList.json");
const mdiIcons = iconList.default.map((icon) => createIconItem(icon, "mdi"));
ICONS = sortIcons([...mdiIcons]);
ICONS = iconList.default.map((icon) => createIconItem(icon, "mdi"));
const customIconItems = await Promise.all(
Object.keys(customIcons).map(loadCustomIconItems)
);
ICONS = sortIcons([...mdiIcons, ...customIconItems.flat()]);
const customIconLoads: Promise<PickerComboBoxItem[]>[] = [];
Object.keys(customIcons).forEach((iconSet) => {
customIconLoads.push(loadCustomIconItems(iconSet));
});
(await Promise.all(customIconLoads)).forEach((customIconItems) => {
ICONS.push(...customIconItems);
});
};
const loadCustomIconItems = async (
@@ -86,8 +81,8 @@ const loadCustomIconItems = async (
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item>
<ha-icon .icon=${item.icon ?? item.id} slot="start"></ha-icon>
${item.icon ?? item.id}
<ha-icon .icon=${item.id} slot="start"></ha-icon>
${item.id}
</ha-combo-box-item>
`;
@@ -109,32 +104,12 @@ export class HaIconPicker extends LitElement {
@property({ type: Boolean }) public invalid = false;
@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();
}
}
private _getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
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}
@@ -153,35 +128,15 @@ 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, "-");
// The full list also holds the suggestions and their section titles
const iconItems = ICONS.length ? ICONS : filteredItems;
const iconItems = allItems?.length ? allItems : filteredItems;
if (!normalizedFilter.length) {
return iconItems;
@@ -220,7 +175,8 @@ export class HaIconPicker extends LitElement {
protected firstUpdated() {
if (!ICONS_LOADED) {
loadIcons().then(() => {
this._picker?.refreshItems();
this._getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
this.requestUpdate();
});
}
}
@@ -406,8 +406,6 @@ export class HaItemDisplayEditor extends LitElement {
cursor: move;
padding: 8px;
margin: -8px;
/* Keep the handle above the row's hit area so dragging can start */
z-index: 1;
}
.separator {
width: 1px;
+2 -12
View File
@@ -201,9 +201,6 @@ 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;
@@ -456,7 +453,6 @@ 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}
@@ -689,11 +685,6 @@ 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)
@@ -717,11 +708,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return;
}
const searchableItems = this._searchableItems(this._allItems);
const index = this._fuseIndex(searchableItems, this.searchKeys);
const index = this._fuseIndex(this._allItems, this.searchKeys);
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
searchableItems,
this._allItems,
searchString,
(item) => item.id,
index
+2 -3
View File
@@ -50,7 +50,6 @@ const wait = (ms: number) =>
* @cssprop --ha-ripple-hover-color - Hover color.
* @cssprop --ha-ripple-pressed-color - Pressed color.
* @cssprop --ha-ripple-hover-opacity - Hover opacity. Defaults to 0.08.
* @cssprop --ha-ripple-hover-duration - Fade duration of the hover color. Defaults to 15ms.
* @cssprop --ha-ripple-pressed-opacity - Pressed opacity. Defaults to 0.12.
*/
@customElement("ha-ripple")
@@ -397,8 +396,8 @@ export class HaRipple extends LitElement {
var(--ha-ripple-color, var(--secondary-text-color))
);
transition:
opacity var(--ha-ripple-hover-duration, 15ms) linear,
background-color var(--ha-ripple-hover-duration, 15ms) linear;
opacity 15ms linear,
background-color 15ms linear;
}
.hovered .hover {
opacity: var(--ha-ripple-hover-opacity, 0.08);
@@ -21,8 +21,6 @@ export class HaActionSelector extends LitElement {
@property({ attribute: false }) public value?: Action;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -53,7 +51,6 @@ export class HaActionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-action
.pathKey=${this.name}
.disabled=${this.disabled}
.actions=${this._actions(this.value)}
.hass=${this.hass}
@@ -16,8 +16,6 @@ export class HaConditionSelector extends LitElement {
@property({ attribute: false }) public value?: Condition;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -29,7 +27,6 @@ export class HaConditionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-condition
.pathKey=${this.name}
.disabled=${this.disabled}
.conditions=${this.value || []}
.hass=${this.hass}
@@ -1,18 +1,9 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import {
apiContext,
connectionContext,
devicesContext,
entitiesContext,
statesContext,
} from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
@@ -24,7 +15,7 @@ import {
filterSelectorEntities,
} from "../../data/selector";
import type { HassServiceTargetWithExclude } from "../../data/target";
import type { HomeAssistant, HomeAssistantConnection } from "../../types";
import type { HomeAssistant } from "../../types";
import "../ha-target-picker";
type TargetSelectorFilters = NonNullable<TargetSelector["target"]>;
@@ -37,6 +28,8 @@ const ALL_DEVICES = () => true;
@customElement("ha-selector-target")
export class HaTargetSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: TargetSelector;
@property({ type: Object }) public value?: HassServiceTargetWithExclude;
@@ -51,27 +44,9 @@ export class HaTargetSelector extends LitElement {
@state() private _createDomains: string[] | undefined;
@consume({ context: apiContext, subscribe: true })
private _api?: ContextType<typeof apiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
@consume({ context: devicesContext, subscribe: true })
private _devices?: ContextType<typeof devicesContext>;
private _fetchedEntitySources = false;
private _deviceIntegrationLookup = memoizeOne(
(entities: HomeAssistant["entities"] | undefined) =>
getDeviceIntegrationLookup(Object.values(entities ?? {}))
(entities: HomeAssistant["entities"]) =>
getDeviceIntegrationLookup(Object.values(entities))
);
private _hasIntegration(selector: TargetSelector) {
@@ -87,31 +62,18 @@ export class HaTargetSelector extends LitElement {
);
}
protected updated(changedProperties: PropertyValues): void {
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
const oldConnection = changedProperties.get("_connection") as
HomeAssistantConnection | undefined;
if (
oldConnection &&
this._connection &&
(oldConnection.connection !== this._connection.connection ||
(!oldConnection.connected && this._connection.connected))
changedProperties.has("selector") &&
this._hasIntegration(this.selector) &&
!this._entitySources
) {
this._fetchedEntitySources = false;
}
if (
this._api &&
!this._fetchedEntitySources &&
this._hasIntegration(this.selector)
) {
this._fetchedEntitySources = true;
fetchEntitySourcesWithCache(this._api.callWS, this._states ?? {})
.then((sources) => {
fetchEntitySourcesWithCache(this.hass.callWS, this.hass.states).then(
(sources) => {
this._entitySources = sources;
})
.catch(() => {
this._entitySources ??= {};
});
}
);
}
if (changedProperties.has("selector")) {
this._createDomains = computeCreateDomains(this.selector);
@@ -125,6 +87,7 @@ export class HaTargetSelector extends LitElement {
return html` ${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-target-picker
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.deviceFilter=${this._deviceFilter(this.selector.target?.device)}
@@ -155,8 +118,8 @@ export class HaTargetSelector extends LitElement {
filter,
entity,
entitySources,
this._entities,
this._devices
this.hass.entities,
this.hass.devices
)
);
}
@@ -169,7 +132,7 @@ export class HaTargetSelector extends LitElement {
}
return (device: DeviceRegistryEntry): boolean => {
const deviceIntegrations = this._hasIntegration(this.selector)
? this._deviceIntegrationLookup(this._entities)
? this._deviceIntegrationLookup(this.hass.entities)
: undefined;
return ensureArray(filters).some((filter) =>
filterSelectorDevices(filter, device, deviceIntegrations)
@@ -17,8 +17,6 @@ export class HaTriggerSelector extends LitElement {
@property({ attribute: false }) public value?: Trigger;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -34,7 +32,6 @@ export class HaTriggerSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-trigger
.pathKey=${this.name}
.disabled=${this.disabled}
.triggers=${this._triggers(this.value)}
.hass=${this.hass}
+6 -14
View File
@@ -1,13 +1,10 @@
import type { ContextType } from "@lit/context";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { entitiesContext, statesContext } from "../data/context";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import { entityTypeFilterFunc } from "../data/entity/entity_type";
@@ -111,6 +108,8 @@ export const applySourceFilters = (
*/
@customElement("ha-sources-picker")
export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value: HassServiceTarget = {};
/** The filters the page applies, so the picker counts what they keep. */
@@ -126,14 +125,6 @@ export class HaSourcesPicker extends LitElement {
@property({ type: Boolean }) public disabled = false;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
protected render() {
const noTargets = countTargets(this.value) === 0;
@@ -145,12 +136,13 @@ export class HaSourcesPicker extends LitElement {
}
<ha-target-picker
class=${classMap({ "no-padding-top": noTargets })}
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.activeFilter=${this._activeFilter(
this.filters,
this._states,
this._entities,
this.hass.states,
this.hass.entities,
this.entitySources
)}
.primaryEntitiesOnly=${false}
+84 -165
View File
@@ -1,4 +1,3 @@
import type { ContextType } from "@lit/context";
import "@home-assistant/webawesome/dist/components/popover/popover";
import { mdiPlaylistMinus, mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
@@ -9,7 +8,6 @@ import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { transform } from "../common/decorators/transform";
import { ensureArray } from "../common/array/ensure-array";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
@@ -23,17 +21,7 @@ import {
type FloorComboBoxItem,
} from "../data/area_floor_picker";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import {
apiContext,
brandsAccessTokenContext,
configContext,
connectionContext,
internationalizationContext,
labelsContext,
registriesContext,
statesContext,
uiContext,
} from "../data/context";
import { labelsContext } from "../data/context";
import {
deviceComboBoxKeys,
getDevices,
@@ -59,6 +47,7 @@ import {
type TargetType,
type TargetTypeFloorless,
} from "../data/target";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { isHelperDomain } from "../panels/config/helpers/const";
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
import {
@@ -66,11 +55,7 @@ import {
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import type {
HomeAssistant,
HomeAssistantUI,
ValueChangedEvent,
} from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-button";
@@ -104,7 +89,9 @@ const isTargetType = (value: string): value is TargetType =>
value === "floor";
@customElement("ha-target-picker")
export class HaTargetPicker extends LitElement {
export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: HassServiceTargetWithExclude;
@property() public helper?: string;
@@ -170,47 +157,6 @@ export class HaTargetPicker extends LitElement {
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: statesContext, subscribe: true })
@transform<HomeAssistant["states"], boolean>({
transformer: function (this: HaTargetPicker, states) {
return !!this._pendingEntityId && !!states?.[this._pendingEntityId];
},
watch: ["_pendingEntityId"],
})
private _pendingEntityLoaded = false;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, boolean | undefined>({
transformer: (ui) => ui?.themes?.darkMode,
})
private _darkMode?: boolean;
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@consume({ context: apiContext, subscribe: true })
private _api?: ContextType<typeof apiContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: brandsAccessTokenContext, subscribe: true })
private _brandsAccessToken?: string;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-generic-picker.exclude") private _excludePicker?: HaGenericPicker;
@@ -221,9 +167,7 @@ export class HaTargetPicker extends LitElement {
private _getDevicesMemoized = memoizeOne(
(
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
hass: HomeAssistant,
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
includeDeviceClasses?: string[],
@@ -234,13 +178,13 @@ export class HaTargetPicker extends LitElement {
idPrefix?: string
) =>
getDevices({
states,
areas: registries.areas,
devices: registries.devices,
entities: registries.entities,
localize: i18n.localize,
language: i18n.locale.language,
translationMetadata: i18n.translationMetadata,
states: hass.states,
areas: hass.areas,
devices: hass.devices,
entities: hass.entities,
localize: hass.localize,
language: hass.locale.language,
translationMetadata: hass.translationMetadata,
configEntryLookup,
includeDomains,
includeDeviceClasses,
@@ -257,9 +201,7 @@ export class HaTargetPicker extends LitElement {
private _getEntitiesMemoized = memoizeOne(
(
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
hass: HomeAssistant,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
@@ -270,15 +212,7 @@ export class HaTargetPicker extends LitElement {
value?: string,
idPrefix?: string
) =>
getEntities({
states,
entities: registries.entities,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
localize: i18n.localize,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
getEntities(hass, {
includeDomains,
excludeDomains,
entityFilter,
@@ -294,7 +228,7 @@ export class HaTargetPicker extends LitElement {
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private get _showEntityId() {
return this._config?.userData?.showEntityIdPicker;
return this.hass.userData?.showEntityIdPicker;
}
private _fuseIndexes = {
@@ -314,34 +248,36 @@ export class HaTargetPicker extends LitElement {
@state() private _pendingEntityId?: string;
public willUpdate(changedProps: PropertyValues) {
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._loadConfigEntries();
}
const devices = this._registries?.devices;
const devicesChanged =
changedProps.has("_registries") &&
devices !==
(
changedProps.get("_registries") as
ContextType<typeof registriesContext> | undefined
)?.devices;
changedProps.has("hass") &&
this.hass.devices !== changedProps.get("hass")?.devices;
if (
(changedProps.has("value") || devicesChanged) &&
devices &&
this.hass &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this.value?.device_id &&
ensureArray(this.value.device_id).some((deviceId) => !devices[deviceId])
ensureArray(this.value.device_id).some(
(deviceId) => !this.hass.devices[deviceId]
)
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map to offer a fix.
this._loadCompositeSplits();
}
const registriesChanged =
changedProps.has("hass") &&
(devicesChanged ||
this.hass.entities !== changedProps.get("hass")?.entities ||
this.hass.areas !== changedProps.get("hass")?.areas);
if (
changedProps.has("value") ||
changedProps.has("allowExclude") ||
@@ -349,12 +285,18 @@ export class HaTargetPicker extends LitElement {
changedProps.has("deviceFilter") ||
changedProps.has("includeDomains") ||
changedProps.has("includeDeviceClasses") ||
changedProps.has("entityFilter")
changedProps.has("entityFilter") ||
registriesChanged
) {
this._exclusions.resolve();
}
if (this._pendingEntityId && this._pendingEntityLoaded) {
if (
this._pendingEntityId &&
changedProps.has("hass") &&
this.hass.states !== changedProps.get("hass")?.states &&
this.hass.states[this._pendingEntityId]
) {
this._addTarget(this._pendingEntityId, "entity");
this._pendingEntityId = undefined;
}
@@ -511,7 +453,7 @@ export class HaTargetPicker extends LitElement {
return nothing;
}
return html`
<div class="exclusion-list-label">${this._i18n.localize(label)}</div>
<div class="exclusion-list-label">${this.hass.localize(label)}</div>
<ha-list-base>
${items.map(
(item) =>
@@ -544,16 +486,16 @@ export class HaTargetPicker extends LitElement {
.helper=${this.helper}
.sections=${sections}
.notFoundLabel=${this._noTargetFoundLabel}
.emptyLabel=${this._i18n.localize(
.emptyLabel=${this.hass.localize(
"ui.components.target-picker.no_targets"
)}
.selectedSection=${this._selectedSection}
.popoverAnchor=${this._replaceTargetAnchor}
.rowRenderer=${this._rowRenderer(this._brandsAccessToken)}
.rowRenderer=${this._renderRow}
.getItems=${this._getItems}
@value-changed=${this._targetPicked}
@picker-closed=${this._handlePickerClosed}
.addButtonLabel=${this._i18n.localize(
.addButtonLabel=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
.getAdditionalItems=${this._getAdditionalItems}
@@ -568,11 +510,11 @@ export class HaTargetPicker extends LitElement {
.disabled=${this.disabled}
.sections=${sections}
.notFoundLabel=${this._noTargetFoundLabel}
.emptyLabel=${this._i18n.localize(
.emptyLabel=${this.hass.localize(
"ui.components.target-picker.no_targets"
)}
.selectedSection=${this._excludeSection}
.rowRenderer=${this._rowRenderer(this._brandsAccessToken)}
.rowRenderer=${this._renderRow}
.getItems=${this._getExcludeItems}
@value-changed=${this._excludePicked}
>
@@ -587,7 +529,7 @@ export class HaTargetPicker extends LitElement {
.path=${mdiPlaylistMinus}
slot="start"
></ha-svg-icon>
${this._i18n.localize(
${this.hass.localize(
"ui.components.target-picker.exclude_target"
)}
</ha-button>
@@ -603,20 +545,20 @@ export class HaTargetPicker extends LitElement {
return [
{
id: "entity",
label: this._i18n.localize("ui.components.target-picker.type.entities"),
label: this.hass.localize("ui.components.target-picker.type.entities"),
},
{
id: "device",
label: this._i18n.localize("ui.components.target-picker.type.devices"),
label: this.hass.localize("ui.components.target-picker.type.devices"),
},
{
id: "area",
label: this._i18n.localize("ui.components.target-picker.type.areas"),
label: this.hass.localize("ui.components.target-picker.type.areas"),
},
"separator" as const,
{
id: "label",
label: this._i18n.localize("ui.components.target-picker.type.labels"),
label: this.hass.localize("ui.components.target-picker.type.labels"),
},
];
}
@@ -745,7 +687,7 @@ export class HaTargetPicker extends LitElement {
this._excludeSection = section as TargetTypeFloorless | undefined;
return this._getItemsMemoized(
this._i18n,
this.hass.localize,
this._excludeEntityFilter(
this.entityFilter,
this._exclusions.includedEntities
@@ -757,8 +699,7 @@ export class HaTargetPicker extends LitElement {
undefined,
searchString,
this._configEntryLookup,
this._states,
this._registries,
this.hass.entities,
this._excludeSection,
this._excludePickerIncludedDevices(this.value)
);
@@ -875,7 +816,7 @@ export class HaTargetPicker extends LitElement {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) {
if (this._states?.[item.entityId]) {
if (this.hass.states[item.entityId]) {
this._addTarget(item.entityId, "entity");
} else {
this._pendingEntityId = item.entityId;
@@ -962,7 +903,7 @@ export class HaTargetPicker extends LitElement {
this._selectedSection = section as TargetTypeFloorless | undefined;
return this._getItemsMemoized(
this._i18n,
this.hass.localize,
this.entityFilter,
this.deviceFilter,
this.includeDomains,
@@ -971,15 +912,14 @@ export class HaTargetPicker extends LitElement {
this._replaceTarget,
searchString,
this._configEntryLookup,
this._states,
this._registries,
this.hass.entities,
this._selectedSection
);
};
private _getItemsMemoized = memoizeOne(
(
i18n: ContextType<typeof internationalizationContext>,
localize: HomeAssistant["localize"],
entityFilter: this["entityFilter"],
deviceFilter: this["deviceFilter"],
includeDomains: this["includeDomains"],
@@ -988,13 +928,11 @@ export class HaTargetPicker extends LitElement {
replaceTarget: TargetItem | undefined,
searchTerm: string,
configEntryLookup: Record<string, ConfigEntry>,
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
_entities: HomeAssistant["entities"],
filterType?: TargetTypeFloorless,
// Hidden without hiding their child devices.
hiddenDeviceIds?: string[]
) => {
const localize = i18n.localize;
const replacingEntityId =
replaceTarget?.type === "entity" ? replaceTarget.id : undefined;
const replacingDeviceId =
@@ -1012,9 +950,7 @@ export class HaTargetPicker extends LitElement {
if (!filterType || filterType === "entity") {
let entityItems = this._getEntitiesMemoized(
states,
registries,
i18n,
this.hass,
includeDomains,
undefined,
entityFilter,
@@ -1068,7 +1004,7 @@ export class HaTargetPicker extends LitElement {
...(hiddenDeviceIds ?? []),
...(selectedDeviceIds ?? []),
...(selectedDeviceIds
? Object.values(registries.devices)
? Object.values(this.hass.devices)
.filter(
(device) =>
device.parent_device_id !== null &&
@@ -1079,9 +1015,7 @@ export class HaTargetPicker extends LitElement {
]
: undefined;
let deviceItems = this._getDevicesMemoized(
states,
registries,
i18n,
this.hass,
configEntryLookup,
includeDomains,
includeDeviceClasses,
@@ -1132,11 +1066,11 @@ export class HaTargetPicker extends LitElement {
if (!filterType || filterType === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
states,
registries.floors,
registries.areas,
registries.devices,
registries.entities,
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
),
@@ -1197,10 +1131,10 @@ export class HaTargetPicker extends LitElement {
if (!filterType || filterType === "label") {
let labels = this._getLabelsMemoized(
states,
registries.areas,
registries.devices,
registries.entities,
this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._labelRegistry,
includeDomains,
undefined,
@@ -1261,32 +1195,30 @@ export class HaTargetPicker extends LitElement {
});
}
private _getAdditionalItems = () =>
this._getCreateItems(this.createDomains, this._i18n.localize);
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
private _getCreateItems = memoizeOne(
(
createDomains: this["createDomains"],
localize: ContextType<typeof internationalizationContext>["localize"]
) => {
(createDomains: this["createDomains"]) => {
if (!createDomains?.length) {
return [];
}
return createDomains.map((domain) => {
const primary = localize(
const primary = this.hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? localize(`ui.panel.config.helpers.types.${domain}`)
: domainToName(localize, domain),
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
: domainToName(this.hass.localize, domain),
}
);
return {
id: CREATE_ID + domain,
primary: primary,
secondary: localize("ui.components.entity.entity-picker.new_entity"),
secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity"
),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
});
@@ -1294,24 +1226,18 @@ export class HaTargetPicker extends LitElement {
);
private async _loadConfigEntries() {
if (!this._api) {
return;
}
const configEntries = await getConfigEntries(this._api);
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private async _loadCompositeSplits() {
if (!this._api || !this._connection) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(
this._connection.connection,
this._api.callWS
this.hass.connection,
this.hass.callWS
);
} catch (_err) {
this._compositeSplits = {};
@@ -1336,13 +1262,6 @@ export class HaTargetPicker extends LitElement {
});
}
// A new renderer when the brands token arrives, so open lists redraw icons.
private _rowRenderer = memoizeOne(
(_brandsAccessToken: string | undefined) =>
(item: Parameters<HaTargetPicker["_renderRow"]>[0]) =>
this._renderRow(item)
);
private _renderRow = (
item:
| PickerComboBoxItem
@@ -1362,8 +1281,8 @@ export class HaTargetPicker extends LitElement {
type === "device" && !!(item as DevicePickerItem).is_child;
if (type === "area" || type === "floor" || isChildDeviceRow) {
rtl = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
this.hass.language,
this.hass.translationMetadata.translations
);
hasFloor =
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
@@ -1429,9 +1348,9 @@ export class HaTargetPicker extends LitElement {
{
domain: (item as DevicePickerItem).domain!,
type: "icon",
darkOptimized: this._darkMode,
darkOptimized: this.hass.themes.darkMode,
},
this._config?.auth?.data.hassUrl
this.hass.auth.data.hassUrl
)}
/>
`
@@ -1477,7 +1396,7 @@ export class HaTargetPicker extends LitElement {
};
private _noTargetFoundLabel = (search: string) =>
this._i18n.localize("ui.components.target-picker.no_target_found", {
this.hass.localize("ui.components.target-picker.no_target_found", {
term: html`<b>‘${search}’</b>`,
});
@@ -1485,7 +1404,7 @@ export class HaTargetPicker extends LitElement {
caseInsensitiveStringCompare(
(entityA as PickerComboBoxItem).sorting_label!,
(entityB as PickerComboBoxItem).sorting_label!,
this._i18n?.locale.language ?? navigator.language
this.hass?.locale.language ?? navigator.language
);
static styles = css`
+15 -152
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,
@@ -30,21 +24,8 @@ import { showToast } from "../util/toast";
const HIDDEN_CLEANUP_DELAY = 60000;
// How long the backend gets to answer the offer, it has to connect to the camera
const ANSWER_TIMEOUT = 20000;
// How long finding a network path gets after the answer. Browsers can take
// 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;
// Gives the backend time to tear down the failed session before the next one
const RETRY_DELAY = 1000;
interface WebRtcPlayerError {
type: "not_supported" | "start_failed" | "connect_failed" | "no_connection";
type: "not_supported" | "start_failed" | "connect_failed";
message?: string;
}
@@ -118,7 +99,7 @@ export class HaWebRtcPlayer extends LitElement {
if (this._microphoneDenied) {
return "denied";
}
if (this._microphoneConnecting || this._retryMicrophoneTrack) {
if (this._microphoneConnecting) {
return "connecting";
}
return this._localReturnAudioTrack ? "on" : "off";
@@ -129,17 +110,11 @@ export class HaWebRtcPlayer extends LitElement {
}
public async toggleMicrophone() {
if (
this._localReturnAudioTrack ||
this._microphoneConnecting ||
this._retryMicrophoneTrack
) {
if (this._localReturnAudioTrack || this._microphoneConnecting) {
// Stop (not disable) so the browser releases the microphone, also cancels connecting
this._microphoneRequest++;
this._localReturnAudioTrack?.stop();
this._localReturnAudioTrack = undefined;
this._retryMicrophoneTrack?.stop();
this._retryMicrophoneTrack = undefined;
this._microphoneConnecting = false;
this._fireMicrophoneChanged();
await this._microphoneSender?.replaceTrack(null).catch(() => undefined);
@@ -192,16 +167,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() {
@@ -230,17 +197,6 @@ export class HaWebRtcPlayer extends LitElement {
private _cleanUpCount = 0;
private _connectTimeout?: number;
private _retryTimeout?: number;
private _retryMicrophoneTrack?: MediaStreamTrack;
private _retries = 0;
// The stream only loaded with all ICE servers, so keep using them
private _allIceServers = false;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
@@ -256,7 +212,6 @@ export class HaWebRtcPlayer extends LitElement {
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
} else {
this._retries = 0;
this._startWebRtc();
}
};
@@ -298,7 +253,6 @@ export class HaWebRtcPlayer extends LitElement {
public override connectedCallback() {
super.connectedCallback();
if (this.hasUpdated && this.entityid) {
this._retries = 0;
this._startWebRtc();
}
document.addEventListener("visibilitychange", this._handleVisibilityChange);
@@ -320,14 +274,11 @@ export class HaWebRtcPlayer extends LitElement {
if (!changedProperties.has("entityid")) {
return;
}
this._retries = 0;
this._allIceServers = false;
this._startWebRtc();
}
private async _startWebRtc(
localReturnAudioTrack?: MediaStreamTrack,
icePath?: IcePath
localReturnAudioTrack?: MediaStreamTrack
): Promise<void> {
this._cleanUp();
@@ -382,21 +333,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
@@ -476,7 +415,7 @@ export class HaWebRtcPlayer extends LitElement {
while (this._candidatesList.length) {
const candidate = this._candidatesList.pop();
if (candidate) {
candidates += `a=${candidate.candidate}\r\n`;
candidates += `a=${candidate}\r\n`;
}
}
@@ -484,8 +423,6 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("start webRtcOffer", offer_sdp);
this._startConnectTimeout(ANSWER_TIMEOUT);
this._unsub = webRtcOffer(
this._connection,
this.entityid,
@@ -506,58 +443,11 @@ export class HaWebRtcPlayer extends LitElement {
"ice connection state change",
this._peerConnection?.iceConnectionState
);
const iceState = this._peerConnection?.iceConnectionState;
if (iceState === "connected" || iceState === "completed") {
clearTimeout(this._connectTimeout);
this._connectTimeout = undefined;
} else if (iceState === "failed") {
// An ICE restart needs a new offer, which the backend answers from a new
// peer that can't continue this connection, so start a new one instead.
this._retryOrFail({ type: "no_connection" });
if (this._peerConnection?.iceConnectionState === "failed") {
this._peerConnection.restartIce();
}
};
private _startConnectTimeout(timeout: number) {
clearTimeout(this._connectTimeout);
this._connectTimeout = window.setTimeout(() => {
this._connectTimeout = undefined;
this._logEvent("connect timeout");
this._retryOrFail({ type: "no_connection" });
}, timeout);
}
private _retryOrFail(error: WebRtcPlayerError) {
if (
this._retries >= MAX_RETRIES ||
// A microphone connect the user started is handled by starting again
// without the microphone. A retry keeps the microphone of the stream.
(this._localReturnAudioTrack &&
this._microphoneConnecting &&
this._retries === 0)
) {
this._failStart(error);
return;
}
this._retries++;
this._logEvent("retry", this._retries, error);
// Keep showing the last frame instead of the poster while starting again
this._captureReconnectFrame();
// Taken out before the clean up, so a running microphone is not stopped
const microphoneTrack = this._localReturnAudioTrack;
this._localReturnAudioTrack = undefined;
this._cleanUp();
if (microphoneTrack) {
this._retryMicrophoneTrack = microphoneTrack;
this._fireMicrophoneChanged();
}
this._retryTimeout = window.setTimeout(() => {
this._retryTimeout = undefined;
const track = this._retryMicrophoneTrack;
this._retryMicrophoneTrack = undefined;
this._startWebRtc(track);
}, RETRY_DELAY);
}
private async _handleOfferEvent(
peerConnection: RTCPeerConnection,
event: WebRtcOfferEvent
@@ -580,10 +470,6 @@ export class HaWebRtcPlayer extends LitElement {
this._candidatesList = [];
}
if (event.type === "answer") {
const iceState = peerConnection.iceConnectionState;
if (iceState !== "connected" && iceState !== "completed") {
this._startConnectTimeout(ICE_TIMEOUT);
}
this._logEvent("answer", event.answer);
this._handleAnswer(peerConnection, event);
@@ -609,8 +495,7 @@ export class HaWebRtcPlayer extends LitElement {
}
}
if (event.type === "error") {
// Often temporary, like the backend still closing the previous session
this._retryOrFail({ type: "start_failed", message: event.message });
this._failStart({ type: "start_failed", message: event.message });
}
}
@@ -702,13 +587,8 @@ export class HaWebRtcPlayer extends LitElement {
}
private _failStart(error: WebRtcPlayerError) {
// The camera can reject the microphone, keep the video running without it.
// After retries it is the connection that failed, not the microphone.
if (
this._localReturnAudioTrack &&
this._microphoneConnecting &&
this._retries === 0
) {
// The camera can reject the microphone, keep the video running without it
if (this._localReturnAudioTrack && this._microphoneConnecting) {
showToast(this, {
message: this._localize(
"ui.components.web-rtc-player.microphone_connect_failed",
@@ -719,19 +599,12 @@ export class HaWebRtcPlayer extends LitElement {
return;
}
this._error = error;
this._reconnectPoster = undefined;
this._cleanUp();
// Tell the camera stream this player has no video, so it can fall back
fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
}
private _cleanUp() {
this._cleanUpCount++;
this._microphoneRequest++;
clearTimeout(this._connectTimeout);
this._connectTimeout = undefined;
clearTimeout(this._retryTimeout);
this._retryTimeout = undefined;
if (this._remoteStream) {
this._remoteStream.getTracks().forEach((track) => {
track.stop();
@@ -739,15 +612,9 @@ export class HaWebRtcPlayer extends LitElement {
this._remoteStream = undefined;
}
if (
this._localReturnAudioTrack ||
this._microphoneConnecting ||
this._retryMicrophoneTrack
) {
if (this._localReturnAudioTrack || this._microphoneConnecting) {
this._localReturnAudioTrack?.stop();
this._localReturnAudioTrack = undefined;
this._retryMicrophoneTrack?.stop();
this._retryMicrophoneTrack = undefined;
this._microphoneConnecting = false;
this._fireMicrophoneChanged();
}
@@ -799,10 +666,6 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("loadedData", data);
this._stopTimer();
if (this._retries >= MAX_RETRIES) {
this._allIceServers = true;
}
this._retries = 0;
// Without a track the microphone is still waiting for permission
if (this._microphoneConnecting && this._localReturnAudioTrack) {
+3 -8
View File
@@ -6,7 +6,6 @@ import { customElement, property, query, state } from "lit/decorators";
import type { ContextType } from "@lit/context";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { localizeYamlErrorReason } from "../common/translations/localize-yaml-error";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import { showToast } from "../util/toast";
@@ -181,7 +180,7 @@ export class HaYamlEditor extends LitElement {
let errorMsg: string | undefined;
let yamlError: {
mark?: { position: number; line: number; column: number };
reason?: string;
message?: string;
} | null = null;
if (this._yaml) {
@@ -190,12 +189,8 @@ export class HaYamlEditor extends LitElement {
} catch (err: any) {
// Invalid YAML
isValid = false;
const reason = localizeYamlErrorReason(
this._i18n!.localize,
err.reason
);
yamlError = { mark: err.mark, reason };
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
yamlError = err;
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
}
} else {
parsed = {};
+11 -116
View File
@@ -2,8 +2,6 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { keyed } from "lit/directives/keyed";
import { isInteractiveElement } from "../../common/dom/is-interactive-element";
import "../ha-ripple";
import { HaListItemBase } from "./ha-list-item-base";
@@ -17,19 +15,6 @@ import { HaListItemBase } from "./ha-list-item-base";
* roving tabindex, the host is the tab stop and the inner element carries
* `tabindex="-1"`. For a non-interactive row, use `ha-list-item-base`.
*
* Only the headline and supporting text are rendered inside the `<a>` or
* `<button>`, and its hit area is stretched over the whole row. The `start`
* and `end` slots sit next to it, so controls placed there stay separate
* controls for assistive technology. Slotted elements are raised above the
* stretched hit area, except for icons, badges, plain text and empty dividers
* without `tabindex`, so clicks and hover on those reach the row. To keep such
* an element clickable on its own, like a drag handle, give it `z-index: 1`.
*
* Subclasses customize the row with `_renderStart()`, `_renderContent()` and
* `_renderEnd()`. `_renderInner()` is not used.
*
* @csspart base - The row container.
* @csspart control - The inner `<a>` or `<button>`.
* @csspart ripple - The ripple effect element.
*
* @attr {string} href - URL. When set, renders an `<a>` instead of a `<button>`.
@@ -55,27 +40,11 @@ export class HaListItemButton extends HaListItemBase {
this._item?.click();
}
protected override render(): TemplateResult {
return this._renderBase(this._renderContent());
}
protected _renderBase(content: TemplateResult): TemplateResult {
// The ripple binds to #item on connect, so render it after the control and
// recreate it with the control
return html`<div part="base" class="base" @click=${this._handleBaseClick}>
${this._renderStart()}${this._renderControl(content)}${keyed(
`${this.href !== undefined}-${this.interactive}`,
this._renderRipple()
)}${this._renderEnd()}
</div>`;
}
/** Renders the native `<a>` or `<button>` around the row content. */
protected _renderControl(content: TemplateResult): TemplateResult {
protected _renderBase(inner: TemplateResult): TemplateResult {
if (this.href !== undefined) {
return html`<a
part="control"
class="control"
part="base"
class="base interactive"
id="item"
href=${ifDefined(this.disabled ? undefined : this.href)}
target=${ifDefined(this.target)}
@@ -84,18 +53,18 @@ export class HaListItemButton extends HaListItemBase {
tabindex="-1"
aria-disabled=${this.disabled ? "true" : "false"}
>
${content}
${this._renderRipple()}${inner}
</a>`;
}
return html`<button
part="control"
class="control"
part="base"
class="base interactive"
id="item"
type="button"
?disabled=${this.disabled}
tabindex="-1"
>
${content}
${this._renderRipple()}${inner}
</button>`;
}
@@ -107,48 +76,18 @@ export class HaListItemButton extends HaListItemBase {
></ha-ripple>`;
}
// Raised slot content that is not a control, like a wrapper without
// controls, still activates the row
private _handleBaseClick(ev: MouseEvent): void {
const item = this._item;
if (!item || this.disabled) {
return;
}
const path = ev.composedPath();
const baseIndex = path.indexOf(ev.currentTarget as EventTarget);
for (const node of path.slice(0, baseIndex)) {
if (node === item || isInteractiveElement(node)) {
return;
}
}
ev.stopPropagation();
// A copy of the event keeps modifier keys, so links can open in a new tab
item.dispatchEvent(new MouseEvent("click", ev));
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
cursor: pointer;
--ha-ripple-color: var(--primary-text-color);
/* Smooth the hover when moving between controls in the row */
--ha-ripple-hover-duration: var(--ha-animation-duration-fast);
}
:host([disabled]) {
cursor: default;
}
.base {
isolation: isolate;
}
.control {
display: flex;
align-items: center;
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 0;
.base.interactive {
width: 100%;
border: none;
background: transparent;
color: inherit;
@@ -158,52 +97,8 @@ export class HaListItemButton extends HaListItemBase {
appearance: none;
cursor: inherit;
}
/* Stretch the hit area of the control over the whole row */
.control::before {
content: "";
position: absolute;
inset: 0;
border-radius: var(--ha-list-item-focus-radius);
}
.control:focus-visible {
outline: none;
}
.control:focus-visible::before {
outline: var(--ha-list-item-focus-width) solid var(--ha-color-focus);
outline-offset: var(--ha-list-item-focus-offset);
}
/*
* Slotted content is painted above the hit area, so it can be clicked.
* Icons, badges, text and empty dividers go below it, so clicks on
* them reach the row.
* Some icons are positioned, so they need a negative z-index.
*/
::slotted([slot="start"]),
::slotted([slot="end"]) {
position: relative;
z-index: 1;
}
::slotted(
:is(
ha-svg-icon,
ha-icon,
ha-icon-next,
ha-state-icon,
ha-domain-icon,
ha-floor-icon,
ha-spinner,
ha-progress-ring,
ha-user-badge,
ha-person-badge,
ha-tree-indicator,
ha-app-icon,
state-badge,
img,
span,
div:empty
):not([tabindex])
) {
z-index: -1;
:host([disabled]) .base.interactive {
color: var(--disabled-text-color);
}
`,
];
+11 -25
View File
@@ -34,8 +34,6 @@ 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")
@@ -45,12 +43,6 @@ 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(
@@ -92,21 +84,19 @@ export class HaRowItem extends LitElement {
}
protected _renderInner(): TemplateResult {
return html`
${this._renderStart()}${this._renderContent()}${this._renderEnd()}
`;
}
protected _renderContent(): TemplateResult {
const hasContent = this._slotController.test("content");
return html`<div part="content" class="content">
${
hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()
}
</div>`;
return html`
${this._renderStart()}
<div part="content" class="content">
${
hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()
}
</div>
${this._renderEnd()}
`;
}
protected _renderStart(): TemplateResult {
@@ -200,10 +190,6 @@ export class HaRowItem extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
}
:host([wrap-headline]) .headline,
:host([wrap-supporting-text]) .supporting {
white-space: normal;
}
`;
}
@@ -1,128 +0,0 @@
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,6 +77,7 @@ 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,
@@ -87,7 +88,6 @@ 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,6 +120,105 @@ 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 _renderBase(inner: TemplateResult): TemplateResult {
if (!this.interactive) {
return html`<div part="base" class="base" id="item">${inner}</div>`;
}
return super._renderBase(inner);
}
// 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;
@@ -1846,5 +1945,6 @@ export class HaMediaPlayerBrowse extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-media-player-browse": HaMediaPlayerBrowse;
"ha-media-player-browse-list-item": HaMediaPlayerBrowseListItem;
}
}
@@ -1,20 +1,11 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../common/array/ensure-array";
import { consume } from "../../../common/decorators/consume";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
apiContext,
entitiesContext,
statesContext,
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
@@ -42,16 +33,7 @@ import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@customElement("ha-dialog-target-details")
class DialogTargetDetails extends LitElement implements HassDialog {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: TargetDetailsDialogParams;
@@ -68,8 +50,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private _exclusionsRequest = 0;
private _deviceIntegrationLookup = memoizeOne(
(entities: HomeAssistant["entities"] | null) =>
getDeviceIntegrationLookup(Object.values(entities ?? {}))
(entities: HomeAssistant["entities"]) =>
getDeviceIntegrationLookup(Object.values(entities))
);
public showDialog(params: TargetDetailsDialogParams): void {
@@ -77,17 +59,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
this._initialExclusions = params.exclusions ?? [];
this._exclusions = this._initialExclusions;
this._opened = true;
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (
changedProperties.has("_params") &&
this._params &&
!this._params.exclusions &&
this._params.exclude
) {
this._resolveExclusions(this._params);
if (!params.exclusions && params.exclude) {
this._resolveExclusions(params);
}
}
@@ -96,7 +69,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
let exclusions: TargetExclusion[];
try {
exclusions = await resolveItemExclusions(
this._api.callWS,
this.hass,
{ type: params.type, id: params.itemId },
params.exclude,
params.selector
@@ -155,8 +128,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private async _loadEntitySources(): Promise<void> {
try {
this._entitySources = await fetchEntitySourcesWithCache(
this._api.callWS,
this._states ?? {}
this.hass.callWS,
this.hass.states
);
} catch (err) {
// eslint-disable-next-line no-console
@@ -182,7 +155,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return true;
}
const deviceIntegrations = this._hasIntegration({ target })
? this._deviceIntegrationLookup(this._entities)
? this._deviceIntegrationLookup(this.hass.entities)
: undefined;
return ensureArray(target.device).some((d) =>
filterSelectorDevices(d, device, deviceIntegrations)
@@ -244,19 +217,19 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return html`
<ha-adaptive-dialog
.open=${this._opened}
header-title=${this._localize(
header-title=${this.hass.localize(
"ui.components.target-picker.target_details"
)}
@closed=${this._dialogClosed}
>
<div class="type-wrapper">
<div class="type-label">
${this._localize(
${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}
</div>
<ha-list-base
.ariaLabel=${`${this._localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
.ariaLabel=${`${this.hass.localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
wrap-focus
>
${
@@ -289,10 +262,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
appearance="plain"
@click=${this.closeDialog}
>
${this._localize("ui.common.cancel")}
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._apply}>
${this._localize("ui.common.apply")}
${this.hass.localize("ui.common.apply")}
</ha-button>
</ha-dialog-footer>
`
@@ -3,24 +3,14 @@ import memoizeOne from "memoize-one";
import {
consumeContext,
ContextController,
ContextSubscriptionController,
} from "../../common/decorators/consume";
import {
apiContext,
registriesContext,
statesContext,
} from "../../data/context";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
explainTarget,
type HassServiceTargetWithExclude,
type TargetItem,
} from "../../data/target";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantRegistries,
} from "../../types";
import type { HomeAssistant } from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { filterTargetEntries } from "./filter-target-entries";
import { targetExpandGroupContext } from "./target-expand-group-context";
@@ -36,6 +26,7 @@ import {
export type TargetExclusionsHost = ReactiveControllerHost &
HTMLElement & {
hass: HomeAssistant;
value?: HassServiceTargetWithExclude;
allowExclude: boolean;
primaryEntitiesOnly?: boolean;
@@ -64,31 +55,11 @@ export class TargetExclusionsController extends ContextController {
@consumeContext({ context: targetExpandGroupContext, subscribe: true })
private _expandGroup?: boolean;
@consumeContext({ context: registriesContext, subscribe: true })
private _registries?: HomeAssistantRegistries;
@consumeContext({
context: apiContext,
subscribe: true,
transform: (api) => api?.callWS,
})
private _callWS?: HomeAssistantApi["callWS"];
private _states?: HomeAssistant["states"] | null;
private _request = 0;
constructor(host: TargetExclusionsHost) {
super(host);
this._host = host;
// States change all the time, so they only resolve again once loaded.
new ContextSubscriptionController(host, statesContext, (states) => {
const loaded = !this._states && !!states;
this._states = states;
if (loaded) {
this.resolve();
}
});
}
protected contextUpdated() {
@@ -107,9 +78,9 @@ export class TargetExclusionsController extends ContextController {
*/
public async resolve({ force = false }: { force?: boolean } = {}) {
const request = ++this._request;
const { value, allowExclude } = this._host;
const { hass, value, allowExclude } = this._host;
if (
!this._ready ||
!hass ||
!value ||
(!force &&
!hasIncludedTargets(value.exclude) &&
@@ -169,7 +140,7 @@ export class TargetExclusionsController extends ContextController {
item: TargetItem
): Promise<TargetExclusion | undefined> {
const { value } = this._host;
if (!this._ready || !value) {
if (!value) {
return undefined;
}
const explained = await this._explain({
@@ -181,19 +152,9 @@ export class TargetExclusionsController extends ContextController {
]?.[0];
}
private get _ready(): boolean {
return !!(
this._callWS &&
this._states &&
this._registries?.entities &&
this._registries.devices &&
this._registries.areas
);
}
private _explain(target: HassServiceTargetWithExclude) {
return explainTarget(
this._callWS!,
this._host.hass.callWS,
target,
this.expandGroup,
this._host.primaryEntitiesOnly ?? true
@@ -209,9 +170,8 @@ export class TargetExclusionsController extends ContextController {
// What the picker's filters leave of a target, narrowed like the target's
// row, so what an exclusion takes out matches what the row counts.
private _keeps(): TargetNarrowing {
const states = this._states!;
const { entities, devices, areas } = this._registries!;
const {
hass,
deviceFilter,
entityFilter,
includeDomains,
@@ -220,10 +180,10 @@ export class TargetExclusionsController extends ContextController {
} = this._host;
return (include) =>
filterTargetEntries(
states,
entities,
devices,
areas,
hass.states,
hass.entities,
hass.devices,
hass.areas,
include,
{
referenced_areas: include.referenced_areas,
@@ -145,7 +145,7 @@ export const computeExclusionsPerTarget = (
* `expandGroup` follows what the target is used for.
*/
export const resolveItemExclusions = async (
callWS: HomeAssistant["callWS"],
hass: HomeAssistant,
item: TargetItem,
exclude: HassServiceTarget | undefined,
primaryEntitiesOnly?: boolean,
@@ -155,7 +155,7 @@ export const resolveItemExclusions = async (
return [];
}
const explained = await explainTarget(
callWS,
hass.callWS,
{ [`${item.type}_id`]: [item.id], exclude },
expandGroup,
primaryEntitiesOnly
-201
View File
@@ -1,201 +0,0 @@
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));
};
-115
View File
@@ -1,115 +0,0 @@
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";
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
export const MJPEG_STREAM = "mjpeg";
export type CameraStreamType = StreamType | typeof MJPEG_STREAM;
export interface CameraStream {
type: CameraStreamType;
visible: boolean;
}
/** What a player reported, `hasVideo: false` means it failed to load. */
export interface CameraStreamTracks {
hasAudio: boolean;
hasVideo: boolean;
}
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;
}
/**
* Decides which players are rendered and which one is shown. Hidden players
* are still loading in the background.
*/
export const selectCameraStreams = ({
supportedTypes,
hlsStreams,
webRtcStreams,
webRtcFirst,
}: CameraStreamSelectionOptions): CameraStream[] => {
if (!supportedTypes) {
return [];
}
if (supportedTypes.length === 0) {
// doesn't support any stream type, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
if (supportedTypes.length === 1) {
// only 1 stream type, no need to choose
if (
(supportedTypes[0] === STREAM_TYPE_HLS &&
hlsStreams?.hasVideo === false) ||
(supportedTypes[0] === STREAM_TYPE_WEB_RTC &&
webRtcStreams?.hasVideo === false)
) {
// stream failed to load, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
return [{ type: supportedTypes[0], visible: true }];
}
const hlsFailed = hlsStreams?.hasVideo === false;
if (webRtcStreams?.hasVideo) {
// WebRTC is preferred whenever it plays, it has the lowest latency
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
if (webRtcStreams?.hasVideo === false) {
if (hlsFailed) {
// both streams failed to load, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
return [{ type: STREAM_TYPE_HLS, visible: true }];
}
// WebRTC is still loading
if (hlsFailed || (webRtcFirst && !hlsStreams)) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
return [
{ type: STREAM_TYPE_HLS, visible: true },
{ 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)
);
};
+4 -6
View File
@@ -7,15 +7,13 @@ import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-pro
import type { HomeAssistant, HomeAssistantApi } from "../types";
import { fileDownload } from "../util/file_download";
import { getSignedPath } from "./auth";
import type { StreamType } from "./camera-streams";
export const CAMERA_ORIENTATIONS = [1, 2, 3, 4, 6, 8];
export {
STREAM_TYPE_HLS,
STREAM_TYPE_WEB_RTC,
type StreamType,
} from "./camera-streams";
export const STREAM_TYPE_HLS = "hls";
export const STREAM_TYPE_WEB_RTC = "web_rtc";
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
export { CameraEntityFeature } from "./feature/camera_entity_feature";
+4 -10
View File
@@ -220,30 +220,24 @@ export class ConfigEntryStream {
private _entries: ConfigEntry[] = [];
processMessage(message: ConfigEntryUpdate[]) {
let entries =
message.length === 0 ||
message.some((configEntry) => configEntry.type === null)
? []
: [...this._entries];
message.forEach((configEntry) => {
if (configEntry.type === null || configEntry.type === "added") {
entries.push(configEntry.entry);
this._entries.push(configEntry.entry);
return;
}
if (configEntry.type === "removed") {
entries = entries.filter(
this._entries = this._entries.filter(
(entry) => entry.entry_id !== configEntry.entry.entry_id
);
return;
}
if (configEntry.type === "updated") {
const newEntry = configEntry.entry;
entries = entries.map((entry) =>
this._entries = this._entries.map((entry) =>
entry.entry_id === newEntry.entry_id ? newEntry : entry
);
}
});
this._entries = entries;
return entries;
return this._entries;
}
}
-128
View File
@@ -1,128 +0,0 @@
import type { HassEntities } from "home-assistant-js-websocket";
import type { ConfigEntry } from "./config_entries";
import type { DataEntryFlowProgress } from "./data_entry_flow";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import type { IntegrationSetup } from "./integration";
import type { RepairsIssue } from "./repairs";
import type { LoggedError } from "./system_log";
import { getLoggedErrorIntegration } from "./system_log";
export type HealthStatus = "ok" | "warning" | "error" | "info";
export type OverallHealthStatus = Exclude<HealthStatus, "info">;
export interface ConfigEntryHealth {
status: OverallHealthStatus;
state: HealthStatus;
entities: {
status: HealthStatus;
total: number;
unavailable: EntityRegistryEntry[];
};
repairs: { status: HealthStatus; issues: RepairsIssue[] };
logs: { status: HealthStatus; errors: number; warnings: number };
flows: { status: HealthStatus; flows: DataEntryFlowProgress[] };
setupSeconds?: number;
}
const ATTENTION_SOURCES = ["reauth", "reconfigure"];
const STATE_STATUS: Record<ConfigEntry["state"], HealthStatus> = {
loaded: "ok",
setup_in_progress: "info",
unload_in_progress: "info",
not_loaded: "info",
setup_retry: "warning",
setup_error: "error",
migration_error: "error",
failed_unload: "error",
};
const worst = (statuses: HealthStatus[]): OverallHealthStatus =>
statuses.includes("error")
? "error"
: statuses.includes("warning")
? "warning"
: "ok";
export const computeConfigEntryHealth = (
entry: ConfigEntry,
entities: EntityRegistryEntry[],
states: HassEntities,
issues: RepairsIssue[],
log: LoggedError[],
flows: DataEntryFlowProgress[],
setups: IntegrationSetup[],
// Library loggers from the manifest, so dependency errors count too.
loggers: string[] = []
): ConfigEntryHealth => {
const enabled = entities.filter((entity) => !entity.disabled_by);
// Entities without a state yet (e.g. while the entry is not loaded) count as unavailable.
const unavailable = enabled.filter(
(entity) =>
!states[entity.entity_id] ||
states[entity.entity_id].state === "unavailable"
);
const entryIssues = issues.filter(
(issue) =>
!issue.ignored && (issue.issue_domain ?? issue.domain) === entry.domain
);
const entryLog = log.filter(
(item) =>
getLoggedErrorIntegration(item) === entry.domain ||
loggers.some(
(logger) => item.name === logger || item.name.startsWith(`${logger}.`)
)
);
const sumCount = (levels: LoggedError["level"][]) =>
entryLog
.filter((item) => levels.includes(item.level))
.reduce((total, item) => total + item.count, 0);
const errors = sumCount(["error", "critical"]);
const warnings = sumCount(["warning"]);
const entryFlows = flows.filter(
(flow) =>
flow.handler === entry.domain &&
flow.context.entry_id === entry.entry_id &&
ATTENTION_SOURCES.includes(flow.context.source)
);
// A disabled entry that failed to unload still needs a restart.
const state: HealthStatus =
entry.disabled_by && entry.state !== "failed_unload"
? "info"
: STATE_STATUS[entry.state];
const health: ConfigEntryHealth = {
status: "ok",
state,
entities: {
status: entry.disabled_by || !unavailable.length ? "ok" : "warning",
total: enabled.length,
unavailable,
},
repairs: {
status: entryIssues.some((issue) => issue.severity !== "warning")
? "error"
: entryIssues.length
? "warning"
: "ok",
issues: entryIssues,
},
logs: {
status: errors ? "error" : warnings ? "warning" : "ok",
errors,
warnings,
},
flows: { status: entryFlows.length ? "error" : "ok", flows: entryFlows },
setupSeconds: setups.find((setup) => setup.domain === entry.domain)
?.seconds,
};
health.status = worst([
state,
health.entities.status,
health.repairs.status,
health.logs.status,
health.flows.status,
]);
return health;
};
+7 -7
View File
@@ -1,6 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
import type {
DataEntryFlowProgress,
DataEntryFlowStep,
@@ -30,11 +30,11 @@ const HEADERS = {
};
export const createConfigFlow = (
callApi: HomeAssistant["callApi"],
hass: HomeAssistant,
handler: string,
entry_id?: string
) =>
callApi<DataEntryFlowStep>(
hass.callApi<DataEntryFlowStep>(
"POST",
"config/config_entries/flow",
{
@@ -53,11 +53,11 @@ export const fetchConfigFlow = (hass: HomeAssistant, flowId: string) =>
);
export const handleConfigFlowStep = (
callApi: HomeAssistant["callApi"],
hass: HomeAssistant,
flowId: string,
data: Record<string, any>
) =>
callApi<DataEntryFlowStep>(
hass.callApi<DataEntryFlowStep>(
"POST",
`config/config_entries/flow/${flowId}`,
data,
@@ -78,10 +78,10 @@ export const deleteConfigFlow = (
// The backend indexes a single key, and only these two exist.
export const getConfigFlowHandlers = (
callApi: HomeAssistantApi["callApi"],
hass: HomeAssistant,
type?: "helper" | "integration"
) =>
callApi<string[]>(
hass.callApi<string[]>(
"GET",
`config/config_entries/flow_handlers${type ? `?type=${type}` : ""}`
);
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export interface Counter {
id: string;
@@ -25,10 +25,10 @@ export const fetchCounter = (hass: HomeAssistant) =>
hass.callWS<Counter[]>({ type: "counter/list" });
export const createCounter = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
values: CounterMutableParams
) =>
callWS<Counter>({
hass.callWS<Counter>({
type: "counter/create",
...values,
});
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { HomeAssistant, HomeAssistantApi } from "../../types";
import type { HomeAssistant } from "../../types";
import type { ConfigEntry } from "../config_entries";
import type {
EntityRegistryDisplayEntry,
@@ -231,11 +231,11 @@ export const devicesInArea = (devices: DeviceRegistryEntry[], areaId: string) =>
devices.filter((device) => device.area_id === areaId);
export const updateDeviceRegistryEntry = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
deviceId: string,
updates: Partial<DeviceRegistryEntryMutableParams>
) =>
callWS<DeviceRegistryEntry>({
hass.callWS<DeviceRegistryEntry>({
type: "config/device_registry/update",
device_id: deviceId,
...updates,
+2
View File
@@ -81,6 +81,7 @@ export const emptyGridSourceEnergyPreference =
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
cost_adjustment_day: 0,
});
export const emptySolarEnergyPreference =
@@ -168,6 +169,7 @@ export interface GridSourceTypeEnergyPreference {
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
cost_adjustment_day: number;
name?: string;
}
+23 -23
View File
@@ -6,7 +6,6 @@ import {
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import type { RelatedIdSets } from "../../common/search/related-context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
@@ -52,14 +51,6 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
];
export interface GetEntitiesOptions {
states: HomeAssistant["states"];
entities: HomeAssistant["entities"];
devices: HomeAssistant["devices"];
areas: HomeAssistant["areas"];
floors: HomeAssistant["floors"];
localize: LocalizeFunc;
language: string;
translationMetadata: HomeAssistant["translationMetadata"];
includeDomains?: string[];
excludeDomains?: string[];
entityFilter?: HaEntityPickerEntityFilterFunc;
@@ -72,11 +63,20 @@ export interface GetEntitiesOptions {
}
export const getEntities = (
options: GetEntitiesOptions
hass: Pick<
HomeAssistant,
| "states"
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
| "localize"
>,
options?: GetEntitiesOptions
): EntityComboBoxItem[] => {
const {
states,
localize,
includeDomains,
excludeDomains,
entityFilter,
@@ -86,11 +86,11 @@ export const getEntities = (
excludeEntities,
value,
idPrefix = "",
} = options;
} = options ?? {};
let items: EntityComboBoxItem[];
let entityIds = Object.keys(states);
let entityIds = Object.keys(hass.states);
// These run over every entity, so use Sets for O(1) membership instead of
// repeated Array.includes scans.
@@ -126,21 +126,21 @@ export const getEntities = (
// of inside the map over (potentially thousands of) entities.
const domainNames = new Map<string, string>();
const displayOptions = {
entities: options.entities,
devices: options.devices,
areas: options.areas,
floors: options.floors,
language: options.language,
translationMetadata: options.translationMetadata,
entities: hass.entities,
devices: hass.devices,
areas: hass.areas,
floors: hass.floors,
language: hass.language,
translationMetadata: hass.translationMetadata,
};
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = states[entityId];
const stateObj = hass.states[entityId];
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
domainName = domainToName(localize, domain);
domainName = domainToName(hass.localize, domain);
domainNames.set(domain, domainName);
}
@@ -156,7 +156,7 @@ export const getEntities = (
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
...computeEntitySearchLabels(stateObj, displayOptions),
...computeEntitySearchLabels(stateObj, hass),
domainName: domainName || null,
entityId: entityId,
},
+9 -9
View File
@@ -5,7 +5,7 @@ import memoizeOne from "memoize-one";
import { computeDomain } from "../../common/entity/compute_domain";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { debounce } from "../../common/util/debounce";
import type { HomeAssistant, HomeAssistantApi } from "../../types";
import type { HomeAssistant } from "../../types";
import type { LightColor } from "../light";
import type { NextNamePart, RegistryEntry } from "../registry";
import type { Segment } from "../vacuum";
@@ -284,11 +284,11 @@ export const getExtendedEntityRegistryEntries = (
});
export const updateEntityRegistryEntry = (
callWS: HomeAssistantApi["callWS"],
hass: Pick<HomeAssistant, "callWS">,
entityId: string,
updates: Partial<EntityRegistryEntryUpdateParams>
): Promise<UpdateEntityRegistryEntryResult> =>
callWS({
hass.callWS({
type: "config/entity_registry/update",
entity_id: entityId,
...updates,
@@ -388,11 +388,11 @@ export interface AutomaticEntityIdOverrides {
}
export const getAutomaticEntityIds = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
entity_ids: string[],
overrides?: Record<string, AutomaticEntityIdOverrides>
) =>
callWS<Record<string, string | null>>({
hass.callWS<Record<string, string | null>>({
type: "config/entity_registry/get_automatic_entity_ids",
entity_ids,
...(overrides && { overrides }),
@@ -403,22 +403,22 @@ export const getAutomaticEntityIds = (
* the area. Returns the entity ID of each entity after the change.
*/
export const updateEntitiesArea = async (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
entityIds: string[],
areaId: string
): Promise<Record<string, string>> => {
await Promise.all(
entityIds.map((entityId) =>
updateEntityRegistryEntry(callWS, entityId, { area_id: areaId })
updateEntityRegistryEntry(hass, entityId, { area_id: areaId })
)
);
const newEntityIds = await getAutomaticEntityIds(callWS, entityIds);
const newEntityIds = await getAutomaticEntityIds(hass, entityIds);
const result: Record<string, string> = {};
for (const entityId of entityIds) {
const newEntityId = newEntityIds[entityId];
if (newEntityId) {
// eslint-disable-next-line no-await-in-loop
await updateEntityRegistryEntry(callWS, entityId, {
await updateEntityRegistryEntry(hass, entityId, {
new_entity_id: newEntityId,
});
}
+6
View File
@@ -49,12 +49,17 @@ export interface SecurityAlertEntityConfig {
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
hidden_entities?: string[];
}
export interface MaintenanceFrontendSystemData {
hidden_entities?: string[];
}
export interface ClimateFrontendSystemData {
hidden_entities?: string[];
}
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
@@ -74,6 +79,7 @@ declare global {
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
maintenance: MaintenanceFrontendSystemData;
climate: ClimateFrontendSystemData;
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export interface InputBoolean {
id: string;
@@ -17,10 +17,10 @@ export const fetchInputBoolean = (hass: HomeAssistant) =>
hass.callWS<InputBoolean[]>({ type: "input_boolean/list" });
export const createInputBoolean = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
values: InputBooleanMutableParams
) =>
callWS<InputBoolean>({
hass.callWS<InputBoolean>({
type: "input_boolean/create",
...values,
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export interface InputButton {
id: string;
@@ -15,10 +15,10 @@ export const fetchInputButton = (hass: HomeAssistant) =>
hass.callWS<InputButton[]>({ type: "input_button/list" });
export const createInputButton = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
values: InputButtonMutableParams
) =>
callWS<InputButton>({
hass.callWS<InputButton>({
type: "input_button/create",
...values,
});
+2 -2
View File
@@ -45,10 +45,10 @@ export const fetchInputDateTime = (hass: HomeAssistant) =>
hass.callWS<InputDateTime[]>({ type: "input_datetime/list" });
export const createInputDateTime = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
values: InputDateTimeMutableParams
) =>
callWS<InputDateTime>({
hass.callWS<InputDateTime>({
type: "input_datetime/create",
...values,
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export interface InputNumber {
id: string;
@@ -29,10 +29,10 @@ export const fetchInputNumber = (hass: HomeAssistant) =>
hass.callWS<InputNumber[]>({ type: "input_number/list" });
export const createInputNumber = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
values: InputNumberMutableParams
) =>
callWS<InputNumber>({
hass.callWS<InputNumber>({
type: "input_number/create",
...values,
});
+3 -3
View File
@@ -2,7 +2,7 @@ import type {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
interface InputSelectEntityAttributes extends HassEntityAttributeBase {
options: string[];
@@ -41,10 +41,10 @@ export const fetchInputSelect = (hass: HomeAssistant) =>
hass.callWS<InputSelect[]>({ type: "input_select/list" });
export const createInputSelect = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
values: InputSelectMutableParams
) =>
callWS<InputSelect>({
hass.callWS<InputSelect>({
type: "input_select/create",
...values,
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export interface InputText {
id: string;
@@ -36,10 +36,10 @@ export const fetchInputText = (hass: HomeAssistant) =>
hass.callWS<InputText[]>({ type: "input_text/list" });
export const createInputText = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
values: InputTextMutableParams
) =>
callWS<InputText>({
hass.callWS<InputText>({
type: "input_text/create",
...values,
});
+2 -2
View File
@@ -196,8 +196,8 @@ export const fetchIntegrationManifest = (
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
.then(sanitizeManifest);
export const fetchIntegrationSetups = (callWS: HomeAssistant["callWS"]) =>
callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
export const fetchIntegrationLogInfo = (conn) =>
conn.sendMessagePromise({
+3 -3
View File
@@ -1,9 +1,9 @@
import type { Connection, HassEventBase } from "home-assistant-js-websocket";
import { getCollection } from "home-assistant-js-websocket";
import type { ConfigPath } from "../common/util/config-path";
import type { HuiBadge } from "../panels/lovelace/badges/hui-badge";
import type { HuiCard } from "../panels/lovelace/cards/hui-card";
import type { HuiSection } from "../panels/lovelace/sections/hui-section";
import type { LovelacePath } from "../panels/lovelace/editor/lovelace-path";
import type { Lovelace } from "../panels/lovelace/types";
import type { HomeAssistant } from "../types";
import type { LovelaceSectionConfig } from "./lovelace/config/section";
@@ -21,7 +21,7 @@ export interface LovelaceViewElement extends HTMLElement {
narrow?: boolean;
// Temporary compatibility: custom view layouts still read the view index
index?: number;
path?: ConfigPath;
path?: LovelacePath;
cards?: HuiCard[];
badges?: HuiBadge[];
sections?: HuiSection[];
@@ -34,7 +34,7 @@ export interface LovelaceSectionElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
preview?: boolean;
path?: ConfigPath;
path?: LovelacePath;
cards?: HuiCard[];
badges?: HuiBadge[];
sections?: HuiSection[];
+7 -11
View File
@@ -209,15 +209,7 @@ export const watchForNewMatterDevice = (
callback: (device: DeviceRegistryEntry) => void
): UnsubscribeFunc => {
let curMatterDevices: Set<string> | undefined;
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) => {
const unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
if (!curMatterDevices) {
curMatterDevices = new Set(
Object.values(entries)
@@ -234,11 +226,15 @@ export const watchForNewMatterDevice = (
!curMatterDevices!.has(device.id)
);
if (newMatterDevices.length) {
unsubscribe();
unsubDeviceReg();
curMatterDevices = undefined;
callback(newMatterDevices[0]);
}
});
return unsubscribe;
return () => {
unsubDeviceReg();
curMatterDevices = undefined;
};
};
export const redirectOnNewMatterDevice = (
+2 -3
View File
@@ -21,9 +21,8 @@ export const findOTBRInfoForDataset = (
}
const preferredAddress = dataset.preferred_extended_address;
const preferred = preferredAddress ? otbrInfo[preferredAddress] : undefined;
if (preferred?.extended_pan_id === dataset.extended_pan_id) {
return preferred;
if (preferredAddress && otbrInfo[preferredAddress]) {
return otbrInfo[preferredAddress];
}
return Object.values(otbrInfo).find(
+2 -2
View File
@@ -15,7 +15,7 @@ export const regenerateEntityIds = async (
hass: HomeAssistant,
entities: string[]
): Promise<void> => {
const entityIdsMapping = await getAutomaticEntityIds(hass.callWS, entities);
const entityIdsMapping = await getAutomaticEntityIds(hass, entities);
const entityIdsEntries = Object.entries(entityIdsMapping);
@@ -95,7 +95,7 @@ export const regenerateEntityIds = async (
entityIdsEntries
.filter(([oldId, newId]) => newId && oldId !== newId)
.forEach(([oldEntityId, newEntityId]) =>
updateEntityRegistryEntry(hass.callWS, oldEntityId, {
updateEntityRegistryEntry(hass, oldEntityId, {
new_entity_id: newEntityId!,
}).catch((err: any) => {
showAlertDialog(element, {
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
export const weekdays = [
"sunday",
@@ -33,10 +33,10 @@ export const fetchSchedule = (hass: HomeAssistant) =>
hass.callWS<Schedule[]>({ type: "schedule/list" });
export const createSchedule = (
callWS: HomeAssistantApi["callWS"],
hass: HomeAssistant,
values: ScheduleMutableParams
) =>
callWS<Schedule>({
hass.callWS<Schedule>({
type: "schedule/create",
...values,
});
+2 -2
View File
@@ -20,10 +20,10 @@ export interface SensorDeviceClassUnits {
}
export const getSensorDeviceClassConvertibleUnits = (
callWS: HomeAssistant["callWS"],
hass: HomeAssistant,
deviceClass: string
): Promise<SensorDeviceClassUnits> =>
callWS({
hass.callWS({
type: "sensor/device_class_convertible_units",
device_class: deviceClass,
});
+2 -2
View File
@@ -23,8 +23,8 @@ export interface LoggedError {
first_occurred: number;
}
export const fetchSystemLog = async (callWS: HomeAssistant["callWS"]) => {
const log = await callWS<LoggedError[]>({ type: "system_log/list" });
export const fetchSystemLog = async (hass: HomeAssistant) => {
const log = await hass.callWS<LoggedError[]>({ type: "system_log/list" });
for (const error of log) {
error.level = error.level.toLowerCase() as LoggedError["level"];
}
+3 -6
View File
@@ -11,7 +11,7 @@ import secondsToDuration from "../common/datetime/seconds_to_duration";
import { formatNumericDuration } from "../common/datetime/format_duration";
import type { FormatEntityStateFunc } from "../common/translations/entity-state";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { HomeAssistant } from "../types";
import type { FrontendLocaleData } from "./translation";
export type TimerEntity = HassEntityBase & {
@@ -48,11 +48,8 @@ export interface TimerMutableParams {
export const fetchTimer = (hass: HomeAssistant) =>
hass.callWS<Timer[]>({ type: "timer/list" });
export const createTimer = (
callWS: HomeAssistantApi["callWS"],
values: TimerMutableParams
) =>
callWS<Timer>({
export const createTimer = (hass: HomeAssistant, values: TimerMutableParams) =>
hass.callWS<Timer>({
type: "timer/create",
...values,
});
+1 -1
View File
@@ -101,7 +101,7 @@ export const updateItem = (
"update_item",
{
item: item.uid,
rename: item.summary || undefined,
rename: item.summary,
status: item.status,
description: item.description,
due_datetime: item.due?.includes("T") ? item.due : undefined,
@@ -26,7 +26,7 @@ export const showConfigFlowDialog = (
showDevices: true,
createFlow: async (hass, handler) => {
const [step] = await Promise.all([
createConfigFlow(hass.callApi, handler, dialogParams.entryId),
createConfigFlow(hass, handler, dialogParams.entryId),
hass.loadFragmentTranslation("config"),
hass.loadBackendTranslation("config", handler),
hass.loadBackendTranslation("selector", handler),
@@ -51,7 +51,7 @@ export const showConfigFlowDialog = (
return step;
},
handleFlowStep: async (hass, flowId, data) => {
const step = await handleConfigFlowStep(hass.callApi, flowId, data);
const step = await handleConfigFlowStep(hass, flowId, data);
await loadFlowStepTranslations(hass, step);
return step;
},
@@ -303,7 +303,7 @@ class StepFlowCreateEntry extends LitElement {
if (Object.keys(this._deviceUpdate).length) {
const deviceUpdates = Object.entries(this._deviceUpdate).map(
([deviceId, update]) =>
updateDeviceRegistryEntry(this.hass.callWS, deviceId, {
updateDeviceRegistryEntry(this.hass, deviceId, {
name_by_user: update.name,
area_id: update.area,
}).catch((err: unknown) => {
@@ -333,14 +333,14 @@ class StepFlowCreateEntry extends LitElement {
});
const entityIdsMapping = await getAutomaticEntityIds(
this.hass.callWS,
this.hass,
entityIds
);
Object.entries(entityIdsMapping).forEach(([oldEntityId, newEntityId]) => {
if (newEntityId) {
entityUpdates.push(
updateEntityRegistryEntry(this.hass.callWS, oldEntityId, {
updateEntityRegistryEntry(this.hass, oldEntityId, {
new_entity_id: newEntityId,
}).catch((err) =>
showAlertDialog(this, {
@@ -423,7 +423,7 @@ class StepFlowCreateEntry extends LitElement {
): Promise<ExtEntityRegistryEntry[]> {
try {
const newEntityIds = await updateEntitiesArea(
this.hass.callWS,
this.hass,
this._entitiesWithoutDevice(entities).map((entity) => entity.entity_id),
areaId
);
@@ -1,21 +1,12 @@
import type { ContextType } from "@lit/context";
import "cropperjs";
import type {
CropperCanvas,
CropperGrid,
CropperHandle,
CropperImage,
CropperSelection,
CropperShade,
} from "cropperjs";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import Cropper from "cropperjs";
// @ts-ignore
import cropperCss from "cropperjs/dist/cropper.css";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../common/decorators/consume";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
@@ -25,314 +16,76 @@ import { haStyleDialog } from "../../resources/styles";
import { DialogMixin } from "../dialog-mixin";
import type { HaImageCropperDialogParams } from "./show-image-cropper-dialog";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
const MIN_SELECTION_SIZE = 50;
const INITIAL_COVERAGE = 0.8;
const EPSILON = 0.05;
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const contains = (outer: Rect, inner: Rect): boolean =>
inner.x >= outer.x - EPSILON &&
inner.y >= outer.y - EPSILON &&
inner.x + inner.width <= outer.x + outer.width + EPSILON &&
inner.y + inner.height <= outer.y + outer.height + EPSILON;
const intersect = (a: Rect, b: Rect): Rect => {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
return {
x,
y,
width: Math.min(a.x + a.width, b.x + b.width) - x,
height: Math.min(a.y + a.height, b.y + b.height) - y,
};
};
const sameSize = (a: Rect, b: Rect): boolean =>
Math.abs(a.width - b.width) < EPSILON &&
Math.abs(a.height - b.height) < EPSILON;
const moveInto = (rect: Rect, bounds: Rect): Rect => ({
...rect,
x: clamp(rect.x, bounds.x, bounds.x + bounds.width - rect.width),
y: clamp(rect.y, bounds.y, bounds.y + bounds.height - rect.height),
});
const anchorOf = (
start: number,
size: number,
nextStart: number,
nextSize: number
): number => {
if (Math.abs(nextStart - start) < EPSILON) {
return nextStart;
}
if (Math.abs(nextStart + nextSize - (start + size)) < EPSILON) {
return start + size;
}
return nextStart + nextSize / 2;
};
const shrinkInto = (current: Rect, next: Rect, bounds: Rect): Rect => {
const ax = anchorOf(current.x, current.width, next.x, next.width);
const ay = anchorOf(current.y, current.height, next.y, next.height);
let scale = 1;
if (next.x < bounds.x) {
scale = Math.min(scale, (ax - bounds.x) / (ax - next.x));
}
if (next.x + next.width > bounds.x + bounds.width) {
scale = Math.min(
scale,
(bounds.x + bounds.width - ax) / (next.x + next.width - ax)
);
}
if (next.y < bounds.y) {
scale = Math.min(scale, (ay - bounds.y) / (ay - next.y));
}
if (next.y + next.height > bounds.y + bounds.height) {
scale = Math.min(
scale,
(bounds.y + bounds.height - ay) / (next.y + next.height - ay)
);
}
return {
x: ax + (next.x - ax) * scale,
y: ay + (next.y - ay) * scale,
width: next.width * scale,
height: next.height * scale,
};
};
const scaleRect = ({ x, y, width, height }: Rect, ratio: number): Rect => ({
x: x * ratio,
y: y * ratio,
width: width * ratio,
height: height * ratio,
});
@customElement("image-cropper-dialog")
export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>()(
DialogMixin<HaImageCropperDialogParams>(LitElement)
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@query("cropper-image") private _image?: CropperImage;
@query("img") private _image!: HTMLImageElement;
@query("cropper-selection") private _selection?: CropperSelection;
@state() private _imageSize?: { width: number; height: number };
@state() private _shown = false;
private _cropper?: Cropper;
@state() private _cropReady = false;
@state() private _loadError = false;
@state() private _isTargetAspectRatio?: boolean;
private _imageUrl?: string;
private _resizeObserver?: ResizeObserver;
private _canvasWidth?: number;
private _refitting = false;
private _cropped = false;
private _closing = false;
private _shiftLocked = false;
public disconnectedCallback(): void {
super.disconnectedCallback();
if (!this._cropped) {
this.params?.cancelCallback?.();
}
if (this._imageUrl) {
URL.revokeObjectURL(this._imageUrl);
this._imageUrl = undefined;
}
this._resizeObserver?.disconnect();
this._resizeObserver = undefined;
this._cropper?.destroy();
this._cropper = undefined;
}
protected firstUpdated() {
if (!this.params) {
return;
}
this._imageUrl = URL.createObjectURL(this.params.file);
this._loadImageSize(this._imageUrl);
}
private async _loadImageSize(url: string) {
const image = new Image();
image.src = url;
try {
await image.decode();
} catch {
this._loadError = true;
return;
}
if (!this.isConnected) {
return;
}
this._imageSize = {
width: image.naturalWidth,
height: image.naturalHeight,
};
this._isTargetAspectRatio = this._checkMatchAspectRatio(
image.naturalWidth,
image.naturalHeight
);
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
!this._cropReady &&
(changedProps.has("_imageSize") || changedProps.has("_shown")) &&
this._image
) {
this._initCropper(this._image);
}
}
private async _initCropper(image: CropperImage) {
try {
await image.$ready();
} catch {
this._loadError = true;
return;
}
if (!this.isConnected || image !== this._image) {
return;
}
const selection = this._selection!;
if (!selection.width) {
await new Promise<void>((resolve) => {
selection.addEventListener("change", () => resolve(), { once: true });
});
if (!this.isConnected || image !== this._image) {
return;
}
}
this._initDirtyTracking(
{ type: "deep" },
this._cropData(this._selectionRect(), this._imageRect())
);
this._cropReady = true;
this._resizeObserver = new ResizeObserver((entries) => {
this._refit(entries[0].contentRect);
this._image.src = URL.createObjectURL(this.params.file);
this._cropper = new Cropper(this._image, {
aspectRatio: this.params.options.aspectRatio,
viewMode: 1,
dragMode: "move",
minCropBoxWidth: 50,
ready: () => {
this._isTargetAspectRatio = this._checkMatchAspectRatio();
URL.revokeObjectURL(this._image!.src);
this._initDirtyTracking({ type: "deep" }, this._cropper!.getData());
this._cropReady = true;
},
crop: () => {
if (this._cropReady) {
this._updateDirtyState(this._cropper!.getData());
}
},
});
this._resizeObserver.observe(image.parentElement!);
}
private _selectionRect(): Rect {
const { x, y, width, height } = this._selection!;
return { x, y, width, height };
}
private _canvasRect(): Rect {
const { width, height } =
this._image!.parentElement!.getBoundingClientRect();
return { x: 0, y: 0, width, height };
}
private _imageRect(): Rect {
const canvasRect = this._image!.parentElement!.getBoundingClientRect();
const imageRect = this._image!.getBoundingClientRect();
return {
x: imageRect.x - canvasRect.x,
y: imageRect.y - canvasRect.y,
width: imageRect.width,
height: imageRect.height,
};
}
private _cropData(selection: Rect, image: Rect): Rect {
const scale = image.width / this._imageSize!.width;
return {
x: Math.round((selection.x - image.x) / scale),
y: Math.round((selection.y - image.y) / scale),
width: Math.round(selection.width / scale),
height: Math.round(selection.height / scale),
};
}
private _refit(canvas: { width: number; height: number }) {
const previous = this._canvasWidth;
this._canvasWidth = canvas.width;
if (!previous || !canvas.width || previous === canvas.width) {
return;
}
const current = this._selectionRect();
const ratio = Math.min(
canvas.width / previous,
canvas.height / current.height
);
const image = scaleRect(this._imageRect(), ratio);
const selection = scaleRect(current, ratio);
const dy =
image.height <= canvas.height
? (canvas.height - image.height) / 2 - image.y
: clamp(
0,
-selection.y,
canvas.height - selection.y - selection.height
);
image.y += dy;
selection.y += dy;
const { width, height } = this._imageSize!;
this._refitting = true;
this._image!.$setTransform(
image.width / width,
0,
0,
image.height / height,
image.x + image.width / 2 - width / 2,
image.y + image.height / 2 - height / 2
);
this._selection!.$change(
selection.x,
selection.y,
selection.width,
selection.height
);
this._refitting = false;
}
private _checkMatchAspectRatio(
naturalWidth: number,
naturalHeight: number
): boolean {
private _checkMatchAspectRatio(): boolean {
const targetRatio = this.params?.options.aspectRatio;
if (!targetRatio) {
return true;
}
if (naturalWidth / naturalHeight === targetRatio) {
const imageData = this._cropper!.getImageData();
if (imageData.aspectRatio === targetRatio) {
return true;
}
// If the image is not exactly the aspect ratio see if it is within a pixel.
if (naturalWidth > naturalHeight) {
const targetHeight = naturalWidth / targetRatio;
return Math.abs(targetHeight - naturalHeight) <= 1;
if (imageData.naturalWidth > imageData.naturalHeight) {
const targetHeight = imageData.naturalWidth / targetRatio;
return Math.abs(targetHeight - imageData.naturalHeight) <= 1;
}
const targetWidth = naturalHeight * targetRatio;
return Math.abs(targetWidth - naturalWidth) <= 1;
const targetWidth = imageData.naturalHeight * targetRatio;
return Math.abs(targetWidth - imageData.naturalWidth) <= 1;
}
protected render(): TemplateResult | typeof nothing {
@@ -340,74 +93,23 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
return nothing;
}
const label = this._i18n.localize("ui.dialogs.image_cropper.crop_image");
return html`
<ha-dialog
open
header-title=${label}
header-title=${this._i18n.localize(
"ui.dialogs.image_cropper.crop_image"
)}
.preventScrimClose=${this.isDirtyState}
@wa-hide=${this._handleHide}
@after-show=${this._handleAfterShow}
>
<div
class="container ${classMap({
round: Boolean(this.params.options.round),
})}"
>
${
this._loadError
? html`<ha-alert alert-type="error">
${this._i18n.localize("ui.dialogs.image_cropper.load_error")}
</ha-alert>`
: this._imageSize && this._shown
? html`
<cropper-canvas
background
@actionmove=${this._handleCanvasActionMove}
style=${styleMap({
aspectRatio: `${this._imageSize.width} / ${this._imageSize.height}`,
})}
>
<cropper-image
src=${this._imageUrl!}
alt=${label}
scalable
translatable
@change=${this._handleImageChange}
></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="move" plain></cropper-handle>
<cropper-selection
aspect-ratio=${ifDefined(
this.params.options.aspectRatio || undefined
)}
initial-coverage=${INITIAL_COVERAGE}
precise
movable
resizable
outlined
@change=${this._handleSelectionChange}
>
<cropper-grid
role="grid"
bordered
covered
></cropper-grid>
<cropper-handle action="move" plain></cropper-handle>
<cropper-handle action="n-resize"></cropper-handle>
<cropper-handle action="e-resize"></cropper-handle>
<cropper-handle action="s-resize"></cropper-handle>
<cropper-handle action="w-resize"></cropper-handle>
<cropper-handle action="ne-resize"></cropper-handle>
<cropper-handle action="nw-resize"></cropper-handle>
<cropper-handle action="se-resize"></cropper-handle>
<cropper-handle action="sw-resize"></cropper-handle>
</cropper-selection>
</cropper-canvas>
`
: nothing
}
<img
alt=${this._i18n.localize("ui.dialogs.image_cropper.crop_image")}
/>
</div>
<ha-dialog-footer slot="footer">
<ha-button
@@ -425,7 +127,9 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
appearance="plain"
@click=${this._useOriginal}
>
${this._i18n.localize("ui.dialogs.image_cropper.use_original")}
${this._i18n.localize(
"ui.dialogs.image_cropper.use_original"
)}
</ha-button>
`
: nothing
@@ -442,99 +146,17 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
`;
}
private _handleAfterShow() {
this._shown = true;
}
private _handleHide(ev: Event) {
// Ignore hide events from nested overlays and blocked dismissals.
if (ev.eventPhase !== Event.AT_TARGET || ev.defaultPrevented) {
return;
}
this._closing = true;
}
private _handleCanvasActionMove(
ev: CustomEvent<{ relatedEvent?: PointerEvent }>
) {
this._shiftLocked = ev.detail.relatedEvent?.shiftKey ?? false;
}
private _handleSelectionChange(ev: CustomEvent<Rect>) {
if (!this._cropReady || this._refitting) {
return;
}
const selection = this._selectionRect();
const image = this._imageRect();
const bounds = intersect(image, this._canvasRect());
const next = ev.detail;
if (next.width < MIN_SELECTION_SIZE && next.width < selection.width) {
ev.preventDefault();
return;
}
if (!contains(bounds, next)) {
ev.preventDefault();
const fitted = sameSize(next, selection)
? moveInto(next, bounds)
: this.params?.options.aspectRatio || this._shiftLocked
? shrinkInto(selection, next, bounds)
: intersect(next, bounds);
this._selection!.$change(fitted.x, fitted.y, fitted.width, fitted.height);
return;
}
this._updateDirtyState(this._cropData(next, image));
}
private _handleImageChange(ev: CustomEvent<Rect>) {
if (!this._cropReady || this._refitting) {
return;
}
const selection = this._selectionRect();
const next = ev.detail;
if (!contains(next, selection)) {
ev.preventDefault();
const current = this._imageRect();
if (sameSize(next, current)) {
const x = clamp(
next.x,
selection.x + selection.width - next.width,
selection.x
);
const y = clamp(
next.y,
selection.y + selection.height - next.height,
selection.y
);
if (x !== current.x || y !== current.y) {
this._image!.$move(x - current.x, y - current.y);
}
}
return;
}
this._updateDirtyState(this._cropData(selection, next));
}
private async _cropImage() {
const selection = this._selection;
if (!selection || !this._cropReady) {
return;
}
const image = this._imageRect();
const rect = this._selectionRect();
const x = clamp(rect.x, image.x, image.x + image.width - rect.width);
const y = clamp(rect.y, image.y, image.y + image.height - rect.height);
if (x !== rect.x || y !== rect.y) {
selection.$change(x, y);
}
const crop = this._cropData(this._selectionRect(), image);
let canvas: HTMLCanvasElement;
try {
canvas = await selection.$toCanvas({
width: crop.width,
beforeDraw: (_context, target) => {
target.height = crop.height;
},
});
} catch {
private _cropImage() {
const canvas = this._cropper?.getCroppedCanvas();
if (!canvas) {
return;
}
const params = this.params!;
@@ -566,22 +188,22 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
return [
haStyleDialog,
css`
${unsafeCSS(cropperCss)}
.container {
max-width: 640px;
}
cropper-canvas {
width: 100%;
direction: ltr;
--theme-color: var(--primary-color);
img {
max-width: 100%;
}
cropper-handle {
--theme-color: var(--primary-color);
}
.container.round cropper-selection,
.container.round cropper-shade,
.container.round cropper-grid {
.container.round .cropper-view-box,
.container.round .cropper-face {
border-radius: var(--ha-picture-border-radius);
}
.cropper-line,
.cropper-point,
.cropper-point.point-se::before {
background-color: var(--primary-color);
}
`,
];
}
@@ -590,11 +212,5 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
declare global {
interface HTMLElementTagNameMap {
"image-cropper-dialog": HaImagecropperDialog;
"cropper-canvas": CropperCanvas;
"cropper-image": CropperImage;
"cropper-shade": CropperShade;
"cropper-handle": CropperHandle;
"cropper-selection": CropperSelection;
"cropper-grid": CropperGrid;
}
}
@@ -146,7 +146,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
}
const result = await updateEntityRegistryEntry(
this._api.callWS,
this._api,
this.entry.entity_id,
{
options_domain: "cover",
@@ -75,7 +75,7 @@ export class HaMoreInfoLightFavoriteColors extends LitElement {
private async _save(newFavoriteColors: LightColor[]): Promise<void> {
const result = await updateEntityRegistryEntry(
this._api.callWS,
this._api,
this.entry!.entity_id,
{
options_domain: "light",
@@ -15,7 +15,6 @@ import {
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { PendingValueSync } from "../../../../common/util/pending-value-sync";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-hs-color-picker";
import "../../../../components/ha-icon";
@@ -60,8 +59,6 @@ class LightRgbColorPicker extends LitElement {
@state() private _isInteracting?: boolean;
private _pendingSync = new PendingValueSync();
protected render() {
if (!this.stateObj) {
return nothing;
@@ -184,29 +181,23 @@ class LightRgbColorPicker extends LitElement {
`;
}
private _updateBrightnessAdjusted() {
const stateObj = this.stateObj;
this._brightnessAdjusted = undefined;
if (
stateObj.attributes.color_mode === LightColorMode.RGB &&
stateObj.attributes.rgb_color &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBWW) &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBW)
) {
const maxVal = Math.max(...stateObj.attributes.rgb_color);
if (maxVal < 255) {
this._brightnessAdjusted = maxVal;
}
}
}
private _updateSliderValues() {
const stateObj = this.stateObj;
if (stateObj.state === "on") {
this._updateBrightnessAdjusted();
this._brightnessAdjusted = undefined;
if (
stateObj.attributes.color_mode === LightColorMode.RGB &&
stateObj.attributes.rgb_color &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBWW) &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBW)
) {
const maxVal = Math.max(...stateObj.attributes.rgb_color);
if (maxVal < 255) {
this._brightnessAdjusted = maxVal;
}
}
this._wvSliderValue =
stateObj.attributes.color_mode === LightColorMode.RGBW &&
@@ -244,23 +235,11 @@ class LightRgbColorPicker extends LitElement {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!changedProps.has("stateObj")) {
if (this._isInteracting || !changedProps.has("stateObj")) {
return;
}
const oldStateObj = changedProps.get("stateObj") as LightEntity | undefined;
if (oldStateObj?.entity_id !== this.stateObj?.entity_id) {
this._throttleUpdateColor.cancel();
this._pendingSync.reset();
this._isInteracting = false;
this._updateSliderValues();
} else if (!this._isInteracting && this._pendingSync.shouldSync()) {
this._updateSliderValues();
} else if (this.stateObj.state === "on") {
// Keep the picker values, but the brightness normalization has to
// follow the reported state, as it is applied to the next color sent.
this._updateBrightnessAdjusted();
}
this._updateSliderValues();
}
private _hsColorCursorMoved(ev: CustomEvent) {
@@ -271,7 +250,6 @@ class LightRgbColorPicker extends LitElement {
return;
}
this._hsPickerValue = color;
this._pendingSync.schedule();
this._throttleUpdateColor();
}
@@ -334,23 +312,13 @@ class LightRgbColorPicker extends LitElement {
this._colorBrightnessSliderValue = hsv[2] / 2.55;
}
this._pendingSync.schedule();
this._throttleUpdateColor();
}
private _hsColorChanged(ev: CustomEvent) {
this._isInteracting = false;
if (!ev.detail.value) {
// Nothing is sent, so stop blocking state updates.
this._throttleUpdateColor.cancel();
this._pendingSync.reset();
this._updateSliderValues();
return;
}
// A pending throttled call would otherwise send an older color after the
// final one.
this._throttleUpdateColor.cancel();
this._hsPickerValue = ev.detail.value;
this._updateColor();
@@ -398,15 +366,11 @@ class LightRgbColorPicker extends LitElement {
private _applyColor(color: LightColor, params?: Record<string, any>) {
fireEvent(this, "color-changed", color);
return this._pendingSync.run(
() =>
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
}),
() => this._updateSliderValues()
);
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
});
}
private _colorBrightnessSliderChanged(ev: CustomEvent) {
@@ -13,7 +13,6 @@ import {
} from "../../../../common/color/convert-light-color";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stateColorCss } from "../../../../common/entity/state_color";
import { PendingValueSync } from "../../../../common/util/pending-value-sync";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-control-slider";
import {
@@ -67,8 +66,6 @@ class LightColorTempPicker extends LitElement {
@state() private _isInteracting?: boolean;
private _pendingSync = new PendingValueSync();
protected render() {
if (!this.stateObj) {
return nothing;
@@ -128,19 +125,11 @@ class LightColorTempPicker extends LitElement {
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!changedProps.has("stateObj")) {
if (this._isInteracting || !changedProps.has("stateObj")) {
return;
}
const oldStateObj = changedProps.get("stateObj");
if (oldStateObj?.entity_id !== this.stateObj?.entity_id) {
this._throttleUpdateColorTemp.cancel();
this._pendingSync.reset();
this._isInteracting = false;
this._updateSliderValues();
} else if (!this._isInteracting && this._pendingSync.shouldSync()) {
this._updateSliderValues();
}
this._updateSliderValues();
}
private _ctColorCursorMoved(ev: CustomEvent) {
@@ -153,7 +142,6 @@ class LightColorTempPicker extends LitElement {
}
this._ctPickerValue = ct;
this._pendingSync.schedule();
this._throttleUpdateColorTemp();
}
@@ -165,16 +153,7 @@ class LightColorTempPicker extends LitElement {
private _ctColorChanged(ev: CustomEvent) {
const ct = ev.detail.value;
this._isInteracting = false;
// A pending throttled call would otherwise send an older value after the
// final one.
this._throttleUpdateColorTemp.cancel();
if (isNaN(ct)) {
// Nothing is sent, so stop blocking state updates.
this._pendingSync.reset();
this._updateSliderValues();
if (isNaN(ct) || this._ctPickerValue === ct) {
return;
}
@@ -191,15 +170,11 @@ class LightColorTempPicker extends LitElement {
private _applyColor(color: LightColor, params?: Record<string, any>) {
fireEvent(this, "color-changed", color);
return this._pendingSync.run(
() =>
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
}),
() => this._updateSliderValues()
);
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
});
}
static get styles(): CSSResultGroup {
@@ -112,7 +112,7 @@ export class HaMoreInfoTimerPresets extends LitElement {
};
const result = await updateEntityRegistryEntry(
this._api.callWS,
this._api,
this.entry.entity_id,
{
options_domain: "timer",
@@ -82,7 +82,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
};
try {
await updateEntityRegistryEntry(this.hass.callWS, this.params.entityId, {
await updateEntityRegistryEntry(this.hass, this.params.entityId, {
options_domain: "vacuum",
options: options,
});
@@ -110,7 +110,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
currentOptions.favorite_positions = this._favoritePositions;
const result = await updateEntityRegistryEntry(
this._api.callWS,
this._api,
this.entry.entity_id,
{
options_domain: "valve",
@@ -88,7 +88,10 @@ class MoreInfoGroup extends LitElement {
return "";
}
return html`
<state-card-content .stateObj=${entityState}></state-card-content>
<state-card-content
.stateObj=${entityState}
.hass=${this.hass}
></state-card-content>
`;
})}`;
}
@@ -124,12 +124,7 @@ class MoreInfoMediaPlayer extends LitElement {
private _volumeController = new VolumeSliderController({
getSlider: () => this._volumeSlider,
step: this._volumeStep,
onSetVolume: (value) => {
// The final value is set right away, so drop the pending update from
// the input events before it, or the same value is sent twice
this._debouncedVolumeSet.cancel();
this._setVolume(value);
},
onSetVolume: (value) => this._setVolume(value),
onSetVolumeDebounced: (value) => this._debouncedVolumeSet(value),
});
+1 -1
View File
@@ -132,7 +132,7 @@ const copyFavoriteOptionsToEntities = async (
if (selected?.entity) {
const result = await Promise.allSettled(
selected.entity.map((entityId: string) =>
updateEntityRegistryEntry(hass.callWS, entityId, {
updateEntityRegistryEntry(hass, entityId, {
options_domain: domain,
options,
})
+1 -1
View File
@@ -531,7 +531,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const result = await updateEntityRegistryEntry(
this.hass.callWS,
this.hass,
favoritesContext.entry.entity_id,
{
options_domain: favoritesHandler.domain,
@@ -74,6 +74,7 @@ export class MoreInfoInfo extends LitElement {
<state-card-content
in-dialog
.stateObj=${stateObj}
.hass=${this.hass}
></state-card-content>
`
}
+1 -11
View File
@@ -642,17 +642,7 @@ export class QuickBar extends LitElement {
);
private _getEntitiesMemoized = memoizeOne((hass: HomeAssistant) =>
getEntities({
states: hass.states,
entities: hass.entities,
devices: hass.devices,
areas: hass.areas,
floors: hass.floors,
localize: hass.localize,
language: hass.language,
translationMetadata: hass.translationMetadata,
idPrefix: `entity${SEPARATOR}`,
})
getEntities(hass, { idPrefix: `entity${SEPARATOR}` })
);
private _getDevicesMemoized = memoizeOne(
+26 -21
View File
@@ -150,11 +150,9 @@ class DialogRestart extends LitElement {
"ui.dialogs.restart.reload.description"
)}
</div>
<ha-svg-icon
slot="start"
class="icon-background reload"
.path=${mdiAutoFix}
></ha-svg-icon>
<div slot="start" class="icon-background reload">
<ha-svg-icon .path=${mdiAutoFix}></ha-svg-icon>
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
@@ -162,11 +160,9 @@ class DialogRestart extends LitElement {
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<ha-svg-icon
slot="start"
class="icon-background restart"
.path=${mdiRefresh}
></ha-svg-icon>
<div slot="start" class="icon-background restart">
<ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
</div>
<div slot="headline">
${this.hass.localize("ui.dialogs.restart.restart.title")}
</div>
@@ -192,11 +188,14 @@ class DialogRestart extends LitElement {
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<ha-svg-icon
<div
slot="start"
class="icon-background reboot"
.path=${mdiPowerCycle}
></ha-svg-icon>
>
<ha-svg-icon
.path=${mdiPowerCycle}
></ha-svg-icon>
</div>
<div slot="headline">
${this.hass.localize(
"ui.dialogs.restart.reboot.title"
@@ -214,11 +213,12 @@ class DialogRestart extends LitElement {
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<ha-svg-icon
<div
slot="start"
class="icon-background shutdown"
.path=${mdiPower}
></ha-svg-icon>
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</div>
<div slot="headline">
${this.hass.localize(
"ui.dialogs.restart.shutdown.title"
@@ -239,11 +239,12 @@ class DialogRestart extends LitElement {
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<ha-svg-icon
<div
slot="start"
class="icon-background restart-safe-mode"
.path=${mdiLifebuoy}
></ha-svg-icon>
>
<ha-svg-icon .path=${mdiLifebuoy}></ha-svg-icon>
</div>
<div slot="headline">
${this.hass.localize(
"ui.dialogs.restart.restart-safe-mode.title"
@@ -435,8 +436,12 @@ class DialogRestart extends LitElement {
.icon-background {
border-radius: var(--ha-border-radius-circle);
color: #fff;
/* 24px icon + padding = 40px circle */
padding: var(--ha-space-2);
display: flex;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.reload {
background-color: #5f8a49;
@@ -50,7 +50,7 @@ export class HaVoiceAssistantSetupStepArea extends LitElement {
});
return;
}
await updateDeviceRegistryEntry(this.hass.callWS, this.deviceId, {
await updateDeviceRegistryEntry(this.hass, this.deviceId, {
area_id: area,
});
this._nextStep();
@@ -310,7 +310,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
if (configFlow) {
const step = await handleConfigFlowStep(
this.hass.callApi,
this.hass,
configFlow.flow_id,
{}
);
@@ -343,15 +343,11 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
}
private async _createConfigEntry(type: "tts" | "stt") {
const configFlow = await createConfigFlow(this.hass.callApi, "wyoming");
const step = await handleConfigFlowStep(
this.hass.callApi,
configFlow.flow_id,
{
host: type === "tts" ? this._ttsHostName : this._sttHostName,
port: type === "tts" ? this._ttsPort : this._sttPort,
}
);
const configFlow = await createConfigFlow(this.hass, "wyoming");
const step = await handleConfigFlowStep(this.hass, configFlow.flow_id, {
host: type === "tts" ? this._ttsHostName : this._sttHostName,
port: type === "tts" ? this._ttsPort : this._sttPort,
});
if (step.type !== "create_entry") {
throw new Error(
`${this.hass.localize("ui.panel.config.voice_assistants.satellite_wizard.local.errors.failed_create_entry", { app: type === "tts" ? this._ttsProviderName : this._sttProviderName })}${"errors" in step ? `: ${step.errors?.base}` : ""}`
@@ -351,7 +351,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
private async _done() {
if (this._deviceName) {
try {
updateDeviceRegistryEntry(this.hass.callWS, this.deviceId, {
updateDeviceRegistryEntry(this.hass, this.deviceId, {
name_by_user: this._deviceName,
});
} catch (error: any) {
+2 -47
View File
@@ -45,50 +45,15 @@
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
margin-top: 142px;
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
padding-top: 48px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 16px;
}
#ha-launch-screen-info-box p {
max-width: 400px;
margin: 0 16px 16px;
text-align: center;
}
/* Margins, not flex `gap` (Safari 14.1). */
#ha-launch-screen .ha-launch-screen-actions {
display: flex;
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 {
padding: 8px 16px;
border: 1px solid currentColor;
border-radius: 18px;
background: none;
color: #009ac7;
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.ohf-logo {
margin: 48px 0;
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
display: flex;
flex-direction: column;
align-items: center;
@@ -108,16 +73,6 @@
aspect-ratio: 237 / 24;
height: auto;
}
/* max() with var() fails after the cascade, so plain fallbacks above
need @supports (Chrome below 79). `body`: Jinja2, see below. */
@supports (margin: max(1px, 2px)) {
body #ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
}
}
@media (prefers-color-scheme: dark) {
/*
* Core renders the app's index.html through Jinja2, where the minified
+17 -80
View File
@@ -65,86 +65,23 @@
</script>
<script>
if (!window.latestJS) {
// Floor check: BigInt for Chrome 67, <dialog> for Safari 15.4 and
// Firefox 98. Not transpiled, so keep this ES5.
(function () {
var ACCEPTED_KEY = "haUnsupportedBrowserAccepted";
var REMIND_AFTER = 30 * 24 * 60 * 60 * 1000;
function loadApp() {
window.customPanelJS = "<%= es5CustomPanelJS %>";
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
}
if (
typeof BigInt !== "undefined" &&
typeof document.createElement("dialog").showModal === "function"
) {
loadApp();
return;
}
// Shown while the app loads too.
var box = document.getElementById("ha-launch-screen-info-box");
var message = document.createElement("p");
message.textContent =
"This browser is no longer supported. Some parts of Home Assistant may not work.";
box.appendChild(message);
// Stored: "always", or when the user last continued (ms).
var stored = null;
try {
stored = localStorage.getItem(ACCEPTED_KEY);
} catch (_err) {
// Ask on every load.
}
if (stored === "always") {
loadApp();
return;
}
var acceptedAt = Number(stored) || 0;
// A clock behind the stored time asks again.
var age = Date.now() - acceptedAt;
if (acceptedAt && age >= 0 && age < REMIND_AFTER) {
loadApp();
return;
}
var actions = document.createElement("div");
actions.className = "ha-launch-screen-actions";
box.appendChild(actions);
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);
// From the first reminder on. A checkbox, so it is not chosen by
// accident.
if (acceptedAt) {
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);
}
})();
// Below the [legacy] browserslist floor the entry bundles cannot be
// parsed, or dialogs cannot open, and nothing would ever leave the
// launch screen. Test the features that set the floors: BigInt for
// Chrome 67, native <dialog> for Safari 15.4 and Firefox 98. Not
// transpiled, so keep this ES5.
if (
typeof BigInt === "undefined" ||
typeof document.createElement("dialog").showModal !== "function"
) {
document.getElementById("ha-launch-screen-info-box").textContent =
"This browser is too old to run Home Assistant.";
} else {
window.customPanelJS = "<%= es5CustomPanelJS %>";
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
}
}
</script>
-4
View File
@@ -155,10 +155,6 @@ export const DirtyStateProviderMixin =
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
// A late fetch can still update a removed provider
if (!this.isConnected) {
return;
}
connectedDirtyStateProviders.set(this, this._blocksReload);
publishGlobalDirtyState();
}
+156 -14
View File
@@ -1,18 +1,28 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type ClimateFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
import "../../components/ha-top-app-bar-fixed";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
@@ -30,10 +40,20 @@ class PanelClimate extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config?: ClimateFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -47,13 +67,22 @@ class PanelClimate extends LitElement {
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
@@ -63,24 +92,46 @@ class PanelClimate extends LitElement {
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"climate"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
// eslint-disable-next-line no-console
console.error("Failed to load climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
@@ -97,6 +148,27 @@ class PanelClimate extends LitElement {
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.climate.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
@@ -108,6 +180,7 @@ class PanelClimate extends LitElement {
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view
></hui-view-container>
`
@@ -117,16 +190,31 @@ class PanelClimate extends LitElement {
`;
}
private async _setLovelace() {
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
CLIMATE_LOVELACE_VIEW_CONFIG,
{
strategy: {
...CLIMATE_LOVELACE_VIEW_CONFIG.strategy,
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
@@ -146,6 +234,60 @@ class PanelClimate extends LitElement {
};
}
private _startEditVisibility = () => {
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private _saveConfig(config: ClimateFrontendSystemData): Promise<void> {
this._config = config;
return queueFrontendSystemDataSave(
this.hass.connection,
"climate",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -2,9 +2,10 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
findFilteredEntities,
generateEntityFilter,
type EntityFilter,
type EntityFilterSources,
} from "../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -12,9 +13,15 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
export interface ClimateViewStrategyConfig {
type: "climate";
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const climateEntityFilters: EntityFilter[] = [
@@ -42,40 +49,53 @@ export const climateEntityFilters: EntityFilter[] = [
},
];
export const hasClimateEntities = (hass: HomeAssistant): boolean => {
const hasAreaSensor = Object.values(hass.areas).some(
(area) =>
(area.temperature_entity_id && hass.states[area.temperature_entity_id]) ||
(area.humidity_entity_id && hass.states[area.humidity_entity_id])
);
export const findClimateEntities = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, climateEntityFilters, hiddenEntities);
if (hasAreaSensor) {
return true;
type ClimateAreaSensorKind = "temperature" | "humidity";
export const findClimateAreaSensors = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): Map<string, ClimateAreaSensorKind> => {
const hidden = new Set(hiddenEntities);
const sensors = new Map<string, ClimateAreaSensorKind>();
for (const area of Object.values(sources.areas)) {
const slots = [
[area.temperature_entity_id, "temperature"],
[area.humidity_entity_id, "humidity"],
] as const;
for (const [entityId, kind] of slots) {
if (entityId && entityId in sources.states && !hidden.has(entityId)) {
sensors.set(entityId, kind);
}
}
}
const entityIds = Object.keys(hass.states);
return climateEntityFilters.some((filter) =>
entityIds.some(
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
)
);
return sensors;
};
export const hasClimateEntities = (
hass: HomeAssistant,
hiddenEntities?: string[]
): boolean =>
findClimateAreaSensors(hass, hiddenEntities).size > 0 ||
findClimateEntities(hass, hiddenEntities).length > 0;
interface ClimateCardOptions {
entities: string[];
areaSensors: Map<string, ClimateAreaSensorKind>;
computeCard: ComputeEntityCard;
}
const processAreasForClimate = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
{ entities, areaSensors, computeCard }: ClimateCardOptions
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, true);
for (const areaId of areaIds) {
const area = hass.areas[areaId];
@@ -96,25 +116,13 @@ const processAreasForClimate = (
// Add temperature and humidity sensors with trend graphs for areas
const temperatureEntityId = area.temperature_entity_id;
if (temperatureEntityId && hass.states[temperatureEntityId]) {
areaCards.push({
...computeTileCard(temperatureEntityId),
name:
hass.localize("component.sensor.entity_component.temperature.name") ||
"Temperature",
features: [{ type: "trend-graph" }],
});
if (temperatureEntityId && areaSensors.has(temperatureEntityId)) {
areaCards.push(computeCard(temperatureEntityId));
}
const humidityEntityId = area.humidity_entity_id;
if (humidityEntityId && hass.states[humidityEntityId]) {
areaCards.push({
...computeTileCard(humidityEntityId),
name:
hass.localize("component.sensor.entity_component.humidity.name") ||
"Humidity",
features: [{ type: "trend-graph" }],
});
if (humidityEntityId && areaSensors.has(humidityEntityId)) {
areaCards.push(computeCard(humidityEntityId));
}
// Add other climate entities
@@ -124,18 +132,7 @@ const processAreasForClimate = (
continue;
}
const state = hass.states[entityId];
if (
state?.attributes.device_class === "temperature" ||
state?.attributes.device_class === "humidity"
) {
areaCards.push({
...computeTileCard(entityId),
features: [{ type: "trend-graph" }],
});
} else {
areaCards.push(computeTileCard(entityId));
}
areaCards.push(computeCard(entityId));
}
if (areaCards.length > 0) {
@@ -159,7 +156,7 @@ const processAreasForClimate = (
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
{ entities, computeCard }: ClimateCardOptions
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -173,10 +170,9 @@ const processUnassignedEntities = (
);
const unassignedEntities = entities.filter(unassignedFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, true);
for (const entityId of unassignedEntities) {
areaCards.push(computeTileCard(entityId));
areaCards.push(computeCard(entityId));
}
return areaCards;
@@ -185,7 +181,7 @@ const processUnassignedEntities = (
@customElement("climate-view-strategy")
export class ClimateViewStrategy extends ReactiveElement {
static async generate(
_config: ClimateViewStrategyConfig,
config: ClimateViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -194,20 +190,40 @@ export class ClimateViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const climateFilters = climateEntityFilters.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
const hiddenEntities = new Set(config.hidden_entities);
const editVisibility = Boolean(config.edit_visibility);
const entities = findClimateEntities(
hass,
editVisibility ? undefined : config.hidden_entities
);
const entities = findEntities(allEntities, climateFilters);
const areaSensors = findClimateAreaSensors(
hass,
editVisibility ? undefined : config.hidden_entities
);
const computeTileCard = computeAreaTileCardConfig(hass, true);
const computeClimateCard = (entityId: string): LovelaceCardConfig => {
const tileCard = computeTileCard(entityId);
const areaSensor = areaSensors.get(entityId);
if (!areaSensor) {
return tileCard;
}
return {
...tileCard,
name:
hass.localize(
`component.sensor.entity_component.${areaSensor}.name`
) || undefined,
features: [{ type: "trend-graph" }],
};
};
const cardOptions: ClimateCardOptions = {
entities,
areaSensors,
computeCard: editVisibility
? wrapEntityVisibility(hiddenEntities, computeClimateCard)
: computeClimateCard,
};
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -233,7 +249,7 @@ export class ClimateViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForClimate(areaIds, hass, entities);
const areaCards = processAreasForClimate(areaIds, hass, cardOptions);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -257,7 +273,11 @@ export class ClimateViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForClimate(hierarchy.areas, hass, entities);
const areaCards = processAreasForClimate(
hierarchy.areas,
hass,
cardOptions
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -266,7 +286,7 @@ export class ClimateViewStrategy extends ReactiveElement {
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, entities);
const unassignedCards = processUnassignedEntities(hass, cardOptions);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff. Show more