mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-14 08:53:25 +00:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366d4b1d8d | ||
|
|
de64bd3c59 | ||
|
|
5253eef284 | ||
|
|
b1164739c3 | ||
|
|
80a516c673 | ||
|
|
d5d0f45919 | ||
|
|
f922bc59a5 | ||
|
|
a71a79b01b | ||
|
|
9b8409d7ec | ||
|
|
fc8d21b920 | ||
|
|
fce0f4da4e | ||
|
|
2391c700c6 | ||
|
|
8cd79a6776 | ||
|
|
b4f6e4e2ea | ||
|
|
50bb1cf53e | ||
|
|
70a1946874 | ||
|
|
3da9ba8119 | ||
|
|
eb8ac4139b | ||
|
|
f9aae72325 | ||
|
|
81279aa6d2 | ||
|
|
33b8afffca | ||
|
|
b157dd174e | ||
|
|
4a0381e82d | ||
|
|
92e248c913 | ||
|
|
5b1ecdb0b0 | ||
|
|
ee35dd8cf6 | ||
|
|
42ce434a94 | ||
|
|
06d564dbd8 | ||
|
|
93f0c2c45a | ||
|
|
fb40d07621 | ||
|
|
a6073e44f7 | ||
|
|
df69f3c7ee | ||
|
|
95a009641f | ||
|
|
36f59f8495 | ||
|
|
8a13bba521 | ||
|
|
cc7b1952c0 | ||
|
|
2edf6f91d0 | ||
|
|
9d52517ae9 | ||
|
|
27a7f6335f | ||
|
|
cd44bfcebc | ||
|
|
8819844740 | ||
|
|
008adf1533 | ||
|
|
8c3d0f2f1e | ||
|
|
3f4f89480f | ||
|
|
432762a2ea | ||
|
|
013cf8d7e6 | ||
|
|
1de489cca9 | ||
|
|
97af661779 | ||
|
|
f702e369fc | ||
|
|
78e0a92ddd | ||
|
|
d742259444 | ||
|
|
623ca14356 |
No files matched your search
@@ -28,10 +28,18 @@ 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
|
||||
|
||||
@@ -44,14 +44,21 @@ export const lorawanFixtures: ConnectivityFixtures = {
|
||||
],
|
||||
commands: ["lorawan/"],
|
||||
manifests: [
|
||||
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
|
||||
manifest("lorawan", "LoRaWAN", {
|
||||
config_flow: false,
|
||||
integration_type: "service",
|
||||
}),
|
||||
manifest("sensecap_lorawan", "SenseCAP", {
|
||||
integration_type: "device",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
|
||||
manifest("chirpstack", "ChirpStack", {
|
||||
integration_type: "hub",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
manifest("thethingsnetwork_lorawan", "The Things Network", {
|
||||
integration_type: "hub",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
|
||||
@@ -10,19 +10,26 @@ export const FRONIUS_ENTRY_ID = "mock-fronius";
|
||||
export const RS485_PORT = "/dev/ttyUSB1";
|
||||
|
||||
export const modbusFixtures: ConnectivityFixtures = {
|
||||
components: ["modbus"],
|
||||
components: ["modbus", "flexit", "solaredge_modbus", "fronius"],
|
||||
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",
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -25,7 +25,6 @@ class DemoMoreInfo extends LitElement {
|
||||
!computeShowNewMoreInfo(state)
|
||||
? html`<state-card-content
|
||||
.stateObj=${state}
|
||||
.hass=${this.hass}
|
||||
in-dialog
|
||||
></state-card-content>`
|
||||
: nothing
|
||||
|
||||
@@ -165,6 +165,18 @@ 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>`.
|
||||
@@ -174,6 +186,7 @@ roving tabindex, the host is the tab stop and the inner element carries
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `control` — the inner `<a>` or `<button>`.
|
||||
- `ripple` — the ripple effect element.
|
||||
|
||||
### `<ha-list-item-option>`
|
||||
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
mdiAccount,
|
||||
mdiChevronRight,
|
||||
mdiCog,
|
||||
mdiDelete,
|
||||
mdiHome,
|
||||
mdiInformationOutline,
|
||||
mdiMapMarker,
|
||||
mdiOpenInNew,
|
||||
mdiPencil,
|
||||
mdiViewDashboard,
|
||||
mdiWifi,
|
||||
} from "@mdi/js";
|
||||
@@ -13,6 +15,8 @@ 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";
|
||||
@@ -77,6 +81,8 @@ 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();
|
||||
@@ -220,6 +226,51 @@ 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
|
||||
@@ -428,6 +479,12 @@ 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++;
|
||||
};
|
||||
@@ -542,6 +599,15 @@ 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);
|
||||
|
||||
+18
-18
@@ -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.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",
|
||||
"@formatjs/intl-datetimeformat": "8.0.3",
|
||||
"@formatjs/intl-displaynames": "8.0.3",
|
||||
"@formatjs/intl-durationformat": "0.12.4",
|
||||
"@formatjs/intl-getcanonicallocales": "3.3.1",
|
||||
"@formatjs/intl-listformat": "9.0.3",
|
||||
"@formatjs/intl-locale": "5.4.3",
|
||||
"@formatjs/intl-numberformat": "10.0.4",
|
||||
"@formatjs/intl-pluralrules": "7.0.4",
|
||||
"@formatjs/intl-relativetimeformat": "13.0.3",
|
||||
"@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.2",
|
||||
"@tsparticles/preset-links": "4.5.2",
|
||||
"@versatiles/style": "8.1.1",
|
||||
"@tsparticles/engine": "4.5.3",
|
||||
"@tsparticles/preset-links": "4.5.3",
|
||||
"@versatiles/style": "9.0.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.202.0",
|
||||
"@vvo/tzdb": "6.204.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": "1.7.0",
|
||||
"cropperjs": "2.3.1",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"decimal.js": "10.6.0",
|
||||
@@ -108,7 +108,7 @@
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"maplibre-gl": "6.17.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.67.0",
|
||||
"@html-eslint/eslint-plugin": "0.68.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.17.1",
|
||||
"eslint-plugin-import-x": "4.18.0",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.3",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
@@ -208,7 +208,7 @@
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.10",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"serve": "14.2.7",
|
||||
"sinon": "22.1.0",
|
||||
"tar": "7.5.22",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
|
||||
Generated
+339
-291
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
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);
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassServices } from "home-assistant-js-websocket";
|
||||
import { getToggleAction } from "./get_toggle_action";
|
||||
|
||||
export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
|
||||
const services = hass.services[domain];
|
||||
if (!services) {
|
||||
export const canToggleDomain = (services: HassServices, domain: string) => {
|
||||
const domainServices = services[domain];
|
||||
if (!domainServices) {
|
||||
return false;
|
||||
}
|
||||
const actionOn = getToggleAction(domain, true);
|
||||
const actionOff = getToggleAction(domain, false);
|
||||
return actionOn in services && actionOff in services;
|
||||
return actionOn in domainServices && actionOff in domainServices;
|
||||
};
|
||||
@@ -1,23 +1,30 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type {
|
||||
HassEntities,
|
||||
HassEntity,
|
||||
HassServices,
|
||||
} from "home-assistant-js-websocket";
|
||||
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 = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
export const canToggleState = (
|
||||
states: HassEntities,
|
||||
services: HassServices,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
|
||||
if (domain === "group") {
|
||||
if (
|
||||
stateObj.attributes?.entity_id?.some((entity) => {
|
||||
const entityStateObj = hass.states[entity];
|
||||
const entityStateObj = states[entity];
|
||||
if (!entityStateObj) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entityDomain = computeStateDomain(entityStateObj);
|
||||
return canToggleDomain(hass, entityDomain);
|
||||
return canToggleDomain(services, entityDomain);
|
||||
})
|
||||
) {
|
||||
return stateObj.state === "on" || stateObj.state === "off";
|
||||
@@ -35,5 +42,5 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
);
|
||||
}
|
||||
|
||||
return canToggleDomain(hass, domain);
|
||||
return canToggleDomain(services, domain);
|
||||
};
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
HassEntities,
|
||||
HassEntity,
|
||||
HassServices,
|
||||
} 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 = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
export const stateCardType = (
|
||||
states: HassEntities,
|
||||
services: HassServices,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
if (stateObj.state === UNAVAILABLE) {
|
||||
return "display";
|
||||
}
|
||||
@@ -16,7 +23,7 @@ export const stateCardType = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
return domain;
|
||||
}
|
||||
if (
|
||||
canToggleState(hass, stateObj) &&
|
||||
canToggleState(states, services, stateObj) &&
|
||||
stateObj.attributes.control !== "hidden"
|
||||
) {
|
||||
return "toggle";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/** 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]);
|
||||
@@ -0,0 +1,54 @@
|
||||
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 {};
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
};
|
||||
@@ -30,10 +30,40 @@ 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"
|
||||
@@ -53,17 +83,7 @@ export class HaAutomationRow extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<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>
|
||||
${content} ${icons}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -119,7 +139,7 @@ export class HaAutomationRow extends LitElement {
|
||||
|
||||
public focus() {
|
||||
requestAnimationFrame(() => {
|
||||
this._rowElement?.focus();
|
||||
(this._linkElement ?? this._rowElement)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,6 +164,30 @@ 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);
|
||||
|
||||
@@ -401,9 +401,13 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
if (properties.has("data")) {
|
||||
// Clean up checked rows that no longer exist in the data
|
||||
// Clean up checked rows that no longer exist or can no longer be selected
|
||||
if (this._checkedRows.length) {
|
||||
const validIds = new Set(this.data.map((row) => String(row[this.id])));
|
||||
const validIds = new Set(
|
||||
this.data
|
||||
.filter((row) => row.selectable !== false)
|
||||
.map((row) => String(row[this.id]))
|
||||
);
|
||||
const validCheckedRows = this._checkedRows.filter((id) =>
|
||||
validIds.has(id)
|
||||
);
|
||||
|
||||
@@ -97,6 +97,8 @@ 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 },
|
||||
@@ -195,6 +197,7 @@ 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)}
|
||||
@@ -253,7 +256,9 @@ 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} @click=${this._save}
|
||||
<ha-button
|
||||
.disabled=${!this._dateValue || this._rangeIncomplete}
|
||||
@click=${this._save}
|
||||
>${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select"
|
||||
)}</ha-button
|
||||
@@ -280,7 +285,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _save() {
|
||||
if (!this._dateValue) {
|
||||
if (!this._dateValue || this._rangeIncomplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,9 +336,21 @@ 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;
|
||||
|
||||
@@ -371,25 +371,24 @@ export class HaEntityPicker extends LitElement {
|
||||
excludeEntities?: string[],
|
||||
value?: string
|
||||
) =>
|
||||
getEntities(
|
||||
{
|
||||
states,
|
||||
...registries,
|
||||
language: i18n.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
localize: i18n.localize,
|
||||
},
|
||||
{
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
entityFilter,
|
||||
includeDeviceClasses,
|
||||
includeUnitOfMeasurement,
|
||||
includeEntities,
|
||||
excludeEntities,
|
||||
value,
|
||||
}
|
||||
)
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
private _sortByRelatedContext = memoizeOne(
|
||||
|
||||
@@ -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 } from "../common/dom/fire_event";
|
||||
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import {
|
||||
CameraEntityFeature,
|
||||
@@ -17,8 +17,16 @@ 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,
|
||||
@@ -29,14 +37,10 @@ import "./ha-hls-player";
|
||||
import "./ha-web-rtc-player";
|
||||
import type { HaWebRtcPlayer } from "./ha-web-rtc-player";
|
||||
|
||||
const MJPEG_STREAM = "mjpeg";
|
||||
export type { CameraStreamType } from "../data/camera-streams";
|
||||
|
||||
export type CameraStreamType = StreamType | typeof MJPEG_STREAM;
|
||||
|
||||
interface Stream {
|
||||
type: CameraStreamType;
|
||||
visible: boolean;
|
||||
}
|
||||
// How long WebRTC gets on its own before HLS is started as well
|
||||
const WEB_RTC_FIRST_TIMEOUT = 12000;
|
||||
|
||||
@customElement("ha-camera-stream")
|
||||
export class HaCameraStream extends LitElement {
|
||||
@@ -78,13 +82,25 @@ export class HaCameraStream extends LitElement {
|
||||
|
||||
@state() private _capabilities?: CameraCapabilities;
|
||||
|
||||
@state() private _hlsStreams?: { hasAudio: boolean; hasVideo: boolean };
|
||||
@state() private _hlsStreams?: CameraStreamTracks;
|
||||
|
||||
@state() private _webRtcStreams?: { 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;
|
||||
|
||||
@query("ha-web-rtc-player") private _webRtcPlayer?: HaWebRtcPlayer;
|
||||
|
||||
private _visibleStreamType?: Stream["type"];
|
||||
private _visibleStreamType?: CameraStreamType;
|
||||
|
||||
private _webRtcFirstTimeout?: number;
|
||||
|
||||
private _capabilitiesRequest = 0;
|
||||
|
||||
private _streamsStartedAt = 0;
|
||||
|
||||
// If WebRTC loaded fast enough to be worth waiting for without HLS
|
||||
private _webRtcLoadedInTime?: boolean;
|
||||
|
||||
private _thumbnailApi = memoizeOne(
|
||||
(
|
||||
@@ -132,11 +148,16 @@ 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 {
|
||||
@@ -146,13 +167,34 @@ export class HaCameraStream extends LitElement {
|
||||
capabilities: this._capabilities,
|
||||
});
|
||||
}
|
||||
const visibleStreamType = this._currentStreams().find(
|
||||
(stream) => stream.visible
|
||||
)?.type;
|
||||
const streams = this._currentStreams();
|
||||
const visibleStreamType = streams.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() {
|
||||
@@ -167,7 +209,7 @@ export class HaCameraStream extends LitElement {
|
||||
)}`;
|
||||
}
|
||||
|
||||
private _renderStream(stream: Stream) {
|
||||
private _renderStream(stream: CameraStream) {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
@@ -230,14 +272,14 @@ export class HaCameraStream extends LitElement {
|
||||
this._capabilities?.frontend_stream_types,
|
||||
this._hlsStreams,
|
||||
this._webRtcStreams,
|
||||
this.muted
|
||||
this._webRtcFirst
|
||||
);
|
||||
}
|
||||
|
||||
private async _getCapabilities() {
|
||||
this._capabilities = undefined;
|
||||
this._hlsStreams = undefined;
|
||||
this._webRtcStreams = undefined;
|
||||
this._resetStreams();
|
||||
const request = ++this._capabilitiesRequest;
|
||||
if (!supportsFeature(this.stateObj!, CameraEntityFeature.STREAM)) {
|
||||
this._capabilities = {
|
||||
frontend_stream_types: [],
|
||||
@@ -245,10 +287,62 @@ export class HaCameraStream extends LitElement {
|
||||
};
|
||||
return;
|
||||
}
|
||||
this._capabilities = await fetchCameraCapabilities(
|
||||
this._api,
|
||||
this.stateObj!.entity_id
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
private async _getPosterUrl(): Promise<void> {
|
||||
@@ -269,89 +363,46 @@ export class HaCameraStream extends LitElement {
|
||||
fireEvent(this, "load");
|
||||
}
|
||||
|
||||
private _handleHlsStreams(ev: CustomEvent) {
|
||||
private _handleHlsStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
|
||||
this._hlsStreams = ev.detail;
|
||||
}
|
||||
|
||||
private _handleWebRtcStreams(ev: CustomEvent) {
|
||||
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;
|
||||
}
|
||||
this._webRtcStreams = ev.detail;
|
||||
}
|
||||
|
||||
private _streams = memoizeOne(
|
||||
(
|
||||
supportedTypes?: StreamType[],
|
||||
hlsStreams?: { hasAudio: boolean; hasVideo: boolean },
|
||||
webRtcStreams?: { hasAudio: boolean; hasVideo: boolean },
|
||||
muted?: boolean
|
||||
): Stream[] => {
|
||||
supportedTypes?: CameraCapabilities["frontend_stream_types"],
|
||||
hlsStreams?: CameraStreamTracks,
|
||||
webRtcStreams?: CameraStreamTracks,
|
||||
webRtcFirst?: boolean
|
||||
): CameraStream[] => {
|
||||
if (__DEMO__) {
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
}
|
||||
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 },
|
||||
];
|
||||
return selectCameraStreams({
|
||||
supportedTypes,
|
||||
hlsStreams,
|
||||
webRtcStreams,
|
||||
webRtcFirst,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -34,17 +35,14 @@ class HaConfigNavigationList extends LitElement {
|
||||
.href=${externalApp ? undefined : page.path}
|
||||
@click=${externalApp ? this._handleExternalApp : undefined}
|
||||
>
|
||||
<div
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class=${page.iconColor ? "icon-background" : ""}
|
||||
.style="background-color: ${page.iconColor || "undefined"}"
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${page.iconPath}
|
||||
.secondaryPath=${page.iconSecondaryPath}
|
||||
.viewBox=${page.iconViewBox}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
style=${styleMap({ backgroundColor: page.iconColor })}
|
||||
.path=${page.iconPath}
|
||||
.secondaryPath=${page.iconSecondaryPath}
|
||||
.viewBox=${page.iconViewBox}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${page.name}</span>
|
||||
${
|
||||
this.hasSecondary
|
||||
@@ -82,8 +80,6 @@ class HaConfigNavigationList extends LitElement {
|
||||
}
|
||||
.icon-background {
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
.icon-background ha-svg-icon {
|
||||
color: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -62,10 +62,6 @@ 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)) {
|
||||
|
||||
@@ -219,6 +219,7 @@ 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;
|
||||
|
||||
@@ -214,15 +214,19 @@ 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;
|
||||
|
||||
@@ -406,6 +406,8 @@ 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;
|
||||
|
||||
@@ -50,6 +50,7 @@ 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")
|
||||
@@ -396,8 +397,8 @@ export class HaRipple extends LitElement {
|
||||
var(--ha-ripple-color, var(--secondary-text-color))
|
||||
);
|
||||
transition:
|
||||
opacity 15ms linear,
|
||||
background-color 15ms linear;
|
||||
opacity var(--ha-ripple-hover-duration, 15ms) linear,
|
||||
background-color var(--ha-ripple-hover-duration, 15ms) linear;
|
||||
}
|
||||
.hovered .hover {
|
||||
opacity: var(--ha-ripple-hover-opacity, 0.08);
|
||||
|
||||
@@ -21,6 +21,8 @@ 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;
|
||||
@@ -51,6 +53,7 @@ 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,6 +16,8 @@ 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;
|
||||
@@ -27,6 +29,7 @@ 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,9 +1,18 @@
|
||||
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";
|
||||
@@ -15,7 +24,7 @@ import {
|
||||
filterSelectorEntities,
|
||||
} from "../../data/selector";
|
||||
import type { HassServiceTargetWithExclude } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantConnection } from "../../types";
|
||||
import "../ha-target-picker";
|
||||
|
||||
type TargetSelectorFilters = NonNullable<TargetSelector["target"]>;
|
||||
@@ -28,8 +37,6 @@ 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;
|
||||
@@ -44,9 +51,27 @@ 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"]) =>
|
||||
getDeviceIntegrationLookup(Object.values(entities))
|
||||
(entities: HomeAssistant["entities"] | undefined) =>
|
||||
getDeviceIntegrationLookup(Object.values(entities ?? {}))
|
||||
);
|
||||
|
||||
private _hasIntegration(selector: TargetSelector) {
|
||||
@@ -62,18 +87,31 @@ export class HaTargetSelector extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>): void {
|
||||
protected updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
const oldConnection = changedProperties.get("_connection") as
|
||||
HomeAssistantConnection | undefined;
|
||||
if (
|
||||
changedProperties.has("selector") &&
|
||||
this._hasIntegration(this.selector) &&
|
||||
!this._entitySources
|
||||
oldConnection &&
|
||||
this._connection &&
|
||||
(oldConnection.connection !== this._connection.connection ||
|
||||
(!oldConnection.connected && this._connection.connected))
|
||||
) {
|
||||
fetchEntitySourcesWithCache(this.hass.callWS, this.hass.states).then(
|
||||
(sources) => {
|
||||
this._fetchedEntitySources = false;
|
||||
}
|
||||
if (
|
||||
this._api &&
|
||||
!this._fetchedEntitySources &&
|
||||
this._hasIntegration(this.selector)
|
||||
) {
|
||||
this._fetchedEntitySources = true;
|
||||
fetchEntitySourcesWithCache(this._api.callWS, this._states ?? {})
|
||||
.then((sources) => {
|
||||
this._entitySources = sources;
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
this._entitySources ??= {};
|
||||
});
|
||||
}
|
||||
if (changedProperties.has("selector")) {
|
||||
this._createDomains = computeCreateDomains(this.selector);
|
||||
@@ -87,7 +125,6 @@ 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)}
|
||||
@@ -118,8 +155,8 @@ export class HaTargetSelector extends LitElement {
|
||||
filter,
|
||||
entity,
|
||||
entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
this._entities,
|
||||
this._devices
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -132,7 +169,7 @@ export class HaTargetSelector extends LitElement {
|
||||
}
|
||||
return (device: DeviceRegistryEntry): boolean => {
|
||||
const deviceIntegrations = this._hasIntegration(this.selector)
|
||||
? this._deviceIntegrationLookup(this.hass.entities)
|
||||
? this._deviceIntegrationLookup(this._entities)
|
||||
: undefined;
|
||||
return ensureArray(filters).some((filter) =>
|
||||
filterSelectorDevices(filter, device, deviceIntegrations)
|
||||
|
||||
@@ -17,6 +17,8 @@ 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;
|
||||
@@ -32,6 +34,7 @@ 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}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { 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";
|
||||
@@ -108,8 +111,6 @@ 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. */
|
||||
@@ -125,6 +126,14 @@ 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;
|
||||
|
||||
@@ -136,13 +145,12 @@ 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.hass.states,
|
||||
this.hass.entities,
|
||||
this._states,
|
||||
this._entities,
|
||||
this.entitySources
|
||||
)}
|
||||
.primaryEntitiesOnly=${false}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -8,6 +9,7 @@ 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";
|
||||
@@ -21,7 +23,17 @@ import {
|
||||
type FloorComboBoxItem,
|
||||
} from "../data/area_floor_picker";
|
||||
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
|
||||
import { labelsContext } from "../data/context";
|
||||
import {
|
||||
apiContext,
|
||||
brandsAccessTokenContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
internationalizationContext,
|
||||
labelsContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../data/context";
|
||||
import {
|
||||
deviceComboBoxKeys,
|
||||
getDevices,
|
||||
@@ -47,7 +59,6 @@ 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 {
|
||||
@@ -55,7 +66,11 @@ import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantUI,
|
||||
ValueChangedEvent,
|
||||
} from "../types";
|
||||
import { brandsUrl } from "../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-button";
|
||||
@@ -89,9 +104,7 @@ const isTargetType = (value: string): value is TargetType =>
|
||||
value === "floor";
|
||||
|
||||
@customElement("ha-target-picker")
|
||||
export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
export class HaTargetPicker extends LitElement {
|
||||
@property({ attribute: false }) public value?: HassServiceTargetWithExclude;
|
||||
|
||||
@property() public helper?: string;
|
||||
@@ -157,6 +170,47 @@ export class HaTargetPicker extends SubscribeMixin(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;
|
||||
@@ -167,7 +221,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
includeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
@@ -178,13 +234,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
idPrefix?: string
|
||||
) =>
|
||||
getDevices({
|
||||
states: hass.states,
|
||||
areas: hass.areas,
|
||||
devices: hass.devices,
|
||||
entities: hass.entities,
|
||||
localize: hass.localize,
|
||||
language: hass.locale.language,
|
||||
translationMetadata: hass.translationMetadata,
|
||||
states,
|
||||
areas: registries.areas,
|
||||
devices: registries.devices,
|
||||
entities: registries.entities,
|
||||
localize: i18n.localize,
|
||||
language: i18n.locale.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
configEntryLookup,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
@@ -201,7 +257,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
@@ -212,7 +270,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
value?: string,
|
||||
idPrefix?: string
|
||||
) =>
|
||||
getEntities(hass, {
|
||||
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,
|
||||
@@ -228,7 +294,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
|
||||
|
||||
private get _showEntityId() {
|
||||
return this.hass.userData?.showEntityIdPicker;
|
||||
return this._config?.userData?.showEntityIdPicker;
|
||||
}
|
||||
|
||||
private _fuseIndexes = {
|
||||
@@ -248,36 +314,34 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _pendingEntityId?: string;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
const devices = this._registries?.devices;
|
||||
const devicesChanged =
|
||||
changedProps.has("hass") &&
|
||||
this.hass.devices !== changedProps.get("hass")?.devices;
|
||||
changedProps.has("_registries") &&
|
||||
devices !==
|
||||
(
|
||||
changedProps.get("_registries") as
|
||||
ContextType<typeof registriesContext> | undefined
|
||||
)?.devices;
|
||||
if (
|
||||
(changedProps.has("value") || devicesChanged) &&
|
||||
this.hass &&
|
||||
devices &&
|
||||
this._compositeSplits === undefined &&
|
||||
!this._loadingCompositeSplits &&
|
||||
this.value?.device_id &&
|
||||
ensureArray(this.value.device_id).some(
|
||||
(deviceId) => !this.hass.devices[deviceId]
|
||||
)
|
||||
ensureArray(this.value.device_id).some((deviceId) => !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") ||
|
||||
@@ -285,18 +349,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
changedProps.has("deviceFilter") ||
|
||||
changedProps.has("includeDomains") ||
|
||||
changedProps.has("includeDeviceClasses") ||
|
||||
changedProps.has("entityFilter") ||
|
||||
registriesChanged
|
||||
changedProps.has("entityFilter")
|
||||
) {
|
||||
this._exclusions.resolve();
|
||||
}
|
||||
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
changedProps.has("hass") &&
|
||||
this.hass.states !== changedProps.get("hass")?.states &&
|
||||
this.hass.states[this._pendingEntityId]
|
||||
) {
|
||||
if (this._pendingEntityId && this._pendingEntityLoaded) {
|
||||
this._addTarget(this._pendingEntityId, "entity");
|
||||
this._pendingEntityId = undefined;
|
||||
}
|
||||
@@ -453,7 +511,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="exclusion-list-label">${this.hass.localize(label)}</div>
|
||||
<div class="exclusion-list-label">${this._i18n.localize(label)}</div>
|
||||
<ha-list-base>
|
||||
${items.map(
|
||||
(item) =>
|
||||
@@ -486,16 +544,16 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.helper=${this.helper}
|
||||
.sections=${sections}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
.emptyLabel=${this._i18n.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
.selectedSection=${this._selectedSection}
|
||||
.popoverAnchor=${this._replaceTargetAnchor}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.rowRenderer=${this._rowRenderer(this._brandsAccessToken)}
|
||||
.getItems=${this._getItems}
|
||||
@value-changed=${this._targetPicked}
|
||||
@picker-closed=${this._handlePickerClosed}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
.addButtonLabel=${this._i18n.localize(
|
||||
"ui.components.target-picker.add_target"
|
||||
)}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
@@ -510,11 +568,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.disabled=${this.disabled}
|
||||
.sections=${sections}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
.emptyLabel=${this._i18n.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
.selectedSection=${this._excludeSection}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.rowRenderer=${this._rowRenderer(this._brandsAccessToken)}
|
||||
.getItems=${this._getExcludeItems}
|
||||
@value-changed=${this._excludePicked}
|
||||
>
|
||||
@@ -529,7 +587,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiPlaylistMinus}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
${this._i18n.localize(
|
||||
"ui.components.target-picker.exclude_target"
|
||||
)}
|
||||
</ha-button>
|
||||
@@ -545,20 +603,20 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return [
|
||||
{
|
||||
id: "entity",
|
||||
label: this.hass.localize("ui.components.target-picker.type.entities"),
|
||||
label: this._i18n.localize("ui.components.target-picker.type.entities"),
|
||||
},
|
||||
{
|
||||
id: "device",
|
||||
label: this.hass.localize("ui.components.target-picker.type.devices"),
|
||||
label: this._i18n.localize("ui.components.target-picker.type.devices"),
|
||||
},
|
||||
{
|
||||
id: "area",
|
||||
label: this.hass.localize("ui.components.target-picker.type.areas"),
|
||||
label: this._i18n.localize("ui.components.target-picker.type.areas"),
|
||||
},
|
||||
"separator" as const,
|
||||
{
|
||||
id: "label",
|
||||
label: this.hass.localize("ui.components.target-picker.type.labels"),
|
||||
label: this._i18n.localize("ui.components.target-picker.type.labels"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -687,7 +745,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._excludeSection = section as TargetTypeFloorless | undefined;
|
||||
|
||||
return this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this._i18n,
|
||||
this._excludeEntityFilter(
|
||||
this.entityFilter,
|
||||
this._exclusions.includedEntities
|
||||
@@ -699,7 +757,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
undefined,
|
||||
searchString,
|
||||
this._configEntryLookup,
|
||||
this.hass.entities,
|
||||
this._states,
|
||||
this._registries,
|
||||
this._excludeSection,
|
||||
this._excludePickerIncludedDevices(this.value)
|
||||
);
|
||||
@@ -816,7 +875,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (item.entityId) {
|
||||
if (this.hass.states[item.entityId]) {
|
||||
if (this._states?.[item.entityId]) {
|
||||
this._addTarget(item.entityId, "entity");
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
@@ -903,7 +962,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._selectedSection = section as TargetTypeFloorless | undefined;
|
||||
|
||||
return this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this._i18n,
|
||||
this.entityFilter,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
@@ -912,14 +971,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._replaceTarget,
|
||||
searchString,
|
||||
this._configEntryLookup,
|
||||
this.hass.entities,
|
||||
this._states,
|
||||
this._registries,
|
||||
this._selectedSection
|
||||
);
|
||||
};
|
||||
|
||||
private _getItemsMemoized = memoizeOne(
|
||||
(
|
||||
localize: HomeAssistant["localize"],
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
entityFilter: this["entityFilter"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
includeDomains: this["includeDomains"],
|
||||
@@ -928,11 +988,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
replaceTarget: TargetItem | undefined,
|
||||
searchTerm: string,
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
_entities: HomeAssistant["entities"],
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
filterType?: TargetTypeFloorless,
|
||||
// Hidden without hiding their child devices.
|
||||
hiddenDeviceIds?: string[]
|
||||
) => {
|
||||
const localize = i18n.localize;
|
||||
const replacingEntityId =
|
||||
replaceTarget?.type === "entity" ? replaceTarget.id : undefined;
|
||||
const replacingDeviceId =
|
||||
@@ -950,7 +1012,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType || filterType === "entity") {
|
||||
let entityItems = this._getEntitiesMemoized(
|
||||
this.hass,
|
||||
states,
|
||||
registries,
|
||||
i18n,
|
||||
includeDomains,
|
||||
undefined,
|
||||
entityFilter,
|
||||
@@ -1004,7 +1068,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
...(hiddenDeviceIds ?? []),
|
||||
...(selectedDeviceIds ?? []),
|
||||
...(selectedDeviceIds
|
||||
? Object.values(this.hass.devices)
|
||||
? Object.values(registries.devices)
|
||||
.filter(
|
||||
(device) =>
|
||||
device.parent_device_id !== null &&
|
||||
@@ -1015,7 +1079,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
]
|
||||
: undefined;
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
states,
|
||||
registries,
|
||||
i18n,
|
||||
configEntryLookup,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
@@ -1066,11 +1132,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType || filterType === "area") {
|
||||
let areasAndFloors = this._getAreasAndFloorsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.floors,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
states,
|
||||
registries.floors,
|
||||
registries.areas,
|
||||
registries.devices,
|
||||
registries.entities,
|
||||
memoizeOne((value: AreaFloorValue): string =>
|
||||
[value.type, value.id].join(SEPARATOR)
|
||||
),
|
||||
@@ -1131,10 +1197,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType || filterType === "label") {
|
||||
let labels = this._getLabelsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
states,
|
||||
registries.areas,
|
||||
registries.devices,
|
||||
registries.entities,
|
||||
this._labelRegistry,
|
||||
includeDomains,
|
||||
undefined,
|
||||
@@ -1195,30 +1261,32 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
|
||||
private _getAdditionalItems = () =>
|
||||
this._getCreateItems(this.createDomains, this._i18n.localize);
|
||||
|
||||
private _getCreateItems = memoizeOne(
|
||||
(createDomains: this["createDomains"]) => {
|
||||
(
|
||||
createDomains: this["createDomains"],
|
||||
localize: ContextType<typeof internationalizationContext>["localize"]
|
||||
) => {
|
||||
if (!createDomains?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return createDomains.map((domain) => {
|
||||
const primary = this.hass.localize(
|
||||
const primary = localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
|
||||
: domainToName(this.hass.localize, domain),
|
||||
? localize(`ui.panel.config.helpers.types.${domain}`)
|
||||
: domainToName(localize, domain),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: CREATE_ID + domain,
|
||||
primary: primary,
|
||||
secondary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.new_entity"
|
||||
),
|
||||
secondary: localize("ui.components.entity.entity-picker.new_entity"),
|
||||
icon_path: mdiPlus,
|
||||
} satisfies EntityComboBoxItem;
|
||||
});
|
||||
@@ -1226,18 +1294,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
if (!this._api) {
|
||||
return;
|
||||
}
|
||||
const configEntries = await getConfigEntries(this._api);
|
||||
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.hass.connection,
|
||||
this.hass.callWS
|
||||
this._connection.connection,
|
||||
this._api.callWS
|
||||
);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
@@ -1262,6 +1336,13 @@ export class HaTargetPicker extends SubscribeMixin(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
|
||||
@@ -1281,8 +1362,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
type === "device" && !!(item as DevicePickerItem).is_child;
|
||||
if (type === "area" || type === "floor" || isChildDeviceRow) {
|
||||
rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
hasFloor =
|
||||
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
|
||||
@@ -1348,9 +1429,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
{
|
||||
domain: (item as DevicePickerItem).domain!,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
darkOptimized: this._darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
this._config?.auth?.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
@@ -1396,7 +1477,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
};
|
||||
|
||||
private _noTargetFoundLabel = (search: string) =>
|
||||
this.hass.localize("ui.components.target-picker.no_target_found", {
|
||||
this._i18n.localize("ui.components.target-picker.no_target_found", {
|
||||
term: html`<b>‘${search}’</b>`,
|
||||
});
|
||||
|
||||
@@ -1404,7 +1485,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
caseInsensitiveStringCompare(
|
||||
(entityA as PickerComboBoxItem).sorting_label!,
|
||||
(entityB as PickerComboBoxItem).sorting_label!,
|
||||
this.hass?.locale.language ?? navigator.language
|
||||
this._i18n?.locale.language ?? navigator.language
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -9,6 +9,12 @@ 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,
|
||||
@@ -24,8 +30,21 @@ 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";
|
||||
type: "not_supported" | "start_failed" | "connect_failed" | "no_connection";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@@ -99,7 +118,7 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
if (this._microphoneDenied) {
|
||||
return "denied";
|
||||
}
|
||||
if (this._microphoneConnecting) {
|
||||
if (this._microphoneConnecting || this._retryMicrophoneTrack) {
|
||||
return "connecting";
|
||||
}
|
||||
return this._localReturnAudioTrack ? "on" : "off";
|
||||
@@ -110,11 +129,17 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
}
|
||||
|
||||
public async toggleMicrophone() {
|
||||
if (this._localReturnAudioTrack || this._microphoneConnecting) {
|
||||
if (
|
||||
this._localReturnAudioTrack ||
|
||||
this._microphoneConnecting ||
|
||||
this._retryMicrophoneTrack
|
||||
) {
|
||||
// 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);
|
||||
@@ -167,8 +192,16 @@ 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);
|
||||
await this._startWebRtc(track, icePath);
|
||||
}
|
||||
|
||||
private _captureReconnectFrame() {
|
||||
@@ -197,6 +230,17 @@ 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
|
||||
@@ -212,6 +256,7 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
} else {
|
||||
this._retries = 0;
|
||||
this._startWebRtc();
|
||||
}
|
||||
};
|
||||
@@ -253,6 +298,7 @@ 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);
|
||||
@@ -274,11 +320,14 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
if (!changedProperties.has("entityid")) {
|
||||
return;
|
||||
}
|
||||
this._retries = 0;
|
||||
this._allIceServers = false;
|
||||
this._startWebRtc();
|
||||
}
|
||||
|
||||
private async _startWebRtc(
|
||||
localReturnAudioTrack?: MediaStreamTrack
|
||||
localReturnAudioTrack?: MediaStreamTrack,
|
||||
icePath?: IcePath
|
||||
): Promise<void> {
|
||||
this._cleanUp();
|
||||
|
||||
@@ -333,9 +382,21 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._logEvent("end clientConfig", this._clientConfig);
|
||||
|
||||
this._peerConnection = new RTCPeerConnection(
|
||||
this._clientConfig.configuration
|
||||
);
|
||||
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)),
|
||||
});
|
||||
|
||||
if (this._clientConfig.dataChannel) {
|
||||
// Some cameras (such as nest) require a data channel to establish a stream
|
||||
@@ -415,7 +476,7 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
while (this._candidatesList.length) {
|
||||
const candidate = this._candidatesList.pop();
|
||||
if (candidate) {
|
||||
candidates += `a=${candidate}\r\n`;
|
||||
candidates += `a=${candidate.candidate}\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +484,8 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._logEvent("start webRtcOffer", offer_sdp);
|
||||
|
||||
this._startConnectTimeout(ANSWER_TIMEOUT);
|
||||
|
||||
this._unsub = webRtcOffer(
|
||||
this._connection,
|
||||
this.entityid,
|
||||
@@ -443,11 +506,58 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
"ice connection state change",
|
||||
this._peerConnection?.iceConnectionState
|
||||
);
|
||||
if (this._peerConnection?.iceConnectionState === "failed") {
|
||||
this._peerConnection.restartIce();
|
||||
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" });
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
@@ -470,6 +580,10 @@ 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);
|
||||
@@ -495,7 +609,8 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
}
|
||||
}
|
||||
if (event.type === "error") {
|
||||
this._failStart({ type: "start_failed", message: event.message });
|
||||
// Often temporary, like the backend still closing the previous session
|
||||
this._retryOrFail({ type: "start_failed", message: event.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,8 +702,13 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
}
|
||||
|
||||
private _failStart(error: WebRtcPlayerError) {
|
||||
// The camera can reject the microphone, keep the video running without it
|
||||
if (this._localReturnAudioTrack && this._microphoneConnecting) {
|
||||
// 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
|
||||
) {
|
||||
showToast(this, {
|
||||
message: this._localize(
|
||||
"ui.components.web-rtc-player.microphone_connect_failed",
|
||||
@@ -599,12 +719,19 @@ 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();
|
||||
@@ -612,9 +739,15 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._remoteStream = undefined;
|
||||
}
|
||||
if (this._localReturnAudioTrack || this._microphoneConnecting) {
|
||||
if (
|
||||
this._localReturnAudioTrack ||
|
||||
this._microphoneConnecting ||
|
||||
this._retryMicrophoneTrack
|
||||
) {
|
||||
this._localReturnAudioTrack?.stop();
|
||||
this._localReturnAudioTrack = undefined;
|
||||
this._retryMicrophoneTrack?.stop();
|
||||
this._retryMicrophoneTrack = undefined;
|
||||
this._microphoneConnecting = false;
|
||||
this._fireMicrophoneChanged();
|
||||
}
|
||||
@@ -666,6 +799,10 @@ 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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -180,7 +181,7 @@ export class HaYamlEditor extends LitElement {
|
||||
let errorMsg: string | undefined;
|
||||
let yamlError: {
|
||||
mark?: { position: number; line: number; column: number };
|
||||
message?: string;
|
||||
reason?: string;
|
||||
} | null = null;
|
||||
|
||||
if (this._yaml) {
|
||||
@@ -189,8 +190,12 @@ export class HaYamlEditor extends LitElement {
|
||||
} catch (err: any) {
|
||||
// Invalid YAML
|
||||
isValid = false;
|
||||
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 })})` : ""}`;
|
||||
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 })})` : ""}`;
|
||||
}
|
||||
} else {
|
||||
parsed = {};
|
||||
|
||||
@@ -2,6 +2,8 @@ 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";
|
||||
|
||||
@@ -15,6 +17,19 @@ 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>`.
|
||||
@@ -40,11 +55,27 @@ export class HaListItemButton extends HaListItemBase {
|
||||
this._item?.click();
|
||||
}
|
||||
|
||||
protected _renderBase(inner: TemplateResult): TemplateResult {
|
||||
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 {
|
||||
if (this.href !== undefined) {
|
||||
return html`<a
|
||||
part="base"
|
||||
class="base interactive"
|
||||
part="control"
|
||||
class="control"
|
||||
id="item"
|
||||
href=${ifDefined(this.disabled ? undefined : this.href)}
|
||||
target=${ifDefined(this.target)}
|
||||
@@ -53,18 +84,18 @@ export class HaListItemButton extends HaListItemBase {
|
||||
tabindex="-1"
|
||||
aria-disabled=${this.disabled ? "true" : "false"}
|
||||
>
|
||||
${this._renderRipple()}${inner}
|
||||
${content}
|
||||
</a>`;
|
||||
}
|
||||
return html`<button
|
||||
part="base"
|
||||
class="base interactive"
|
||||
part="control"
|
||||
class="control"
|
||||
id="item"
|
||||
type="button"
|
||||
?disabled=${this.disabled}
|
||||
tabindex="-1"
|
||||
>
|
||||
${this._renderRipple()}${inner}
|
||||
${content}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
@@ -76,18 +107,48 @@ 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.interactive {
|
||||
width: 100%;
|
||||
.base {
|
||||
isolation: isolate;
|
||||
}
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
@@ -97,8 +158,52 @@ export class HaListItemButton extends HaListItemBase {
|
||||
appearance: none;
|
||||
cursor: inherit;
|
||||
}
|
||||
:host([disabled]) .base.interactive {
|
||||
color: var(--disabled-text-color);
|
||||
/* 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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -84,19 +84,21 @@ 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`
|
||||
${this._renderStart()}
|
||||
<div part="content" class="content">
|
||||
${
|
||||
hasContent
|
||||
? html`<slot name="content"></slot>`
|
||||
: this._renderDefaultContent()
|
||||
}
|
||||
</div>
|
||||
${this._renderEnd()}
|
||||
`;
|
||||
return html`<div part="content" class="content">
|
||||
${
|
||||
hasContent
|
||||
? html`<slot name="content"></slot>`
|
||||
: this._renderDefaultContent()
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected _renderStart(): TemplateResult {
|
||||
|
||||
@@ -125,11 +125,11 @@ 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 {
|
||||
protected override _renderControl(content: TemplateResult): TemplateResult {
|
||||
if (!this.interactive) {
|
||||
return html`<div part="base" class="base" id="item">${inner}</div>`;
|
||||
return content;
|
||||
}
|
||||
return super._renderBase(inner);
|
||||
return super._renderControl(content);
|
||||
}
|
||||
|
||||
// Always reserve the graphic, so titles line up across rows
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
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 { customElement, 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";
|
||||
@@ -33,7 +42,16 @@ import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
|
||||
|
||||
@customElement("ha-dialog-target-details")
|
||||
class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@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>;
|
||||
|
||||
@state() private _params?: TargetDetailsDialogParams;
|
||||
|
||||
@@ -50,8 +68,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
private _exclusionsRequest = 0;
|
||||
|
||||
private _deviceIntegrationLookup = memoizeOne(
|
||||
(entities: HomeAssistant["entities"]) =>
|
||||
getDeviceIntegrationLookup(Object.values(entities))
|
||||
(entities: HomeAssistant["entities"] | null) =>
|
||||
getDeviceIntegrationLookup(Object.values(entities ?? {}))
|
||||
);
|
||||
|
||||
public showDialog(params: TargetDetailsDialogParams): void {
|
||||
@@ -59,8 +77,17 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
this._initialExclusions = params.exclusions ?? [];
|
||||
this._exclusions = this._initialExclusions;
|
||||
this._opened = true;
|
||||
if (!params.exclusions && params.exclude) {
|
||||
this._resolveExclusions(params);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (
|
||||
changedProperties.has("_params") &&
|
||||
this._params &&
|
||||
!this._params.exclusions &&
|
||||
this._params.exclude
|
||||
) {
|
||||
this._resolveExclusions(this._params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +96,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
let exclusions: TargetExclusion[];
|
||||
try {
|
||||
exclusions = await resolveItemExclusions(
|
||||
this.hass,
|
||||
this._api.callWS,
|
||||
{ type: params.type, id: params.itemId },
|
||||
params.exclude,
|
||||
params.selector
|
||||
@@ -128,8 +155,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
private async _loadEntitySources(): Promise<void> {
|
||||
try {
|
||||
this._entitySources = await fetchEntitySourcesWithCache(
|
||||
this.hass.callWS,
|
||||
this.hass.states
|
||||
this._api.callWS,
|
||||
this._states ?? {}
|
||||
);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -155,7 +182,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
return true;
|
||||
}
|
||||
const deviceIntegrations = this._hasIntegration({ target })
|
||||
? this._deviceIntegrationLookup(this.hass.entities)
|
||||
? this._deviceIntegrationLookup(this._entities)
|
||||
: undefined;
|
||||
return ensureArray(target.device).some((d) =>
|
||||
filterSelectorDevices(d, device, deviceIntegrations)
|
||||
@@ -217,19 +244,19 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
.open=${this._opened}
|
||||
header-title=${this.hass.localize(
|
||||
header-title=${this._localize(
|
||||
"ui.components.target-picker.target_details"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div class="type-wrapper">
|
||||
<div class="type-label">
|
||||
${this.hass.localize(
|
||||
${this._localize(
|
||||
`ui.components.target-picker.type.${this._params.type}`
|
||||
)}
|
||||
</div>
|
||||
<ha-list-base
|
||||
.ariaLabel=${`${this.hass.localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
|
||||
.ariaLabel=${`${this._localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
|
||||
wrap-focus
|
||||
>
|
||||
${
|
||||
@@ -262,10 +289,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
${this._localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._apply}>
|
||||
${this.hass.localize("ui.common.apply")}
|
||||
${this._localize("ui.common.apply")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`
|
||||
|
||||
@@ -3,14 +3,24 @@ 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 } from "../../types";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantApi,
|
||||
HomeAssistantRegistries,
|
||||
} from "../../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
import { filterTargetEntries } from "./filter-target-entries";
|
||||
import { targetExpandGroupContext } from "./target-expand-group-context";
|
||||
@@ -26,7 +36,6 @@ import {
|
||||
|
||||
export type TargetExclusionsHost = ReactiveControllerHost &
|
||||
HTMLElement & {
|
||||
hass: HomeAssistant;
|
||||
value?: HassServiceTargetWithExclude;
|
||||
allowExclude: boolean;
|
||||
primaryEntitiesOnly?: boolean;
|
||||
@@ -55,11 +64,31 @@ 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() {
|
||||
@@ -78,9 +107,9 @@ export class TargetExclusionsController extends ContextController {
|
||||
*/
|
||||
public async resolve({ force = false }: { force?: boolean } = {}) {
|
||||
const request = ++this._request;
|
||||
const { hass, value, allowExclude } = this._host;
|
||||
const { value, allowExclude } = this._host;
|
||||
if (
|
||||
!hass ||
|
||||
!this._ready ||
|
||||
!value ||
|
||||
(!force &&
|
||||
!hasIncludedTargets(value.exclude) &&
|
||||
@@ -140,7 +169,7 @@ export class TargetExclusionsController extends ContextController {
|
||||
item: TargetItem
|
||||
): Promise<TargetExclusion | undefined> {
|
||||
const { value } = this._host;
|
||||
if (!value) {
|
||||
if (!this._ready || !value) {
|
||||
return undefined;
|
||||
}
|
||||
const explained = await this._explain({
|
||||
@@ -152,9 +181,19 @@ 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._host.hass.callWS,
|
||||
this._callWS!,
|
||||
target,
|
||||
this.expandGroup,
|
||||
this._host.primaryEntitiesOnly ?? true
|
||||
@@ -170,8 +209,9 @@ 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,
|
||||
@@ -180,10 +220,10 @@ export class TargetExclusionsController extends ContextController {
|
||||
} = this._host;
|
||||
return (include) =>
|
||||
filterTargetEntries(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
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 (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistant["callWS"],
|
||||
item: TargetItem,
|
||||
exclude: HassServiceTarget | undefined,
|
||||
primaryEntitiesOnly?: boolean,
|
||||
@@ -155,7 +155,7 @@ export const resolveItemExclusions = async (
|
||||
return [];
|
||||
}
|
||||
const explained = await explainTarget(
|
||||
hass.callWS,
|
||||
callWS,
|
||||
{ [`${item.type}_id`]: [item.id], exclude },
|
||||
expandGroup,
|
||||
primaryEntitiesOnly
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
);
|
||||
};
|
||||
+6
-4
@@ -7,13 +7,15 @@ 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 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 {
|
||||
STREAM_TYPE_HLS,
|
||||
STREAM_TYPE_WEB_RTC,
|
||||
type StreamType,
|
||||
} from "./camera-streams";
|
||||
|
||||
export { CameraEntityFeature } from "./feature/camera_entity_feature";
|
||||
|
||||
|
||||
@@ -220,24 +220,30 @@ 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") {
|
||||
this._entries.push(configEntry.entry);
|
||||
entries.push(configEntry.entry);
|
||||
return;
|
||||
}
|
||||
if (configEntry.type === "removed") {
|
||||
this._entries = this._entries.filter(
|
||||
entries = entries.filter(
|
||||
(entry) => entry.entry_id !== configEntry.entry.entry_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (configEntry.type === "updated") {
|
||||
const newEntry = configEntry.entry;
|
||||
this._entries = this._entries.map((entry) =>
|
||||
entries = entries.map((entry) =>
|
||||
entry.entry_id === newEntry.entry_id ? newEntry : entry
|
||||
);
|
||||
}
|
||||
});
|
||||
return this._entries;
|
||||
this._entries = entries;
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
import type {
|
||||
DataEntryFlowProgress,
|
||||
DataEntryFlowStep,
|
||||
@@ -30,11 +30,11 @@ const HEADERS = {
|
||||
};
|
||||
|
||||
export const createConfigFlow = (
|
||||
hass: HomeAssistant,
|
||||
callApi: HomeAssistant["callApi"],
|
||||
handler: string,
|
||||
entry_id?: string
|
||||
) =>
|
||||
hass.callApi<DataEntryFlowStep>(
|
||||
callApi<DataEntryFlowStep>(
|
||||
"POST",
|
||||
"config/config_entries/flow",
|
||||
{
|
||||
@@ -53,11 +53,11 @@ export const fetchConfigFlow = (hass: HomeAssistant, flowId: string) =>
|
||||
);
|
||||
|
||||
export const handleConfigFlowStep = (
|
||||
hass: HomeAssistant,
|
||||
callApi: HomeAssistant["callApi"],
|
||||
flowId: string,
|
||||
data: Record<string, any>
|
||||
) =>
|
||||
hass.callApi<DataEntryFlowStep>(
|
||||
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 = (
|
||||
hass: HomeAssistant,
|
||||
callApi: HomeAssistantApi["callApi"],
|
||||
type?: "helper" | "integration"
|
||||
) =>
|
||||
hass.callApi<string[]>(
|
||||
callApi<string[]>(
|
||||
"GET",
|
||||
`config/config_entries/flow_handlers${type ? `?type=${type}` : ""}`
|
||||
);
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } 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 = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: CounterMutableParams
|
||||
) =>
|
||||
hass.callWS<Counter>({
|
||||
callWS<Counter>({
|
||||
type: "counter/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } 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 = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
deviceId: string,
|
||||
updates: Partial<DeviceRegistryEntryMutableParams>
|
||||
) =>
|
||||
hass.callWS<DeviceRegistryEntry>({
|
||||
callWS<DeviceRegistryEntry>({
|
||||
type: "config/device_registry/update",
|
||||
device_id: deviceId,
|
||||
...updates,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -51,6 +52,14 @@ 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;
|
||||
@@ -63,20 +72,11 @@ export interface GetEntitiesOptions {
|
||||
}
|
||||
|
||||
export const getEntities = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
| "states"
|
||||
| "entities"
|
||||
| "devices"
|
||||
| "areas"
|
||||
| "floors"
|
||||
| "language"
|
||||
| "translationMetadata"
|
||||
| "localize"
|
||||
>,
|
||||
options?: GetEntitiesOptions
|
||||
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(hass.states);
|
||||
let entityIds = Object.keys(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: hass.entities,
|
||||
devices: hass.devices,
|
||||
areas: hass.areas,
|
||||
floors: hass.floors,
|
||||
language: hass.language,
|
||||
translationMetadata: hass.translationMetadata,
|
||||
entities: options.entities,
|
||||
devices: options.devices,
|
||||
areas: options.areas,
|
||||
floors: options.floors,
|
||||
language: options.language,
|
||||
translationMetadata: options.translationMetadata,
|
||||
};
|
||||
|
||||
items = entityIds.map<EntityComboBoxItem>((entityId) => {
|
||||
const stateObj = hass.states[entityId];
|
||||
const stateObj = states[entityId];
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
if (domainName === undefined) {
|
||||
domainName = domainToName(hass.localize, domain);
|
||||
domainName = domainToName(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, hass),
|
||||
...computeEntitySearchLabels(stateObj, displayOptions),
|
||||
domainName: domainName || null,
|
||||
entityId: entityId,
|
||||
},
|
||||
|
||||
@@ -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 } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } 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 = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
entityId: string,
|
||||
updates: Partial<EntityRegistryEntryUpdateParams>
|
||||
): Promise<UpdateEntityRegistryEntryResult> =>
|
||||
hass.callWS({
|
||||
callWS({
|
||||
type: "config/entity_registry/update",
|
||||
entity_id: entityId,
|
||||
...updates,
|
||||
@@ -388,11 +388,11 @@ export interface AutomaticEntityIdOverrides {
|
||||
}
|
||||
|
||||
export const getAutomaticEntityIds = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
entity_ids: string[],
|
||||
overrides?: Record<string, AutomaticEntityIdOverrides>
|
||||
) =>
|
||||
hass.callWS<Record<string, string | null>>({
|
||||
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 (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
entityIds: string[],
|
||||
areaId: string
|
||||
): Promise<Record<string, string>> => {
|
||||
await Promise.all(
|
||||
entityIds.map((entityId) =>
|
||||
updateEntityRegistryEntry(hass, entityId, { area_id: areaId })
|
||||
updateEntityRegistryEntry(callWS, entityId, { area_id: areaId })
|
||||
)
|
||||
);
|
||||
const newEntityIds = await getAutomaticEntityIds(hass, entityIds);
|
||||
const newEntityIds = await getAutomaticEntityIds(callWS, 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(hass, entityId, {
|
||||
await updateEntityRegistryEntry(callWS, entityId, {
|
||||
new_entity_id: newEntityId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,20 +49,12 @@ 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 type HomeSummaryPanelSystemDataKey =
|
||||
"security" | "maintenance" | "climate";
|
||||
|
||||
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),
|
||||
@@ -82,7 +74,6 @@ declare global {
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
maintenance: MaintenanceFrontendSystemData;
|
||||
climate: ClimateFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } 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 = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputBooleanMutableParams
|
||||
) =>
|
||||
hass.callWS<InputBoolean>({
|
||||
callWS<InputBoolean>({
|
||||
type: "input_boolean/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } 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 = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputButtonMutableParams
|
||||
) =>
|
||||
hass.callWS<InputButton>({
|
||||
callWS<InputButton>({
|
||||
type: "input_button/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -45,10 +45,10 @@ export const fetchInputDateTime = (hass: HomeAssistant) =>
|
||||
hass.callWS<InputDateTime[]>({ type: "input_datetime/list" });
|
||||
|
||||
export const createInputDateTime = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputDateTimeMutableParams
|
||||
) =>
|
||||
hass.callWS<InputDateTime>({
|
||||
callWS<InputDateTime>({
|
||||
type: "input_datetime/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } 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 = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputNumberMutableParams
|
||||
) =>
|
||||
hass.callWS<InputNumber>({
|
||||
callWS<InputNumber>({
|
||||
type: "input_number/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
HassEntityAttributeBase,
|
||||
HassEntityBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } 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 = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputSelectMutableParams
|
||||
) =>
|
||||
hass.callWS<InputSelect>({
|
||||
callWS<InputSelect>({
|
||||
type: "input_select/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } 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 = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputTextMutableParams
|
||||
) =>
|
||||
hass.callWS<InputText>({
|
||||
callWS<InputText>({
|
||||
type: "input_text/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -196,8 +196,8 @@ export const fetchIntegrationManifest = (
|
||||
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
|
||||
.then(sanitizeManifest);
|
||||
|
||||
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
|
||||
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
|
||||
export const fetchIntegrationSetups = (callWS: HomeAssistant["callWS"]) =>
|
||||
callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
|
||||
|
||||
export const fetchIntegrationLogInfo = (conn) =>
|
||||
conn.sendMessagePromise({
|
||||
|
||||
@@ -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?: LovelacePath;
|
||||
path?: ConfigPath;
|
||||
cards?: HuiCard[];
|
||||
badges?: HuiBadge[];
|
||||
sections?: HuiSection[];
|
||||
@@ -34,7 +34,7 @@ export interface LovelaceSectionElement extends HTMLElement {
|
||||
hass?: HomeAssistant;
|
||||
lovelace?: Lovelace;
|
||||
preview?: boolean;
|
||||
path?: LovelacePath;
|
||||
path?: ConfigPath;
|
||||
cards?: HuiCard[];
|
||||
badges?: HuiBadge[];
|
||||
sections?: HuiSection[];
|
||||
|
||||
+3
-2
@@ -21,8 +21,9 @@ export const findOTBRInfoForDataset = (
|
||||
}
|
||||
|
||||
const preferredAddress = dataset.preferred_extended_address;
|
||||
if (preferredAddress && otbrInfo[preferredAddress]) {
|
||||
return otbrInfo[preferredAddress];
|
||||
const preferred = preferredAddress ? otbrInfo[preferredAddress] : undefined;
|
||||
if (preferred?.extended_pan_id === dataset.extended_pan_id) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
return Object.values(otbrInfo).find(
|
||||
|
||||
@@ -15,7 +15,7 @@ export const regenerateEntityIds = async (
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
): Promise<void> => {
|
||||
const entityIdsMapping = await getAutomaticEntityIds(hass, entities);
|
||||
const entityIdsMapping = await getAutomaticEntityIds(hass.callWS, 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, oldEntityId, {
|
||||
updateEntityRegistryEntry(hass.callWS, oldEntityId, {
|
||||
new_entity_id: newEntityId!,
|
||||
}).catch((err: any) => {
|
||||
showAlertDialog(element, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
|
||||
export const weekdays = [
|
||||
"sunday",
|
||||
@@ -33,10 +33,10 @@ export const fetchSchedule = (hass: HomeAssistant) =>
|
||||
hass.callWS<Schedule[]>({ type: "schedule/list" });
|
||||
|
||||
export const createSchedule = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: ScheduleMutableParams
|
||||
) =>
|
||||
hass.callWS<Schedule>({
|
||||
callWS<Schedule>({
|
||||
type: "schedule/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
+2
-2
@@ -20,10 +20,10 @@ export interface SensorDeviceClassUnits {
|
||||
}
|
||||
|
||||
export const getSensorDeviceClassConvertibleUnits = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistant["callWS"],
|
||||
deviceClass: string
|
||||
): Promise<SensorDeviceClassUnits> =>
|
||||
hass.callWS({
|
||||
callWS({
|
||||
type: "sensor/device_class_convertible_units",
|
||||
device_class: deviceClass,
|
||||
});
|
||||
@@ -23,8 +23,8 @@ export interface LoggedError {
|
||||
first_occurred: number;
|
||||
}
|
||||
|
||||
export const fetchSystemLog = async (hass: HomeAssistant) => {
|
||||
const log = await hass.callWS<LoggedError[]>({ type: "system_log/list" });
|
||||
export const fetchSystemLog = async (callWS: HomeAssistant["callWS"]) => {
|
||||
const log = await callWS<LoggedError[]>({ type: "system_log/list" });
|
||||
for (const error of log) {
|
||||
error.level = error.level.toLowerCase() as LoggedError["level"];
|
||||
}
|
||||
|
||||
+6
-3
@@ -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 } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
import type { FrontendLocaleData } from "./translation";
|
||||
|
||||
export type TimerEntity = HassEntityBase & {
|
||||
@@ -48,8 +48,11 @@ export interface TimerMutableParams {
|
||||
export const fetchTimer = (hass: HomeAssistant) =>
|
||||
hass.callWS<Timer[]>({ type: "timer/list" });
|
||||
|
||||
export const createTimer = (hass: HomeAssistant, values: TimerMutableParams) =>
|
||||
hass.callWS<Timer>({
|
||||
export const createTimer = (
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: TimerMutableParams
|
||||
) =>
|
||||
callWS<Timer>({
|
||||
type: "timer/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export const updateItem = (
|
||||
"update_item",
|
||||
{
|
||||
item: item.uid,
|
||||
rename: item.summary,
|
||||
rename: item.summary || undefined,
|
||||
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, handler, dialogParams.entryId),
|
||||
createConfigFlow(hass.callApi, 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, flowId, data);
|
||||
const step = await handleConfigFlowStep(hass.callApi, 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, deviceId, {
|
||||
updateDeviceRegistryEntry(this.hass.callWS, 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,
|
||||
this.hass.callWS,
|
||||
entityIds
|
||||
);
|
||||
|
||||
Object.entries(entityIdsMapping).forEach(([oldEntityId, newEntityId]) => {
|
||||
if (newEntityId) {
|
||||
entityUpdates.push(
|
||||
updateEntityRegistryEntry(this.hass, oldEntityId, {
|
||||
updateEntityRegistryEntry(this.hass.callWS, 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,
|
||||
this.hass.callWS,
|
||||
this._entitiesWithoutDevice(entities).map((entity) => entity.entity_id),
|
||||
areaId
|
||||
);
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
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 "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 { 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";
|
||||
@@ -16,76 +25,314 @@ 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<Cropper.Data>()(
|
||||
export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
|
||||
DialogMixin<HaImageCropperDialogParams>(LitElement)
|
||||
) {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@query("img") private _image!: HTMLImageElement;
|
||||
@query("cropper-image") private _image?: CropperImage;
|
||||
|
||||
private _cropper?: Cropper;
|
||||
@query("cropper-selection") private _selection?: CropperSelection;
|
||||
|
||||
@state() private _imageSize?: { width: number; height: number };
|
||||
|
||||
@state() private _shown = false;
|
||||
|
||||
@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?.();
|
||||
}
|
||||
this._cropper?.destroy();
|
||||
this._cropper = undefined;
|
||||
if (this._imageUrl) {
|
||||
URL.revokeObjectURL(this._imageUrl);
|
||||
this._imageUrl = undefined;
|
||||
}
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = undefined;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
if (!this.params) {
|
||||
return;
|
||||
}
|
||||
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._imageUrl = URL.createObjectURL(this.params.file);
|
||||
this._loadImageSize(this._imageUrl);
|
||||
}
|
||||
|
||||
private _checkMatchAspectRatio(): boolean {
|
||||
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._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 {
|
||||
const targetRatio = this.params?.options.aspectRatio;
|
||||
if (!targetRatio) {
|
||||
return true;
|
||||
}
|
||||
const imageData = this._cropper!.getImageData();
|
||||
if (imageData.aspectRatio === targetRatio) {
|
||||
if (naturalWidth / naturalHeight === targetRatio) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the image is not exactly the aspect ratio see if it is within a pixel.
|
||||
if (imageData.naturalWidth > imageData.naturalHeight) {
|
||||
const targetHeight = imageData.naturalWidth / targetRatio;
|
||||
return Math.abs(targetHeight - imageData.naturalHeight) <= 1;
|
||||
if (naturalWidth > naturalHeight) {
|
||||
const targetHeight = naturalWidth / targetRatio;
|
||||
return Math.abs(targetHeight - naturalHeight) <= 1;
|
||||
}
|
||||
const targetWidth = imageData.naturalHeight * targetRatio;
|
||||
return Math.abs(targetWidth - imageData.naturalWidth) <= 1;
|
||||
const targetWidth = naturalHeight * targetRatio;
|
||||
return Math.abs(targetWidth - naturalWidth) <= 1;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
@@ -93,23 +340,74 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const label = this._i18n.localize("ui.dialogs.image_cropper.crop_image");
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
header-title=${this._i18n.localize(
|
||||
"ui.dialogs.image_cropper.crop_image"
|
||||
)}
|
||||
header-title=${label}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@wa-hide=${this._handleHide}
|
||||
@after-show=${this._handleAfterShow}
|
||||
>
|
||||
<div
|
||||
class="container ${classMap({
|
||||
round: Boolean(this.params.options.round),
|
||||
})}"
|
||||
>
|
||||
<img
|
||||
alt=${this._i18n.localize("ui.dialogs.image_cropper.crop_image")}
|
||||
/>
|
||||
${
|
||||
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
|
||||
}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
@@ -127,9 +425,7 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
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
|
||||
@@ -146,17 +442,99 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
`;
|
||||
}
|
||||
|
||||
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 _cropImage() {
|
||||
const canvas = this._cropper?.getCroppedCanvas();
|
||||
if (!canvas) {
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
const params = this.params!;
|
||||
@@ -188,22 +566,22 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
${unsafeCSS(cropperCss)}
|
||||
.container {
|
||||
max-width: 640px;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
cropper-canvas {
|
||||
width: 100%;
|
||||
direction: ltr;
|
||||
--theme-color: var(--primary-color);
|
||||
}
|
||||
.container.round .cropper-view-box,
|
||||
.container.round .cropper-face {
|
||||
cropper-handle {
|
||||
--theme-color: var(--primary-color);
|
||||
}
|
||||
.container.round cropper-selection,
|
||||
.container.round cropper-shade,
|
||||
.container.round cropper-grid {
|
||||
border-radius: var(--ha-picture-border-radius);
|
||||
}
|
||||
.cropper-line,
|
||||
.cropper-point,
|
||||
.cropper-point.point-se::before {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -212,5 +590,11 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
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,
|
||||
this._api.callWS,
|
||||
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,
|
||||
this._api.callWS,
|
||||
this.entry!.entity_id,
|
||||
{
|
||||
options_domain: "light",
|
||||
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
@@ -59,6 +60,8 @@ class LightRgbColorPicker extends LitElement {
|
||||
|
||||
@state() private _isInteracting?: boolean;
|
||||
|
||||
private _pendingSync = new PendingValueSync();
|
||||
|
||||
protected render() {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
@@ -181,23 +184,29 @@ 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._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._updateBrightnessAdjusted();
|
||||
|
||||
this._wvSliderValue =
|
||||
stateObj.attributes.color_mode === LightColorMode.RGBW &&
|
||||
@@ -235,11 +244,23 @@ class LightRgbColorPicker extends LitElement {
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (this._isInteracting || !changedProps.has("stateObj")) {
|
||||
if (!changedProps.has("stateObj")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateSliderValues();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private _hsColorCursorMoved(ev: CustomEvent) {
|
||||
@@ -250,6 +271,7 @@ class LightRgbColorPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
this._hsPickerValue = color;
|
||||
this._pendingSync.schedule();
|
||||
|
||||
this._throttleUpdateColor();
|
||||
}
|
||||
@@ -312,13 +334,23 @@ 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();
|
||||
@@ -366,11 +398,15 @@ class LightRgbColorPicker extends LitElement {
|
||||
|
||||
private _applyColor(color: LightColor, params?: Record<string, any>) {
|
||||
fireEvent(this, "color-changed", color);
|
||||
this._api.callService("light", "turn_on", {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
...color,
|
||||
...params,
|
||||
});
|
||||
return this._pendingSync.run(
|
||||
() =>
|
||||
this._api.callService("light", "turn_on", {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
...color,
|
||||
...params,
|
||||
}),
|
||||
() => this._updateSliderValues()
|
||||
);
|
||||
}
|
||||
|
||||
private _colorBrightnessSliderChanged(ev: CustomEvent) {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 {
|
||||
@@ -66,6 +67,8 @@ class LightColorTempPicker extends LitElement {
|
||||
|
||||
@state() private _isInteracting?: boolean;
|
||||
|
||||
private _pendingSync = new PendingValueSync();
|
||||
|
||||
protected render() {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
@@ -125,11 +128,19 @@ class LightColorTempPicker extends LitElement {
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (this._isInteracting || !changedProps.has("stateObj")) {
|
||||
if (!changedProps.has("stateObj")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateSliderValues();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private _ctColorCursorMoved(ev: CustomEvent) {
|
||||
@@ -142,6 +153,7 @@ class LightColorTempPicker extends LitElement {
|
||||
}
|
||||
|
||||
this._ctPickerValue = ct;
|
||||
this._pendingSync.schedule();
|
||||
|
||||
this._throttleUpdateColorTemp();
|
||||
}
|
||||
@@ -153,7 +165,16 @@ class LightColorTempPicker extends LitElement {
|
||||
private _ctColorChanged(ev: CustomEvent) {
|
||||
const ct = ev.detail.value;
|
||||
|
||||
if (isNaN(ct) || this._ctPickerValue === ct) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,11 +191,15 @@ class LightColorTempPicker extends LitElement {
|
||||
|
||||
private _applyColor(color: LightColor, params?: Record<string, any>) {
|
||||
fireEvent(this, "color-changed", color);
|
||||
this._api.callService("light", "turn_on", {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
...color,
|
||||
...params,
|
||||
});
|
||||
return this._pendingSync.run(
|
||||
() =>
|
||||
this._api.callService("light", "turn_on", {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
...color,
|
||||
...params,
|
||||
}),
|
||||
() => this._updateSliderValues()
|
||||
);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -112,7 +112,7 @@ export class HaMoreInfoTimerPresets extends LitElement {
|
||||
};
|
||||
|
||||
const result = await updateEntityRegistryEntry(
|
||||
this._api,
|
||||
this._api.callWS,
|
||||
this.entry.entity_id,
|
||||
{
|
||||
options_domain: "timer",
|
||||
|
||||
@@ -82,7 +82,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
|
||||
};
|
||||
|
||||
try {
|
||||
await updateEntityRegistryEntry(this.hass, this.params.entityId, {
|
||||
await updateEntityRegistryEntry(this.hass.callWS, 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,
|
||||
this._api.callWS,
|
||||
this.entry.entity_id,
|
||||
{
|
||||
options_domain: "valve",
|
||||
|
||||
@@ -88,10 +88,7 @@ class MoreInfoGroup extends LitElement {
|
||||
return "";
|
||||
}
|
||||
return html`
|
||||
<state-card-content
|
||||
.stateObj=${entityState}
|
||||
.hass=${this.hass}
|
||||
></state-card-content>
|
||||
<state-card-content .stateObj=${entityState}></state-card-content>
|
||||
`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
@@ -124,7 +124,12 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
private _volumeController = new VolumeSliderController({
|
||||
getSlider: () => this._volumeSlider,
|
||||
step: this._volumeStep,
|
||||
onSetVolume: (value) => this._setVolume(value),
|
||||
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);
|
||||
},
|
||||
onSetVolumeDebounced: (value) => this._debouncedVolumeSet(value),
|
||||
});
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ const copyFavoriteOptionsToEntities = async (
|
||||
if (selected?.entity) {
|
||||
const result = await Promise.allSettled(
|
||||
selected.entity.map((entityId: string) =>
|
||||
updateEntityRegistryEntry(hass, entityId, {
|
||||
updateEntityRegistryEntry(hass.callWS, entityId, {
|
||||
options_domain: domain,
|
||||
options,
|
||||
})
|
||||
|
||||
@@ -531,7 +531,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
const result = await updateEntityRegistryEntry(
|
||||
this.hass,
|
||||
this.hass.callWS,
|
||||
favoritesContext.entry.entity_id,
|
||||
{
|
||||
options_domain: favoritesHandler.domain,
|
||||
|
||||
@@ -74,7 +74,6 @@ export class MoreInfoInfo extends LitElement {
|
||||
<state-card-content
|
||||
in-dialog
|
||||
.stateObj=${stateObj}
|
||||
.hass=${this.hass}
|
||||
></state-card-content>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -642,7 +642,17 @@ export class QuickBar extends LitElement {
|
||||
);
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne((hass: HomeAssistant) =>
|
||||
getEntities(hass, { idPrefix: `entity${SEPARATOR}` })
|
||||
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}`,
|
||||
})
|
||||
);
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
|
||||
@@ -150,9 +150,11 @@ class DialogRestart extends LitElement {
|
||||
"ui.dialogs.restart.reload.description"
|
||||
)}
|
||||
</div>
|
||||
<div slot="start" class="icon-background reload">
|
||||
<ha-svg-icon .path=${mdiAutoFix}></ha-svg-icon>
|
||||
</div>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background reload"
|
||||
.path=${mdiAutoFix}
|
||||
></ha-svg-icon>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button
|
||||
@@ -160,9 +162,11 @@ class DialogRestart extends LitElement {
|
||||
@click=${this._handleAction}
|
||||
.disabled=${this._loadingBackupInfo}
|
||||
>
|
||||
<div slot="start" class="icon-background restart">
|
||||
<ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
|
||||
</div>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background restart"
|
||||
.path=${mdiRefresh}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.dialogs.restart.restart.title")}
|
||||
</div>
|
||||
@@ -188,14 +192,11 @@ class DialogRestart extends LitElement {
|
||||
@click=${this._handleAction}
|
||||
.disabled=${this._loadingBackupInfo}
|
||||
>
|
||||
<div
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background reboot"
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiPowerCycle}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
.path=${mdiPowerCycle}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.restart.reboot.title"
|
||||
@@ -213,12 +214,11 @@ class DialogRestart extends LitElement {
|
||||
@click=${this._handleAction}
|
||||
.disabled=${this._loadingBackupInfo}
|
||||
>
|
||||
<div
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background shutdown"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
|
||||
</div>
|
||||
.path=${mdiPower}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.restart.shutdown.title"
|
||||
@@ -239,12 +239,11 @@ class DialogRestart extends LitElement {
|
||||
@click=${this._handleAction}
|
||||
.disabled=${this._loadingBackupInfo}
|
||||
>
|
||||
<div
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background restart-safe-mode"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiLifebuoy}></ha-svg-icon>
|
||||
</div>
|
||||
.path=${mdiLifebuoy}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.restart.restart-safe-mode.title"
|
||||
@@ -436,12 +435,8 @@ class DialogRestart extends LitElement {
|
||||
.icon-background {
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 24px icon + padding = 40px circle */
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
.reload {
|
||||
background-color: #5f8a49;
|
||||
|
||||
@@ -50,7 +50,7 @@ export class HaVoiceAssistantSetupStepArea extends LitElement {
|
||||
});
|
||||
return;
|
||||
}
|
||||
await updateDeviceRegistryEntry(this.hass, this.deviceId, {
|
||||
await updateDeviceRegistryEntry(this.hass.callWS, this.deviceId, {
|
||||
area_id: area,
|
||||
});
|
||||
this._nextStep();
|
||||
|
||||
@@ -310,7 +310,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
|
||||
|
||||
if (configFlow) {
|
||||
const step = await handleConfigFlowStep(
|
||||
this.hass,
|
||||
this.hass.callApi,
|
||||
configFlow.flow_id,
|
||||
{}
|
||||
);
|
||||
@@ -343,11 +343,15 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
|
||||
}
|
||||
|
||||
private async _createConfigEntry(type: "tts" | "stt") {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
}
|
||||
);
|
||||
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, this.deviceId, {
|
||||
updateDeviceRegistryEntry(this.hass.callWS, this.deviceId, {
|
||||
name_by_user: this._deviceName,
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -45,15 +45,42 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-top {
|
||||
flex: 1;
|
||||
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
|
||||
margin-top: 142px;
|
||||
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-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin: -4px 16px;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-actions button {
|
||||
margin: 4px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 18px;
|
||||
background: none;
|
||||
color: #009ac7;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-actions .ha-launch-screen-continue {
|
||||
border-color: currentColor;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
margin: 48px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -73,6 +100,16 @@
|
||||
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
|
||||
|
||||
@@ -65,23 +65,83 @@
|
||||
</script>
|
||||
<script>
|
||||
if (!window.latestJS) {
|
||||
// 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);
|
||||
<% } %>
|
||||
}
|
||||
// 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);
|
||||
|
||||
function addButton(label, value) {
|
||||
var button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", function () {
|
||||
try {
|
||||
localStorage.setItem(ACCEPTED_KEY, value());
|
||||
} catch (_err) {
|
||||
// Continue this time only.
|
||||
}
|
||||
box.removeChild(actions);
|
||||
loadApp();
|
||||
});
|
||||
actions.appendChild(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
addButton("Continue anyway", function () {
|
||||
return String(Date.now());
|
||||
}).className = "ha-launch-screen-continue";
|
||||
if (acceptedAt) {
|
||||
addButton("Don't show again", function () {
|
||||
return "always";
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -155,6 +155,10 @@ 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();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,171 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
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 { 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: {
|
||||
type: "climate",
|
||||
},
|
||||
};
|
||||
|
||||
@customElement("ha-panel-climate")
|
||||
class PanelClimate extends HaHomeSummaryPanelBase<"climate"> {
|
||||
protected readonly _systemDataKey = "climate";
|
||||
class PanelClimate extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state() private _viewIndex = 0;
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
if (!this.hasUpdated) {
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!changedProps.has("hass")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
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
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
oldHass.areas !== this.hass.areas ||
|
||||
oldHass.floors !== this.hass.floors ||
|
||||
oldHass.panels !== this.hass.panels
|
||||
) {
|
||||
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._setLovelace();
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
() => this._registriesChanged(),
|
||||
200
|
||||
);
|
||||
|
||||
private _registriesChanged = async () => {
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
.backButton=${this._searchParams.has("historyBack")}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.climate")}</div>
|
||||
${
|
||||
this._lovelace
|
||||
? html`
|
||||
<hui-view-container .hass=${this.hass}>
|
||||
<hui-view-background .hass=${this.hass}>
|
||||
</hui-view-background>
|
||||
<hui-view
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${this._viewIndex}
|
||||
></hui-view
|
||||
></hui-view-container>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-top-app-bar-fixed>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
CLIMATE_LOVELACE_VIEW_CONFIG,
|
||||
this.hass
|
||||
);
|
||||
|
||||
const config = { views: [viewConfig] };
|
||||
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
|
||||
|
||||
if (deepEqual(config, this._lovelace?.config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._childPanelReady ??= new ChildPanelReady(this);
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: rawConfig,
|
||||
editMode: false,
|
||||
urlPath: "climate",
|
||||
mode: "generated",
|
||||
locale: this.hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
saveConfig: async () => undefined,
|
||||
deleteConfig: async () => undefined,
|
||||
setEditMode: () => undefined,
|
||||
showToast: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
user-select: none;
|
||||
}
|
||||
hui-view-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
hui-view {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -2,10 +2,9 @@ import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
import {
|
||||
findFilteredEntities,
|
||||
findEntities,
|
||||
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";
|
||||
@@ -13,15 +12,9 @@ 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[] = [
|
||||
@@ -49,53 +42,40 @@ export const climateEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const findClimateEntities = (
|
||||
sources: EntityFilterSources,
|
||||
hiddenEntities?: string[]
|
||||
): string[] =>
|
||||
findFilteredEntities(sources, climateEntityFilters, hiddenEntities);
|
||||
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])
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (hasAreaSensor) {
|
||||
return true;
|
||||
}
|
||||
return sensors;
|
||||
|
||||
const entityIds = Object.keys(hass.states);
|
||||
|
||||
return climateEntityFilters.some((filter) =>
|
||||
entityIds.some(
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
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, areaSensors, computeCard }: ClimateCardOptions
|
||||
entities: string[]
|
||||
): LovelaceCardConfig[] => {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
|
||||
for (const areaId of areaIds) {
|
||||
const area = hass.areas[areaId];
|
||||
@@ -116,13 +96,25 @@ const processAreasForClimate = (
|
||||
|
||||
// Add temperature and humidity sensors with trend graphs for areas
|
||||
const temperatureEntityId = area.temperature_entity_id;
|
||||
if (temperatureEntityId && areaSensors.has(temperatureEntityId)) {
|
||||
areaCards.push(computeCard(temperatureEntityId));
|
||||
if (temperatureEntityId && hass.states[temperatureEntityId]) {
|
||||
areaCards.push({
|
||||
...computeTileCard(temperatureEntityId),
|
||||
name:
|
||||
hass.localize("component.sensor.entity_component.temperature.name") ||
|
||||
"Temperature",
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
}
|
||||
|
||||
const humidityEntityId = area.humidity_entity_id;
|
||||
if (humidityEntityId && areaSensors.has(humidityEntityId)) {
|
||||
areaCards.push(computeCard(humidityEntityId));
|
||||
if (humidityEntityId && hass.states[humidityEntityId]) {
|
||||
areaCards.push({
|
||||
...computeTileCard(humidityEntityId),
|
||||
name:
|
||||
hass.localize("component.sensor.entity_component.humidity.name") ||
|
||||
"Humidity",
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
}
|
||||
|
||||
// Add other climate entities
|
||||
@@ -132,7 +124,18 @@ const processAreasForClimate = (
|
||||
continue;
|
||||
}
|
||||
|
||||
areaCards.push(computeCard(entityId));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
@@ -156,7 +159,7 @@ const processAreasForClimate = (
|
||||
|
||||
const processUnassignedEntities = (
|
||||
hass: HomeAssistant,
|
||||
{ entities, computeCard }: ClimateCardOptions
|
||||
entities: string[]
|
||||
): LovelaceCardConfig[] => {
|
||||
const unassignedFilter = generateEntityFilter(
|
||||
hass.states,
|
||||
@@ -170,9 +173,10 @@ const processUnassignedEntities = (
|
||||
);
|
||||
const unassignedEntities = entities.filter(unassignedFilter);
|
||||
const areaCards: LovelaceCardConfig[] = [];
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
|
||||
for (const entityId of unassignedEntities) {
|
||||
areaCards.push(computeCard(entityId));
|
||||
areaCards.push(computeTileCard(entityId));
|
||||
}
|
||||
|
||||
return areaCards;
|
||||
@@ -181,7 +185,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);
|
||||
@@ -190,40 +194,20 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
|
||||
const sections: LovelaceSectionRawConfig[] = [];
|
||||
|
||||
const hiddenEntities = new Set(config.hidden_entities);
|
||||
const editVisibility = Boolean(config.edit_visibility);
|
||||
const entities = findClimateEntities(
|
||||
hass,
|
||||
editVisibility ? undefined : config.hidden_entities
|
||||
const allEntities = Object.keys(hass.states);
|
||||
|
||||
const climateFilters = climateEntityFilters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
|
||||
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 entities = findEntities(allEntities, climateFilters);
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
@@ -249,7 +233,7 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForClimate(areaIds, hass, cardOptions);
|
||||
const areaCards = processAreasForClimate(areaIds, hass, entities);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -273,11 +257,7 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForClimate(
|
||||
hierarchy.areas,
|
||||
hass,
|
||||
cardOptions
|
||||
);
|
||||
const areaCards = processAreasForClimate(hierarchy.areas, hass, entities);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -286,7 +266,7 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
}
|
||||
|
||||
// Process unassigned entities
|
||||
const unassignedCards = processUnassignedEntities(hass, cardOptions);
|
||||
const unassignedCards = processUnassignedEntities(hass, entities);
|
||||
|
||||
if (unassignedCards.length > 0) {
|
||||
const section: LovelaceSectionRawConfig = {
|
||||
|
||||
@@ -857,6 +857,8 @@ class HaConfigAreaPage extends LitElement {
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
width="24"
|
||||
height="24"
|
||||
@error=${this._onImageError}
|
||||
@load=${this._onImageLoad}
|
||||
/>`;
|
||||
@@ -1061,7 +1063,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
img {
|
||||
.img-container img {
|
||||
border-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
@@ -1115,11 +1117,6 @@ class HaConfigAreaPage extends LitElement {
|
||||
ha-list-item-button ha-icon-next {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
ha-card > *:first-child {
|
||||
margin-top: -16px;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ import "./types/ha-automation-action-wait_for_trigger";
|
||||
import "./types/ha-automation-action-wait_template";
|
||||
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
|
||||
import { scrollRowIntoView } from "../scroll-row-into-view";
|
||||
import { AutomationPathController } from "../automation-path";
|
||||
|
||||
export interface ActionElement extends LitElement {
|
||||
action: Action;
|
||||
@@ -182,6 +183,8 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public root = false;
|
||||
|
||||
@property({ attribute: false }) public index!: number;
|
||||
|
||||
@property({ type: Boolean }) public first?: boolean;
|
||||
|
||||
@property({ type: Boolean }) public last?: boolean;
|
||||
@@ -194,6 +197,11 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
@property({ type: Boolean, attribute: "sort-selected" })
|
||||
public sortSelected = false;
|
||||
|
||||
protected readonly pathController = new AutomationPathController(this, {
|
||||
segment: () => [this.index],
|
||||
row: () => (this.optionsInSidebar ? this : undefined),
|
||||
});
|
||||
|
||||
@storage({
|
||||
key: "automationClipboard",
|
||||
state: false,
|
||||
|
||||
@@ -43,6 +43,7 @@ export class HaChooseAction extends LitElement implements ActionElement {
|
||||
|
||||
return html`
|
||||
<ha-automation-option
|
||||
path-key="choose"
|
||||
.options=${options}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._optionsChanged}
|
||||
|
||||
@@ -46,6 +46,7 @@ export class HaIfAction extends LitElement implements ActionElement {
|
||||
)}:
|
||||
</h4>
|
||||
<ha-automation-condition
|
||||
path-key="if"
|
||||
.conditions=${action.if ?? []}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._ifChanged}
|
||||
@@ -60,6 +61,7 @@ export class HaIfAction extends LitElement implements ActionElement {
|
||||
)}:
|
||||
</h4>
|
||||
<ha-automation-action
|
||||
path-key="then"
|
||||
.actions=${action.then ?? []}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._thenChanged}
|
||||
@@ -73,6 +75,7 @@ export class HaIfAction extends LitElement implements ActionElement {
|
||||
)}:
|
||||
</h4>
|
||||
<ha-automation-action
|
||||
path-key="else"
|
||||
.actions=${action.else || []}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._elseChanged}
|
||||
|
||||
@@ -35,6 +35,7 @@ export class HaParallelAction extends LitElement implements ActionElement {
|
||||
|
||||
return html`
|
||||
<ha-automation-action
|
||||
path-key="parallel"
|
||||
.actions=${action.parallel}
|
||||
.narrow=${this.narrow}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import "../ha-automation-action";
|
||||
import type { ActionElement } from "../ha-automation-action-row";
|
||||
import { AutomationPathController } from "../../automation-path";
|
||||
|
||||
import { isTemplate } from "../../../../../common/string/has-template";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
@@ -40,6 +41,11 @@ export class HaRepeatAction extends LitElement implements ActionElement {
|
||||
|
||||
@property({ type: Boolean, attribute: "indent" }) public indent = false;
|
||||
|
||||
// The form edits the repeat options, so its fields are keyed below "repeat"
|
||||
protected readonly pathController = new AutomationPathController(this, {
|
||||
segment: () => ["repeat"],
|
||||
});
|
||||
|
||||
@query("ha-form")
|
||||
private _formElement?: HaForm;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export class HaSequenceAction extends LitElement implements ActionElement {
|
||||
|
||||
return html`
|
||||
<ha-automation-action
|
||||
path-key="sequence"
|
||||
.actions=${action.sequence ?? []}
|
||||
.narrow=${this.narrow}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -90,6 +90,7 @@ export class HaWaitForTriggerAction
|
||||
${
|
||||
this.indent || (!this.inSidebar && !this.indent)
|
||||
? html`<ha-automation-trigger
|
||||
path-key="wait_for_trigger"
|
||||
class=${!this.inSidebar && !this.indent ? "expansion-panel" : ""}
|
||||
.triggers=${ensureArray(this.action.wait_for_trigger)}
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -102,19 +102,6 @@ import type {
|
||||
AutomationItemComboBoxItem,
|
||||
} from "./types";
|
||||
|
||||
type PickerSources = Pick<
|
||||
HomeAssistant,
|
||||
| "states"
|
||||
| "entities"
|
||||
| "devices"
|
||||
| "areas"
|
||||
| "floors"
|
||||
| "localize"
|
||||
| "language"
|
||||
| "locale"
|
||||
| "translationMetadata"
|
||||
>;
|
||||
|
||||
const TARGET_SEARCH_SECTIONS = [
|
||||
"separator",
|
||||
"entity",
|
||||
@@ -218,18 +205,20 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
(
|
||||
sources: PickerSources,
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
idPrefix: string
|
||||
) =>
|
||||
getDevices({
|
||||
states: sources.states,
|
||||
areas: sources.areas,
|
||||
devices: sources.devices,
|
||||
entities: sources.entities,
|
||||
localize: sources.localize,
|
||||
language: sources.locale.language,
|
||||
translationMetadata: sources.translationMetadata,
|
||||
states,
|
||||
areas: registries.areas,
|
||||
devices: registries.devices,
|
||||
entities: registries.entities,
|
||||
localize: i18n.localize,
|
||||
language: i18n.locale.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
configEntryLookup,
|
||||
idPrefix,
|
||||
})
|
||||
@@ -238,8 +227,23 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
private _getLabelsMemoized = memoizeOne(getLabels);
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(
|
||||
(sources: PickerSources, idPrefix: string) =>
|
||||
getEntities(sources, { idPrefix })
|
||||
(
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
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,
|
||||
idPrefix,
|
||||
})
|
||||
);
|
||||
|
||||
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
|
||||
@@ -247,21 +251,6 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
private _formatAreaFloorId = (value: AreaFloorValue): string =>
|
||||
[value.type, value.id].join(TARGET_SEPARATOR);
|
||||
|
||||
private _getPickerSources = memoizeOne(
|
||||
(
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>
|
||||
): PickerSources => ({
|
||||
states,
|
||||
...registries,
|
||||
localize: i18n.localize,
|
||||
language: i18n.language,
|
||||
locale: i18n.locale,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
})
|
||||
);
|
||||
|
||||
private _removeKeyboardShortcuts?: () => void;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
@@ -683,7 +672,9 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
|
||||
if (!selectedSection || selectedSection === "entity") {
|
||||
let entityItems = this._getEntitiesMemoized(
|
||||
this._getPickerSources(this._states, this._registries, this._i18n),
|
||||
this._states,
|
||||
this._registries,
|
||||
this._i18n,
|
||||
`entity${TARGET_SEPARATOR}`
|
||||
);
|
||||
|
||||
@@ -720,7 +711,9 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
|
||||
if (!selectedSection || selectedSection === "device") {
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this._getPickerSources(this._states, this._registries, this._i18n),
|
||||
this._states,
|
||||
this._registries,
|
||||
this._i18n,
|
||||
configEntryLookup,
|
||||
`device${TARGET_SEPARATOR}`
|
||||
);
|
||||
|
||||
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user