mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-19 04:57:06 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29cf30fc1a | ||
|
|
f88535c446 | ||
|
|
b9fd639bb7 | ||
|
|
2925dfc14e | ||
|
|
bb7ec34532 | ||
|
|
89015bc135 | ||
|
|
343f1e840a | ||
|
|
1369f80eb0 | ||
|
|
cd52f7b193 | ||
|
|
86dea86b6c | ||
|
|
04035016b0 | ||
|
|
52b0b46991 | ||
|
|
d5389b5bef | ||
|
|
f828c636a2 | ||
|
|
e91337b320 | ||
|
|
8ab7c118a9 | ||
|
|
0eafb1f4b4 | ||
|
|
9a7722a02e | ||
|
|
3e184eeb39 | ||
|
|
61be4e2952 | ||
|
|
315cf97e56 | ||
|
|
771a8aa4c9 | ||
|
|
d9aa649854 | ||
|
|
7a087cd6d0 | ||
|
|
32a30468bb | ||
|
|
0ace3cedfb | ||
|
|
aba7478e26 | ||
|
|
c8b2e5160a | ||
|
|
4c46ca0918 | ||
|
|
0483106f61 |
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
"app": 585518,
|
||||
"core": 57048,
|
||||
"authorize": 552423,
|
||||
"onboarding": 666818
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
"app": 887384,
|
||||
"core": 244482,
|
||||
"authorize": 844995,
|
||||
"onboarding": 1008816
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,11 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
cost_sensors: {},
|
||||
solar_forecast_domains: [],
|
||||
}));
|
||||
hass.mockWS("energy/validate", () => ({
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
({ period }): FossilEnergyConsumption => ({
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSensor = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"sensor/device_class_convertible_units",
|
||||
({ device_class }: { device_class: string }) => ({
|
||||
units: device_class === "energy" ? ["kWh"] : ["W"],
|
||||
})
|
||||
);
|
||||
hass.mockWS("sensor/numeric_device_classes", () => ({
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
|
||||
@@ -15,7 +15,6 @@ const ALL_FEATURES =
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.FAN_SPEED +
|
||||
VacuumEntityFeature.BATTERY +
|
||||
VacuumEntityFeature.STATUS +
|
||||
VacuumEntityFeature.LOCATE +
|
||||
VacuumEntityFeature.CLEAN_SPOT +
|
||||
@@ -28,8 +27,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Full featured vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 85,
|
||||
battery_icon: "mdi:battery-80",
|
||||
fan_speed: "balanced",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Charged",
|
||||
@@ -41,8 +38,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Cleaning vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 62,
|
||||
battery_icon: "mdi:battery-60",
|
||||
fan_speed: "turbo",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Cleaning bedroom",
|
||||
@@ -58,10 +53,7 @@ const ENTITIES = [
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.BATTERY,
|
||||
battery_level: 23,
|
||||
battery_icon: "mdi:battery-20",
|
||||
VacuumEntityFeature.RETURN_HOME,
|
||||
status: "Returning to dock",
|
||||
},
|
||||
},
|
||||
@@ -96,8 +88,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Paused vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 45,
|
||||
battery_icon: "mdi:battery-40",
|
||||
fan_speed: "standard",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Paused",
|
||||
|
||||
+5
-6
@@ -103,11 +103,11 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.6.17",
|
||||
"hls.js": "1.7.0",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"js-yaml": "5.2.3",
|
||||
"js-yaml": "5.3.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -151,8 +151,8 @@
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.9",
|
||||
"@rsdoctor/rspack-plugin": "1.6.2",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -186,7 +186,7 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.9.0",
|
||||
"globals": "17.11.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -223,7 +223,6 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.9.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import { css, type LitElement } from "lit";
|
||||
import type { Ref } from "lit/directives/ref";
|
||||
import { parseAnimationDuration } from "../util/parse-animation-duration";
|
||||
|
||||
type FilterPanelHost = ReactiveControllerHost &
|
||||
LitElement & { expanded: boolean };
|
||||
|
||||
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
|
||||
/**
|
||||
* Layout the controller relies on: the filter is a flex column made of its
|
||||
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
|
||||
* tall as its header; expanded, it fills what is left of the pane and hands
|
||||
* that space down to the list through the wrapper.
|
||||
*/
|
||||
export const filterPanelStyles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
flex: none;
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
ha-expansion-panel::part(summary) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const panels = new Set<FilterPanelController>();
|
||||
|
||||
let pending: Set<FilterPanelController> | undefined;
|
||||
|
||||
const flush = () => {
|
||||
const batch = [...pending!].filter((panel) => panel.host.isConnected);
|
||||
pending = undefined;
|
||||
batch.forEach((panel) => panel.prepare());
|
||||
batch.forEach((panel) => panel.measure());
|
||||
batch.forEach((panel) => panel.play());
|
||||
};
|
||||
|
||||
// Filters that change in the same frame (one closing while another opens) are
|
||||
// animated as one batch: every filter is measured before any of them has
|
||||
// changed the DOM, and the closing ones are parked at their final height so
|
||||
// the opening one reads its own final height from the real layout.
|
||||
const enqueue = (panel: FilterPanelController) => {
|
||||
if (!pending) {
|
||||
pending = new Set();
|
||||
panels.forEach((other) => other.snapshot());
|
||||
requestAnimationFrame(flush);
|
||||
}
|
||||
pending.add(panel);
|
||||
};
|
||||
|
||||
const clearInlineStyles = (element?: HTMLElement) => {
|
||||
element?.style.removeProperty("height");
|
||||
element?.style.removeProperty("flex");
|
||||
element?.style.removeProperty("overflow");
|
||||
};
|
||||
|
||||
/**
|
||||
* Animates a filter of the filter pane between its collapsed and expanded
|
||||
* heights whenever `expanded` changes, and tells the host when to render its
|
||||
* content: from the moment it expands until its collapse animation has ended.
|
||||
*
|
||||
* During the animation the content keeps its final size and the host clips
|
||||
* it, so the list is revealed rather than resized.
|
||||
*/
|
||||
export class FilterPanelController implements ReactiveController {
|
||||
public showContent = false;
|
||||
|
||||
public host: FilterPanelHost;
|
||||
|
||||
private _content: Ref<HTMLElement>;
|
||||
|
||||
private _expanded?: boolean;
|
||||
|
||||
private _first = 0;
|
||||
|
||||
private _last = 0;
|
||||
|
||||
private _contentHeight = 0;
|
||||
|
||||
private _animation?: Animation;
|
||||
|
||||
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
|
||||
this.host = host;
|
||||
this._content = content;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected() {
|
||||
panels.add(this);
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
panels.delete(this);
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
clearInlineStyles(this.host);
|
||||
clearInlineStyles(this._content.value);
|
||||
}
|
||||
|
||||
public hostUpdate() {
|
||||
const expanded = this.host.expanded;
|
||||
if (this._expanded === undefined) {
|
||||
this._expanded = expanded;
|
||||
this.showContent = expanded;
|
||||
return;
|
||||
}
|
||||
if (expanded === this._expanded) {
|
||||
return;
|
||||
}
|
||||
this._expanded = expanded;
|
||||
if (!this.host.isConnected) {
|
||||
this.showContent = expanded;
|
||||
return;
|
||||
}
|
||||
if (expanded) {
|
||||
this.showContent = true;
|
||||
}
|
||||
enqueue(this);
|
||||
}
|
||||
|
||||
public snapshot() {
|
||||
this._first = this.host.getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
public prepare() {
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
const host = this.host;
|
||||
const content = this._content.value;
|
||||
clearInlineStyles(host);
|
||||
clearInlineStyles(content);
|
||||
if (host.expanded) {
|
||||
return;
|
||||
}
|
||||
this._last =
|
||||
host.getBoundingClientRect().height -
|
||||
(content?.getBoundingClientRect().height ?? 0);
|
||||
this._contentHeight = this._first - this._last;
|
||||
host.style.flex = "none";
|
||||
host.style.height = `${this._last}px`;
|
||||
}
|
||||
|
||||
public measure() {
|
||||
if (!this.host.expanded) {
|
||||
return;
|
||||
}
|
||||
this._last = this.host.getBoundingClientRect().height;
|
||||
this._contentHeight =
|
||||
this._content.value?.getBoundingClientRect().height ?? 0;
|
||||
}
|
||||
|
||||
public play() {
|
||||
const host = this.host;
|
||||
if (this._first === this._last) {
|
||||
this._finish();
|
||||
return;
|
||||
}
|
||||
const content = this._content.value;
|
||||
host.style.flex = "none";
|
||||
host.style.overflow = "hidden";
|
||||
if (content) {
|
||||
content.style.flex = "none";
|
||||
content.style.height = `${this._contentHeight}px`;
|
||||
}
|
||||
const animation = host.animate(
|
||||
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
|
||||
{
|
||||
duration:
|
||||
parseAnimationDuration(
|
||||
getComputedStyle(host).getPropertyValue(
|
||||
"--ha-animation-duration-normal"
|
||||
)
|
||||
) || 250,
|
||||
easing: EASING,
|
||||
fill: "forwards",
|
||||
}
|
||||
);
|
||||
animation.onfinish = () => this._finish();
|
||||
this._animation = animation;
|
||||
}
|
||||
|
||||
private _finish() {
|
||||
if (!this.host.expanded) {
|
||||
this.showContent = false;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
clearInlineStyles(this.host);
|
||||
clearInlineStyles(this._content.value);
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export type LocalizeKeys =
|
||||
| `ui.dialogs.unsupported.reasons.${string}`
|
||||
| `ui.panel.config.${string}.${"caption" | "description"}`
|
||||
| `ui.panel.config.dashboard.${string}`
|
||||
| `ui.panel.config.mqtt.${string}`
|
||||
| `ui.panel.config.storage.segments.${string}`
|
||||
| `ui.panel.config.zha.${string}`
|
||||
| `ui.panel.config.zwave_js.${string}`
|
||||
|
||||
@@ -34,6 +34,8 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
@property({ type: Boolean, reflect: true, attribute: "full-width" })
|
||||
public fullWidth = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public variant:
|
||||
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
|
||||
|
||||
@@ -57,6 +59,7 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
.value=${button.value}
|
||||
@click=${this._handleClick}
|
||||
.title=${button.label}
|
||||
.disabled=${this.disabled}
|
||||
.appearance=${this.active === button.value ? "accent" : "filled"}
|
||||
>
|
||||
${
|
||||
|
||||
@@ -33,6 +33,7 @@ import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { getEntityContext } from "../common/entity/context/get_entity_context";
|
||||
import { computeDeviceName } from "../common/entity/compute_device_name";
|
||||
import { computeEntityName } from "../common/entity/compute_entity_name";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
@@ -752,6 +753,19 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._states![key]
|
||||
);
|
||||
|
||||
const entityName = computeEntityName(
|
||||
this._states![key],
|
||||
this._registries!.entities,
|
||||
this._registries!.devices
|
||||
);
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
: undefined;
|
||||
|
||||
const completionItems: CompletionItem[] = [
|
||||
{
|
||||
label: this._i18n!.localize(
|
||||
@@ -759,31 +773,39 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
),
|
||||
value: formattedState,
|
||||
subValue:
|
||||
// If the state exactly matches the formatted state, don't show the raw state
|
||||
this._states![key].state === formattedState
|
||||
? undefined
|
||||
: this._states![key].state,
|
||||
},
|
||||
];
|
||||
|
||||
if (context.device && context.device.name) {
|
||||
if (entityName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.entity.entity-picker.entity"
|
||||
),
|
||||
value: entityName,
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.device-picker.device"),
|
||||
value: context.device.name,
|
||||
value: deviceName,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.area && context.area.name) {
|
||||
if (areaName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.area-picker.area"),
|
||||
value: context.area.name,
|
||||
value: areaName,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.floor && context.floor.name) {
|
||||
if (floorName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.floor-picker.floor"),
|
||||
value: context.floor.name,
|
||||
value: floorName,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export class HaDurationInput extends LitElement {
|
||||
{ label: "-", iconPath: mdiMinusThick, value: "-" },
|
||||
]}
|
||||
.active=${this._negative ? "-" : "+"}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._negativeChanged}
|
||||
></ha-button-toggle-group>
|
||||
`
|
||||
@@ -235,8 +236,8 @@ export class HaDurationInput extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const negative = (ev.detail?.value || ev.target.value) === "-";
|
||||
this._toggleNegative = negative;
|
||||
const value = this.data;
|
||||
if (value) {
|
||||
if (this.data) {
|
||||
const value = { ...this.data };
|
||||
FIELDS.forEach((t) => {
|
||||
if (value[t]) {
|
||||
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
|
||||
|
||||
@@ -2,12 +2,7 @@ import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -39,11 +34,11 @@ export class HaFilterBlueprints extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _blueprints?: Blueprints;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
@@ -61,6 +56,7 @@ export class HaFilterBlueprints extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -75,38 +71,29 @@ export class HaFilterBlueprints extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
this._blueprints
|
||||
? html`
|
||||
<ha-list
|
||||
@selected=${this._blueprintsSelected}
|
||||
multi
|
||||
class="ha-scrollbar"
|
||||
${
|
||||
this._blueprints && this._shouldRender
|
||||
? html`
|
||||
<ha-list
|
||||
@selected=${this._blueprintsSelected}
|
||||
multi
|
||||
class="ha-scrollbar"
|
||||
>
|
||||
${Object.entries(this._blueprints).map(([id, blueprint]) =>
|
||||
"error" in blueprint
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
.value=${id}
|
||||
.selected=${(this.value || []).includes(id)}
|
||||
>
|
||||
${Object.entries(this._blueprints).map(
|
||||
([id, blueprint]) =>
|
||||
"error" in blueprint
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
.value=${id}
|
||||
.selected=${(this.value || []).includes(id)}
|
||||
>
|
||||
${blueprint.metadata.name || id}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${blueprint.metadata.name || id}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -117,6 +104,19 @@ export class HaFilterBlueprints extends LitElement {
|
||||
this._blueprints = await fetchBlueprints(this.hass, this.type);
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (this.narrow || !this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -191,11 +191,17 @@ export class HaFilterBlueprints extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list {
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -8,14 +8,9 @@ import {
|
||||
mdiTag,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import type { CategoryRegistryEntry } from "../data/category_registry";
|
||||
@@ -52,9 +47,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _categories: CategoryRegistryEntry[] = [];
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["scope"];
|
||||
|
||||
@@ -75,6 +70,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -89,11 +85,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
<ha-list
|
||||
@selected=${this._categorySelected}
|
||||
class="ha-scrollbar"
|
||||
@@ -164,17 +158,34 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
</ha-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
<ha-list-item graphic="icon" @click=${this._addCategory}>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
</ha-list-item>
|
||||
</div>
|
||||
`
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this.expanded
|
||||
? html`<ha-list-item
|
||||
graphic="icon"
|
||||
@click=${this._addCategory}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
</ha-list-item>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleAction(ev: HaDropdownSelectEvent) {
|
||||
const categoryId = (ev.currentTarget as any).categoryId;
|
||||
const action = ev.detail.item.value;
|
||||
@@ -233,6 +244,10 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -272,8 +287,19 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
position: relative;
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -299,8 +325,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
--mdc-list-item-meta-size: auto;
|
||||
--mdc-list-side-padding-right: var(--ha-space-1);
|
||||
--mdc-list-side-padding-left: var(--ha-space-4);
|
||||
@@ -315,6 +339,12 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.add {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,12 +3,7 @@ import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
|
||||
@@ -64,15 +59,13 @@ export class HaFilterDevices extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list-selectable-virtualized")
|
||||
private _listElement?: HaListSelectableVirtualized;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -84,11 +77,26 @@ export class HaFilterDevices extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded || !this._listElement) {
|
||||
return;
|
||||
}
|
||||
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 38px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -104,33 +112,31 @@ export class HaFilterDevices extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list-selectable-virtualized
|
||||
multi
|
||||
.rows=${this._devices(
|
||||
this._devicesReg,
|
||||
this._filter || "",
|
||||
this._localize,
|
||||
this._states,
|
||||
this._i18n.locale.language
|
||||
)}
|
||||
.rowRenderer=${this._renderItem}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
></ha-list-selectable-virtualized>`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list-selectable-virtualized
|
||||
multi
|
||||
.rows=${this._devices(
|
||||
this._devicesReg,
|
||||
this._filter || "",
|
||||
this._localize,
|
||||
this._states,
|
||||
this._i18n.locale.language
|
||||
)}
|
||||
.rowRenderer=${this._renderItem}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
></ha-list-selectable-virtualized>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -171,6 +177,10 @@ export class HaFilterDevices extends LitElement {
|
||||
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -261,43 +271,58 @@ export class HaFilterDevices extends LitElement {
|
||||
this._listElement?.clearSelection();
|
||||
}
|
||||
|
||||
static styles = [
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list-selectable-virtualized {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
static styles = css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
:host([expanded]) ha-expansion-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-list-selectable-virtualized {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
@@ -46,17 +41,18 @@ export class HaFilterDomains extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -71,48 +67,46 @@ export class HaFilterDomains extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._domains(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter,
|
||||
this.value
|
||||
),
|
||||
(i) => i,
|
||||
(domain) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${domain}
|
||||
.selected=${(this.value || []).includes(domain)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${domainToName(this._localize, domain)}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._domains(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter,
|
||||
this.value
|
||||
),
|
||||
(i) => i,
|
||||
(domain) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${domain}
|
||||
.selected=${(this.value || []).includes(domain)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${domainToName(this._localize, domain)}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -145,6 +139,22 @@ export class HaFilterDomains extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -189,18 +199,24 @@ export class HaFilterDomains extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list {
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: initial;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
ha-check-list-item {
|
||||
|
||||
@@ -2,13 +2,8 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
@@ -57,11 +52,11 @@ export class HaFilterEntities extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
@@ -83,6 +78,7 @@ export class HaFilterEntities extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -97,11 +93,9 @@ export class HaFilterEntities extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@@ -124,13 +118,25 @@ export class HaFilterEntities extends LitElement {
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</ha-list>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _keyFunction = (entity) => entity?.entity_id;
|
||||
|
||||
private _renderItem = (entity) =>
|
||||
@@ -167,6 +173,10 @@ export class HaFilterEntities extends LitElement {
|
||||
listItem.selected = this.value?.includes(value);
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -245,11 +255,17 @@ export class HaFilterEntities extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list {
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -4,13 +4,8 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
@@ -69,12 +64,10 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@query("ha-list-selectable") private _list?: HaListSelectable;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -93,6 +86,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -113,11 +107,9 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
<ha-list-selectable
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@@ -162,10 +154,10 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
(area) => this._renderArea(area)
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -251,6 +243,19 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -342,11 +347,17 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list-selectable {
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
@@ -49,11 +44,11 @@ export class HaFilterIntegrations extends LitElement {
|
||||
Object.values(manifests)
|
||||
);
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
const manifests = this._manifests
|
||||
@@ -64,6 +59,7 @@ export class HaFilterIntegrations extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -78,57 +74,67 @@ export class HaFilterIntegrations extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
manifests
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._itemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._integrations(
|
||||
this._localize,
|
||||
manifests,
|
||||
this._filter,
|
||||
this.value,
|
||||
this._i18n.locale.language
|
||||
),
|
||||
(i) => i.domain,
|
||||
(integration) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${integration.domain}
|
||||
.selected=${(this.value || []).includes(
|
||||
integration.domain
|
||||
)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${integration.domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${integration.name}
|
||||
</ha-check-list-item>`
|
||||
${
|
||||
manifests && this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._itemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._integrations(
|
||||
this._localize,
|
||||
manifests,
|
||||
this._filter,
|
||||
this.value,
|
||||
this._i18n.locale.language
|
||||
),
|
||||
(i) => i.domain,
|
||||
(integration) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${integration.domain}
|
||||
.selected=${(this.value || []).includes(
|
||||
integration.domain
|
||||
)}
|
||||
</ha-list>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${integration.domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${integration.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -207,11 +213,17 @@ export class HaFilterIntegrations extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list {
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { navigate } from "../common/navigate";
|
||||
@@ -49,11 +44,11 @@ export class HaFilterLabels extends LitElement {
|
||||
@state()
|
||||
private _labels?: LabelRegistryEntry[];
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
private _filteredLabels = memoizeOne(
|
||||
// `_value` used to recalculate the memoization when the selection changes
|
||||
@@ -80,6 +75,7 @@ export class HaFilterLabels extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -94,66 +90,89 @@ export class HaFilterLabels extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
@selected=${this._labelSelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._filteredLabels(
|
||||
this._labels || [],
|
||||
this._filter,
|
||||
this._i18n.locale.language,
|
||||
this.value
|
||||
),
|
||||
(label) => label.label_id,
|
||||
(label) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${label.label_id}
|
||||
.selected=${(this.value || []).includes(label.label_id)}
|
||||
hasMeta
|
||||
>
|
||||
<ha-label
|
||||
.color=${label.color}
|
||||
.description=${label.description}
|
||||
>
|
||||
${
|
||||
label.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${label.icon}
|
||||
></ha-icon>`
|
||||
: nothing
|
||||
}
|
||||
${label.name}
|
||||
</ha-label>
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
@selected=${this._labelSelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._filteredLabels(
|
||||
this._labels || [],
|
||||
this._filter,
|
||||
this._i18n.locale.language,
|
||||
this.value
|
||||
),
|
||||
(label) => label.label_id,
|
||||
(label) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${label.label_id}
|
||||
.selected=${(this.value || []).includes(label.label_id)}
|
||||
hasMeta
|
||||
>
|
||||
<ha-label
|
||||
.color=${label.color}
|
||||
.description=${label.description}
|
||||
>
|
||||
${
|
||||
label.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${label.icon}
|
||||
></ha-icon>`
|
||||
: nothing
|
||||
}
|
||||
${label.name}
|
||||
</ha-label>
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
<ha-list-item graphic="icon" @click=${this._manageLabels}>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
|
||||
${this._localize("ui.panel.config.labels.manage_labels")}
|
||||
</ha-list-item>
|
||||
</div>`
|
||||
this.expanded
|
||||
? html`<ha-list-item
|
||||
graphic="icon"
|
||||
@click=${this._manageLabels}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
|
||||
${this._localize("ui.panel.config.labels.manage_labels")}
|
||||
</ha-list-item>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
// 48px - height of ha-list-item
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _manageLabels() {
|
||||
navigate("/config/labels");
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -208,11 +227,18 @@ export class HaFilterLabels extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list {
|
||||
:host {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
@@ -241,6 +267,12 @@ export class HaFilterLabels extends LitElement {
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.add {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Chip that opens a filter pane, with a badge showing how many filters are
|
||||
* active.
|
||||
*/
|
||||
@customElement("ha-filter-pane-chip")
|
||||
export class HaFilterPaneChip extends LitElement {
|
||||
@property() public label = "";
|
||||
|
||||
/** SVG path of the leading icon. */
|
||||
@property() public path?: string;
|
||||
|
||||
/** Number of active filters, shown as a badge when there is at least one. */
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
@property({ type: Boolean }) public active = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-assist-chip
|
||||
.label=${this.label}
|
||||
.active=${this.active}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
${
|
||||
this.path
|
||||
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-assist-chip>
|
||||
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0 2px;
|
||||
color: var(--text-primary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-pane-chip": HaFilterPaneChip;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import type { List, SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-check-list-item";
|
||||
@@ -32,9 +27,9 @@ export class HaFilterStates extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
@query("ha-list") private _list!: List;
|
||||
|
||||
protected render() {
|
||||
if (!this.states) {
|
||||
@@ -45,6 +40,7 @@ export class HaFilterStates extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -59,11 +55,9 @@ export class HaFilterStates extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
<ha-list
|
||||
@selected=${this._statesSelected}
|
||||
multi
|
||||
@@ -88,13 +82,36 @@ export class HaFilterStates extends LitElement {
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
this._shouldRender = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if ((changed.has("expanded") || changed.has("states")) && this.expanded) {
|
||||
setTimeout(async () => {
|
||||
if (!this.expanded) return;
|
||||
const list = this._list;
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
list.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -135,11 +152,17 @@ export class HaFilterStates extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list {
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -2,13 +2,8 @@ import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -39,15 +34,16 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
|
||||
@state() private _voiceAssistantOptions: string[] = [];
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -62,11 +58,9 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-list
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-list
|
||||
@selected=${this._assistantsSelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@@ -89,10 +83,10 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
${voiceAssistants[voiceAssistantId].name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -101,6 +95,19 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
this._voiceAssistantOptions = Object.keys(voiceAssistants);
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -141,11 +148,18 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list {
|
||||
:host {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -43,6 +43,8 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
|
||||
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
|
||||
matter: () =>
|
||||
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
|
||||
mqtt: () =>
|
||||
import("../resources/mqtt-logo-svg").then((mod) => mod.mdiMqttLogo),
|
||||
};
|
||||
|
||||
@customElement("ha-icon")
|
||||
|
||||
@@ -81,6 +81,7 @@ export class HaChooseSelector extends LitElement {
|
||||
.required=${this.required}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
.helper=${this.helper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-selector>`;
|
||||
}
|
||||
|
||||
@@ -107,7 +108,12 @@ export class HaChooseSelector extends LitElement {
|
||||
: {
|
||||
[this._activeChoice!]: this.value,
|
||||
};
|
||||
this._activeChoice = ev.detail?.value || ev.target.value;
|
||||
const choice = ev.detail?.value || ev.target.value;
|
||||
this._activeChoice = choice;
|
||||
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
|
||||
value[choice] =
|
||||
this.selector.choose.choices[choice].selector.constant?.value;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...value,
|
||||
|
||||
@@ -664,6 +664,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
|
||||
@@ -184,6 +184,7 @@ export const checkForEntityUpdates = async (
|
||||
}
|
||||
|
||||
showToast(element, {
|
||||
id: "check-updates",
|
||||
message: hass.localize("ui.panel.config.updates.checking_updates"),
|
||||
});
|
||||
|
||||
@@ -194,6 +195,7 @@ export const checkForEntityUpdates = async (
|
||||
if (computeDomain(event.data.entity_id) === "update") {
|
||||
updated++;
|
||||
showToast(element, {
|
||||
id: "check-updates",
|
||||
message: hass.localize("ui.panel.config.updates.updates_refreshed", {
|
||||
count: updated,
|
||||
}),
|
||||
@@ -216,6 +218,7 @@ export const checkForEntityUpdates = async (
|
||||
|
||||
if (updated === 0) {
|
||||
showToast(element, {
|
||||
id: "check-updates",
|
||||
message: hass.localize("ui.panel.config.updates.no_new_updates"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export enum VacuumEntityFeature {
|
||||
STOP = 8,
|
||||
RETURN_HOME = 16,
|
||||
FAN_SPEED = 32,
|
||||
BATTERY = 64,
|
||||
STATUS = 128,
|
||||
SEND_COMMAND = 256,
|
||||
LOCATE = 512,
|
||||
@@ -34,7 +33,6 @@ export enum VacuumEntityFeature {
|
||||
}
|
||||
|
||||
interface VacuumEntityAttributes extends HassEntityAttributeBase {
|
||||
battery_level?: number;
|
||||
fan_speed?: string;
|
||||
fan_speed_list?: string[];
|
||||
[key: string]: any;
|
||||
|
||||
+3
-2
@@ -566,9 +566,10 @@ export const getWeatherStateIcon = (
|
||||
if (userDefinedIcon) {
|
||||
return html`
|
||||
<div
|
||||
style="background-size: cover;${styleMap({
|
||||
style=${styleMap({
|
||||
"background-size": "cover",
|
||||
"background-image": userDefinedIcon,
|
||||
})}"
|
||||
})}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { callWS } from "../util/websocket";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
|
||||
export enum InclusionState {
|
||||
/** The controller isn't doing anything regarding inclusion. */
|
||||
@@ -465,6 +466,27 @@ export interface RequestedGrant {
|
||||
clientSideAuth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Z-Wave node ID of a device from its registry identifiers, which have
|
||||
* the form `<home id>-<node id>[-<manufacturer>:<product type>:<product id>]`.
|
||||
* Returns undefined for devices without a node, e.g. provisioning entries.
|
||||
*/
|
||||
export const getNodeIdFromDevice = (
|
||||
device: DeviceRegistryEntry
|
||||
): number | undefined => {
|
||||
for (const [domain, identifier] of device.identifiers) {
|
||||
// a provisioning entry is identified by its DSK, whose blocks parse as numbers
|
||||
if (domain !== "zwave_js" || identifier.startsWith("provision_")) {
|
||||
continue;
|
||||
}
|
||||
const nodeId = parseInt(identifier.split("-")[1]);
|
||||
if (!isNaN(nodeId)) {
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const invokeZWaveCCApi = <T = unknown>(
|
||||
hass: HomeAssistant,
|
||||
device_id: string,
|
||||
|
||||
@@ -159,23 +159,6 @@ class MoreInfoVacuum extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Use deprecated battery_level and battery_icon attributes
|
||||
if (
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
|
||||
this.stateObj.attributes.battery_level
|
||||
) {
|
||||
return html`
|
||||
<span class="battery" slot="after-time">
|
||||
<span
|
||||
>${Math.round(
|
||||
this.stateObj.attributes.battery_level
|
||||
)}${blankBeforePercent(this._i18n.locale)}%</span
|
||||
>
|
||||
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,11 +74,6 @@ export class MockVacuumEntity extends MockBaseEntity {
|
||||
stateAttrs.fan_speed = attrs.fan_speed ?? null;
|
||||
}
|
||||
|
||||
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.BATTERY)) {
|
||||
stateAttrs.battery_level = attrs.battery_level ?? null;
|
||||
stateAttrs.battery_icon = attrs.battery_icon ?? null;
|
||||
}
|
||||
|
||||
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.STATUS)) {
|
||||
stateAttrs.status = attrs.status ?? null;
|
||||
}
|
||||
|
||||
@@ -28,12 +28,13 @@ import type {
|
||||
SortingDirection,
|
||||
} from "../components/data-table/ha-data-table";
|
||||
import { showDataTableSettingsDialog } from "../components/data-table/show-dialog-data-table-settings";
|
||||
import "../components/ha-adaptive-dialog";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-dialog";
|
||||
import "../components/ha-dialog-footer";
|
||||
import "../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
|
||||
import "../components/ha-dropdown-item";
|
||||
import "../components/ha-filter-pane-chip";
|
||||
import "../components/ha-icon-button";
|
||||
import "../components/ha-svg-icon";
|
||||
import "../components/input/ha-input-search";
|
||||
@@ -237,20 +238,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
const localize = this.localizeFunc || this.hass.localize;
|
||||
const showPane = this._showPaneController.value ?? !this.narrow;
|
||||
const filterButton = this.hasFilters
|
||||
? html`<div class="relative">
|
||||
<ha-assist-chip
|
||||
.label=${localize("ui.components.subpage-data-table.filters")}
|
||||
.active=${this.filters}
|
||||
@click=${this._toggleFilters}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
${
|
||||
this.filters
|
||||
? html`<div class="badge">${this.filters}</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
? html`<ha-filter-pane-chip
|
||||
.label=${localize("ui.components.subpage-data-table.filters")}
|
||||
.path=${mdiFilterVariant}
|
||||
.count=${this.filters}
|
||||
.active=${!!this.filters}
|
||||
@click=${this._toggleFilters}
|
||||
></ha-filter-pane-chip>`
|
||||
: nothing;
|
||||
|
||||
const selectModeBtn =
|
||||
@@ -471,18 +465,14 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
? nothing
|
||||
: html`<div class="pane" slot="pane">
|
||||
<div class="table-header">
|
||||
<ha-assist-chip
|
||||
<ha-filter-pane-chip
|
||||
.label=${localize(
|
||||
"ui.components.subpage-data-table.filters"
|
||||
)}
|
||||
.path=${mdiFilterVariant}
|
||||
active
|
||||
@click=${this._toggleFilters}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFilterVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
></ha-filter-pane-chip>
|
||||
${
|
||||
this.filters
|
||||
? html`<ha-icon-button
|
||||
@@ -576,17 +566,16 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
</hass-tabs-subpage>
|
||||
${
|
||||
this.showFilters && !showPane
|
||||
? html`<ha-adaptive-dialog
|
||||
open
|
||||
flexcontent
|
||||
? html`<ha-dialog
|
||||
.open=${true}
|
||||
width="full"
|
||||
header-title=${localize("ui.components.subpage-data-table.filters")}
|
||||
@closed=${this._closeFilters}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="headerNavigationIcon"
|
||||
data-dialog="close"
|
||||
.path=${mdiClose}
|
||||
@click=${this._closeFilters}
|
||||
.label=${localize(
|
||||
"ui.components.subpage-data-table.close_filter"
|
||||
)}
|
||||
@@ -607,13 +596,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
<slot name="filter-pane"></slot>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" data-dialog="close">
|
||||
<ha-button slot="primaryAction" @click=${this._closeFilters}>
|
||||
${localize("ui.components.subpage-data-table.show_results", {
|
||||
number: this.data.length,
|
||||
})}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-adaptive-dialog>`
|
||||
</ha-dialog>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
@@ -886,24 +875,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.narrow-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -952,11 +923,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ha-assist-chip {
|
||||
ha-assist-chip,
|
||||
ha-filter-pane-chip {
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
--ha-assist-chip-container-color: var(--card-background-color);
|
||||
}
|
||||
@@ -966,19 +934,20 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
--md-assist-chip-trailing-space: 8px;
|
||||
}
|
||||
|
||||
ha-adaptive-dialog {
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
/* Fixed height so the sheet does not resize while filtering. */
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
--ha-dialog-min-height: calc(var(--safe-height) - var(--ha-space-20));
|
||||
}
|
||||
|
||||
.filter-dialog-content {
|
||||
height: calc(
|
||||
100vh -
|
||||
70px - var(--header-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
ha-dropdown ha-assist-chip {
|
||||
|
||||
@@ -49,6 +49,7 @@ const SUPPORTED_UI_TYPES = [
|
||||
|
||||
const secretTag = defineScalarTag("!secret", {
|
||||
resolve: (data) => `!secret ${data}`,
|
||||
identify: () => false,
|
||||
});
|
||||
|
||||
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
|
||||
|
||||
@@ -545,7 +545,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${this._currentAddon.description}.<br />
|
||||
<div class="description-text">${this._currentAddon.description}</div>
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.visit_app_page",
|
||||
{
|
||||
@@ -1658,6 +1658,15 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.description:dir(rtl) > .description-text {
|
||||
text-align: right;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.long-description {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
img.logo {
|
||||
max-width: 100%;
|
||||
max-height: 40px;
|
||||
|
||||
@@ -33,23 +33,31 @@ interface ProgressSegment {
|
||||
|
||||
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
|
||||
|
||||
const ADDON_STAGES: CreateBackupStage[] = [
|
||||
"addons",
|
||||
"apps",
|
||||
// Quick metadata writes emitted while the backup is initialized, before the
|
||||
// Home Assistant stage (docker_config only by older Supervisors)
|
||||
const SETUP_STAGES: CreateBackupStage[] = [
|
||||
"addon_repositories",
|
||||
"app_repositories",
|
||||
"docker_config",
|
||||
];
|
||||
|
||||
const ADDON_STAGES: CreateBackupStage[] = ["addons", "apps"];
|
||||
|
||||
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
|
||||
|
||||
// Emitted after the backup file is finished, when the backend waits for
|
||||
// cold-backup add-ons to come back up
|
||||
const AWAIT_RESTART_STAGES: CreateBackupStage[] = [
|
||||
"await_addon_restarts",
|
||||
"await_app_restarts",
|
||||
];
|
||||
|
||||
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
|
||||
|
||||
// Ordered groups matching actual backend execution order
|
||||
// Ordered groups matching actual backend execution order. The await restart
|
||||
// stages share the last creation group to keep the progress monotonic.
|
||||
const STAGE_ORDER: CreateBackupStage[][] = [
|
||||
[...SETUP_STAGES, ...HA_STAGES],
|
||||
ADDON_STAGES,
|
||||
MEDIA_STAGES,
|
||||
HA_STAGES,
|
||||
[...MEDIA_STAGES, ...AWAIT_RESTART_STAGES],
|
||||
["upload_to_agents"],
|
||||
["cleaning_up"],
|
||||
];
|
||||
@@ -165,21 +173,21 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
return [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.apps"
|
||||
"ui.panel.config.backup.overview.progress.segments.home_assistant"
|
||||
),
|
||||
state: this._getSegmentState(0, currentGroupIndex),
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.media"
|
||||
"ui.panel.config.backup.overview.progress.segments.apps"
|
||||
),
|
||||
state: this._getSegmentState(1, currentGroupIndex),
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.home_assistant"
|
||||
"ui.panel.config.backup.overview.progress.segments.media"
|
||||
),
|
||||
state: this._getSegmentState(2, currentGroupIndex),
|
||||
flex: 2,
|
||||
@@ -201,18 +209,18 @@ export class HaBackupOverviewProgress extends LitElement {
|
||||
];
|
||||
}
|
||||
|
||||
// Non-HAOS: No app segment, just Media, HA, Upload and Cleaning up
|
||||
// Non-HAOS: No app segment, just HA, Media, Upload and Cleaning up
|
||||
return [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.media"
|
||||
"ui.panel.config.backup.overview.progress.segments.home_assistant"
|
||||
),
|
||||
state: this._getSegmentState(1, currentGroupIndex),
|
||||
state: this._getSegmentState(0, currentGroupIndex),
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.backup.overview.progress.segments.home_assistant"
|
||||
"ui.panel.config.backup.overview.progress.segments.media"
|
||||
),
|
||||
state: this._getSegmentState(2, currentGroupIndex),
|
||||
flex: 2,
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { mdiMqttLogo } from "../../resources/mqtt-logo-svg";
|
||||
|
||||
const getHasDomainCheck = (domain: string) => {
|
||||
const prefix = `${domain}.`;
|
||||
@@ -142,6 +143,14 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "knx",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/mqtt",
|
||||
iconPath: mdiMqttLogo,
|
||||
iconColor: "#660066",
|
||||
component: "mqtt",
|
||||
translationKey: "mqtt",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/thread",
|
||||
iconPath:
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
mdiAlertCircleOutline,
|
||||
mdiCheck,
|
||||
mdiDevices,
|
||||
mdiShape,
|
||||
mdiTune,
|
||||
} from "@mdi/js";
|
||||
import { storage } from "../../../../../common/decorators/storage";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-code-editor";
|
||||
import "../../../../../components/ha-formfield";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
|
||||
import "../../../../../components/ha-switch";
|
||||
import "../../../../../components/input/ha-input";
|
||||
import type { ConfigEntry } from "../../../../../data/config_entries";
|
||||
import { getConfigEntries } from "../../../../../data/config_entries";
|
||||
import type { Action } from "../../../../../data/script";
|
||||
import { callExecuteScript } from "../../../../../data/service";
|
||||
@@ -18,6 +31,10 @@ import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { showToast } from "../../../../../util/toast";
|
||||
import "./mqtt-subscribe-card";
|
||||
import { brandsUrl } from "../../../../../util/brands-url";
|
||||
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import { mdiMqttLogo } from "../../../../../resources/mqtt-logo-svg";
|
||||
|
||||
const qosLevel = ["0", "1", "2"];
|
||||
|
||||
@@ -59,78 +76,240 @@ export class MQTTConfigPanel extends LitElement {
|
||||
})
|
||||
private _retain = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@state() private _configEntry?: ConfigEntry;
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.hass) {
|
||||
this._fetchConfigEntry();
|
||||
}
|
||||
}
|
||||
|
||||
private _MQTTDeviceIds = memoizeOne(
|
||||
(
|
||||
devices: HomeAssistant["devices"],
|
||||
configEntryId?: string
|
||||
): Set<string> => {
|
||||
if (!configEntryId) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(
|
||||
Object.values(devices)
|
||||
.filter((device) => device.config_entries.includes(configEntryId))
|
||||
.map((device) => device.id)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _entityCount = memoizeOne(
|
||||
(entities: HomeAssistant["entities"], deviceIds: Set<string>): number =>
|
||||
Object.values(entities).filter(
|
||||
(entity) => entity.device_id && deviceIds.has(entity.device_id)
|
||||
).length
|
||||
);
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._configEntry) {
|
||||
return nothing;
|
||||
}
|
||||
const isOnline = this._configEntry.state === "loaded";
|
||||
const deviceIds = this._MQTTDeviceIds(
|
||||
this.hass.devices,
|
||||
this._configEntry.entry_id
|
||||
);
|
||||
const entityCount = this._entityCount(this.hass.entities, deviceIds);
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
header="MQTT"
|
||||
back-path="/config/integrations/integration/mqtt"
|
||||
has-fab
|
||||
>
|
||||
<div class="content">
|
||||
<ha-card
|
||||
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
|
||||
>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._openOptionFlow}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.mqtt.option_flow"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.mqtt.description_publish"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<div class="panel-dev-mqtt-fields">
|
||||
<ha-input
|
||||
.label=${this.hass.localize("ui.panel.config.mqtt.topic")}
|
||||
.value=${this._topic}
|
||||
@change=${this._handleTopic}
|
||||
></ha-input>
|
||||
<ha-select
|
||||
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
|
||||
.value=${this._qos}
|
||||
@selected=${this._handleQos}
|
||||
.options=${qosLevel}
|
||||
>
|
||||
</ha-select>
|
||||
<ha-formfield
|
||||
label=${this.hass!.localize("ui.panel.config.mqtt.retain")}
|
||||
>
|
||||
<ha-switch
|
||||
@change=${this._handleRetain}
|
||||
.checked=${this._retain}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
<p>${this.hass.localize("ui.panel.config.mqtt.payload")}</p>
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
.value=${this._payload}
|
||||
@value-changed=${this._handlePayload}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._publish}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.mqtt.publish"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<mqtt-subscribe-card .hass=${this.hass}></mqtt-subscribe-card>
|
||||
<div class="container">
|
||||
${this._renderNetworkStatus(isOnline, deviceIds.size)}
|
||||
${this._renderMyNetworkCard(deviceIds.size, entityCount)}
|
||||
${this._renderNavigationCard()} ${this._renderPublishCard()}
|
||||
<mqtt-subscribe-card .hass=${this.hass}></mqtt-subscribe-card>
|
||||
</div>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderNetworkStatus(isOnline: boolean, deviceCount: number) {
|
||||
return html`
|
||||
<ha-card class="content network-status">
|
||||
<div class="card-content">
|
||||
<div class="heading">
|
||||
<div class="icon ${isOnline ? "success" : "error"}">
|
||||
<ha-svg-icon
|
||||
.path=${isOnline ? mdiCheck : mdiAlertCircleOutline}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.mqtt.status_${isOnline ? "online" : "offline"}`
|
||||
)}<br />
|
||||
<small>
|
||||
${this.hass.localize("ui.panel.config.mqtt.devices", {
|
||||
count: deviceCount,
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
<img
|
||||
class="logo"
|
||||
alt="MQTT"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: "mqtt",
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMyNetworkCard(deviceCount: number, entityCount: number) {
|
||||
return html`
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-header">
|
||||
${this.hass.localize("ui.panel.config.mqtt.my_network_title")}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.mqtt.device_count", {
|
||||
count: deviceCount,
|
||||
})}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.mqtt.entity_count", {
|
||||
count: entityCount,
|
||||
})}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderNavigationCard() {
|
||||
return html`
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item type="link" @click=${this._openOptionFlow}>
|
||||
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.mqtt.option_flow")}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.mqtt.option_flow_description"
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
<ha-md-list-item type="link" @click=${this._openConfigFlow}>
|
||||
<ha-svg-icon slot="start" .path=${mdiMqttLogo}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.mqtt.config_flow")}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.mqtt.config_flow_description"
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPublishCard() {
|
||||
return html`
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.mqtt.description_publish"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<div class="panel-dev-mqtt-fields">
|
||||
<ha-input
|
||||
.label=${this.hass.localize("ui.panel.config.mqtt.topic")}
|
||||
.value=${this._topic}
|
||||
@change=${this._handleTopic}
|
||||
></ha-input>
|
||||
<ha-select
|
||||
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
|
||||
.value=${this._qos}
|
||||
@selected=${this._handleQos}
|
||||
.options=${qosLevel}
|
||||
>
|
||||
</ha-select>
|
||||
<ha-formfield
|
||||
label=${this.hass!.localize("ui.panel.config.mqtt.retain")}
|
||||
>
|
||||
<ha-switch
|
||||
@change=${this._handleRetain}
|
||||
.checked=${this._retain}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
<p>${this.hass.localize("ui.panel.config.mqtt.payload")}</p>
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
.value=${this._payload}
|
||||
@value-changed=${this._handlePayload}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._publish}
|
||||
>${this.hass.localize("ui.panel.config.mqtt.publish")}</ha-button
|
||||
>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _fetchConfigEntry(): Promise<void> {
|
||||
const configEntries = await getConfigEntries(this.hass, {
|
||||
domain: "mqtt",
|
||||
});
|
||||
this._configEntry = configEntries.find(
|
||||
(entry) => entry.disabled_by === null && entry.source !== "ignore"
|
||||
);
|
||||
}
|
||||
|
||||
private _handleTopic(ev: InputEvent) {
|
||||
this._topic = (ev.target as HTMLInputElement).value;
|
||||
}
|
||||
@@ -175,19 +354,23 @@ export class MQTTConfigPanel extends LitElement {
|
||||
}
|
||||
|
||||
private async _openOptionFlow() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (!searchParams.has("config_entry")) {
|
||||
showOptionsFlowDialog(this, this._configEntry!);
|
||||
}
|
||||
|
||||
private _openConfigFlow = async () => {
|
||||
if (!this._configEntry) {
|
||||
return;
|
||||
}
|
||||
const configEntryId = searchParams.get("config_entry") as string;
|
||||
const configEntries = await getConfigEntries(this.hass, {
|
||||
domain: "mqtt",
|
||||
showConfigFlowDialog(this, {
|
||||
startFlowHandler: this._configEntry.domain,
|
||||
manifest: await fetchIntegrationManifest(
|
||||
this.hass,
|
||||
this._configEntry.domain
|
||||
),
|
||||
entryId: this._configEntry.entry_id,
|
||||
navigateToResult: true,
|
||||
});
|
||||
const configEntry = configEntries.find(
|
||||
(entry) => entry.entry_id === configEntryId
|
||||
);
|
||||
showOptionsFlowDialog(this, configEntry!);
|
||||
}
|
||||
};
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
@@ -199,17 +382,37 @@ export class MQTTConfigPanel extends LitElement {
|
||||
-moz-user-select: initial;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px 0 32px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
direction: ltr;
|
||||
.nav-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-card .card-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-card .card-header {
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
.content {
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.panel-dev-mqtt-fields {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
ha-card {
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
ha-select {
|
||||
width: 96px;
|
||||
margin: 0 8px;
|
||||
@@ -236,6 +439,78 @@ export class MQTTConfigPanel extends LitElement {
|
||||
display: block;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.network-status div.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.network-status div.heading .logo {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
margin-inline-start: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.network-status div.heading .icon {
|
||||
position: relative;
|
||||
border-radius: var(--ha-border-radius-2xl);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
--icon-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.network-status div.heading .icon.success {
|
||||
--icon-color: var(--success-color);
|
||||
}
|
||||
|
||||
.network-status div.heading .icon.error {
|
||||
--icon-color: var(--error-color);
|
||||
}
|
||||
|
||||
.network-status div.heading .icon::before {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--icon-color);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.network-status div.heading .icon ha-svg-icon {
|
||||
color: var(--icon-color);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.network-status div.heading .details {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.network-status small {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: 0.25px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4)
|
||||
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
a[slot="fab"] {
|
||||
text-decoration: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mdiContentCopy } from "@mdi/js";
|
||||
import { formatTime } from "../../../../../common/datetime/format_time";
|
||||
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-markdown";
|
||||
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
|
||||
import "../../../../../components/ha-select";
|
||||
import "../../../../../components/input/ha-input";
|
||||
import type { MQTTMessage } from "../../../../../data/mqtt";
|
||||
import { subscribeMQTTTopic } from "../../../../../data/mqtt";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { showToast } from "../../../../../util/toast";
|
||||
|
||||
import { storage } from "../../../../../common/decorators/storage";
|
||||
import "../../../../../components/ha-formfield";
|
||||
@@ -68,53 +73,56 @@ class MqttSubscribeCard extends LitElement {
|
||||
return html`
|
||||
<ha-card
|
||||
header=${this.hass.localize("ui.panel.config.mqtt.description_listen")}
|
||||
class="content_subscribe_panel"
|
||||
>
|
||||
<form>
|
||||
<p>
|
||||
<ha-formfield
|
||||
label=${this.hass!.localize(
|
||||
"ui.panel.config.mqtt.json_formatting"
|
||||
)}
|
||||
>
|
||||
<ha-switch
|
||||
@change=${this._handleJSONFormat}
|
||||
.checked=${this._json_format}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</p>
|
||||
<div class="panel-dev-mqtt-subscribe-fields">
|
||||
<ha-input
|
||||
.label=${
|
||||
this._subscribed
|
||||
? this.hass.localize("ui.panel.config.mqtt.listening_to")
|
||||
: this.hass.localize("ui.panel.config.mqtt.subscribe_to")
|
||||
}
|
||||
.disabled=${this._subscribed !== undefined}
|
||||
.value=${this._topic}
|
||||
@change=${this._handleTopic}
|
||||
></ha-input>
|
||||
<ha-select
|
||||
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
|
||||
.disabled=${this._subscribed !== undefined}
|
||||
.value=${this._qos}
|
||||
@selected=${this._handleQos}
|
||||
.options=${qosLevel}
|
||||
>
|
||||
</ha-select>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
.disabled=${this._topic === ""}
|
||||
@click=${this._handleSubmit}
|
||||
>
|
||||
${
|
||||
this._subscribed
|
||||
? this.hass.localize("ui.panel.config.mqtt.stop_listening")
|
||||
: this.hass.localize("ui.panel.config.mqtt.start_listening")
|
||||
}
|
||||
</ha-button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card-content">
|
||||
<form>
|
||||
<p>
|
||||
<ha-formfield
|
||||
label=${this.hass!.localize(
|
||||
"ui.panel.config.mqtt.json_formatting"
|
||||
)}
|
||||
>
|
||||
<ha-switch
|
||||
@change=${this._handleJSONFormat}
|
||||
.checked=${this._json_format}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</p>
|
||||
<div class="panel-dev-mqtt-subscribe-fields">
|
||||
<ha-input
|
||||
.label=${
|
||||
this._subscribed
|
||||
? this.hass.localize("ui.panel.config.mqtt.listening_to")
|
||||
: this.hass.localize("ui.panel.config.mqtt.subscribe_to")
|
||||
}
|
||||
.disabled=${this._subscribed !== undefined}
|
||||
.value=${this._topic}
|
||||
@change=${this._handleTopic}
|
||||
></ha-input>
|
||||
<ha-select
|
||||
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
|
||||
.disabled=${this._subscribed !== undefined}
|
||||
.value=${this._qos}
|
||||
@selected=${this._handleQos}
|
||||
.options=${qosLevel}
|
||||
>
|
||||
</ha-select>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
.disabled=${this._topic === ""}
|
||||
@click=${this._handleSubmit}
|
||||
>
|
||||
${
|
||||
this._subscribed
|
||||
? this.hass.localize("ui.panel.config.mqtt.stop_listening")
|
||||
: this.hass.localize("ui.panel.config.mqtt.start_listening")
|
||||
}
|
||||
</ha-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="events">
|
||||
${this._messages.map(
|
||||
(msg) => html`
|
||||
@@ -128,7 +136,17 @@ class MqttSubscribeCard extends LitElement {
|
||||
this.hass!.config
|
||||
),
|
||||
})}
|
||||
<pre>${msg.payload}</pre>
|
||||
<div class="code-block">
|
||||
<ha-icon-button
|
||||
class="copy-button"
|
||||
.path=${mdiContentCopy}
|
||||
@click=${this._handleCopyClick}
|
||||
data-payload=${msg.payload}
|
||||
></ha-icon-button>
|
||||
<ha-markdown
|
||||
.content=${`\`\`\`${this._json_format ? "json" : ""}\n${msg.payload}\n\`\`\``}
|
||||
></ha-markdown>
|
||||
</div>
|
||||
<div class="bottom">
|
||||
QoS: ${msg.message.qos} - Retain:
|
||||
${Boolean(msg.message.retain)}
|
||||
@@ -156,6 +174,16 @@ class MqttSubscribeCard extends LitElement {
|
||||
this._json_format = (ev.target! as any).checked;
|
||||
}
|
||||
|
||||
private async _handleCopyClick(ev: Event): Promise<void> {
|
||||
const payload = (ev.target as HTMLElement).getAttribute("data-payload");
|
||||
if (payload) {
|
||||
await copyToClipboard(payload);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleSubmit(): Promise<void> {
|
||||
if (this._subscribed) {
|
||||
this._subscribed();
|
||||
@@ -199,6 +227,12 @@ class MqttSubscribeCard extends LitElement {
|
||||
padding: var(--ha-space-4);
|
||||
padding-bottom: var(--ha-space-8);
|
||||
}
|
||||
.content_subscribe_panel {
|
||||
margin-top: var(--ha-space-6);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
direction: ltr;
|
||||
}
|
||||
.events {
|
||||
margin: -16px 0;
|
||||
padding: 0 16px;
|
||||
@@ -230,6 +264,20 @@ class MqttSubscribeCard extends LitElement {
|
||||
ha-input {
|
||||
flex: 1;
|
||||
}
|
||||
.code-block {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.code-block ha-markdown {
|
||||
padding-right: 40px;
|
||||
}
|
||||
.copy-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
ha-select {
|
||||
display: block;
|
||||
|
||||
+96
-60
@@ -2,6 +2,7 @@ import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -19,11 +20,13 @@ import "../../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
import type {
|
||||
RssiError,
|
||||
ZWaveJSNodeStatisticsUpdatedMessage,
|
||||
ZWaveJSNodeStatus,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import {
|
||||
fetchZwaveNetworkStatus,
|
||||
getNodeIdFromDevice,
|
||||
NodeStatus,
|
||||
subscribeZwaveNodeStatistics,
|
||||
} from "../../../../../data/zwave_js";
|
||||
@@ -54,19 +57,36 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _searchFilter = "";
|
||||
|
||||
public hassSubscribe() {
|
||||
const devices = Object.values(this.hass.devices).filter((device) =>
|
||||
device.config_entries.some((entry) => entry === this.configEntryId)
|
||||
);
|
||||
// Route statistics reference repeaters by device registry ID
|
||||
private _nodeIdsByDeviceId: Record<string, number> = {};
|
||||
|
||||
return devices.map((device) =>
|
||||
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
|
||||
const nodeId = message.nodeId ?? message.node_id;
|
||||
this._devices[nodeId!] = device;
|
||||
this._nodeStatistics[nodeId!] = message;
|
||||
this._handleUpdatedNodeStatistics();
|
||||
})
|
||||
);
|
||||
public hassSubscribe() {
|
||||
const subscriptions: Promise<UnsubscribeFunc>[] = [];
|
||||
const devices: Record<number, DeviceRegistryEntry> = {};
|
||||
const nodeIdsByDeviceId: Record<string, number> = {};
|
||||
|
||||
Object.values(this.hass.devices).forEach((device) => {
|
||||
if (!device.config_entries.includes(this.configEntryId)) {
|
||||
return;
|
||||
}
|
||||
const nodeId = getNodeIdFromDevice(device);
|
||||
if (nodeId === undefined) {
|
||||
return;
|
||||
}
|
||||
devices[nodeId] = device;
|
||||
nodeIdsByDeviceId[device.id] = nodeId;
|
||||
subscriptions.push(
|
||||
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
|
||||
this._nodeStatistics[nodeId] = message;
|
||||
this._handleUpdatedNodeStatistics();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
this._nodeIdsByDeviceId = nodeIdsByDeviceId;
|
||||
this._devices = devices;
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
@@ -164,8 +184,10 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
|
||||
const targetName =
|
||||
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
|
||||
const route =
|
||||
this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr;
|
||||
// links point away from the controller, so the route belongs to the target
|
||||
const stats =
|
||||
this._nodeStatistics[target] ?? this._nodeStatistics[source];
|
||||
const route = stats?.lwr || stats?.nlwr;
|
||||
return html`${sourceName} →
|
||||
${targetName}${
|
||||
route?.protocol_data_rate
|
||||
@@ -333,55 +355,69 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
});
|
||||
|
||||
if (controllerNode === undefined) {
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
const controllerId = String(controllerNode);
|
||||
|
||||
Object.entries(nodeStatistics).forEach(([nodeId, stats]) => {
|
||||
const route = stats.lwr || stats.nlwr;
|
||||
if (route) {
|
||||
const hops = [
|
||||
...route.repeaters.map((id, i) => [
|
||||
Object.keys(this._devices).find(
|
||||
(_nodeId) => this._devices[_nodeId]?.id === id
|
||||
)?.[0],
|
||||
route.repeater_rssi[i],
|
||||
]),
|
||||
[controllerNode!, route.rssi],
|
||||
];
|
||||
let sourceNode: string = nodeId;
|
||||
hops.forEach(([repeater, rssi]) => {
|
||||
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
|
||||
const existingLink = links.find(
|
||||
(link) =>
|
||||
link.source === sourceNode && link.target === String(repeater)
|
||||
);
|
||||
const width = this._getLineWidth(RSSI);
|
||||
if (existingLink) {
|
||||
existingLink.value = Math.max(existingLink.value!, RSSI);
|
||||
existingLink.lineStyle = {
|
||||
...existingLink.lineStyle,
|
||||
width: Math.max(existingLink.lineStyle!.width!, width),
|
||||
type:
|
||||
route.protocol_data_rate > 1
|
||||
? "solid"
|
||||
: existingLink.lineStyle!.type,
|
||||
};
|
||||
} else {
|
||||
links.push({
|
||||
source: sourceNode,
|
||||
target: String(repeater),
|
||||
value: RSSI,
|
||||
lineStyle: {
|
||||
width,
|
||||
color:
|
||||
repeater === controllerNode
|
||||
? style.getPropertyValue("--primary-color")
|
||||
: style.getPropertyValue("--disabled-color"),
|
||||
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
|
||||
},
|
||||
symbolSize: width * 3,
|
||||
});
|
||||
}
|
||||
sourceNode = String(repeater);
|
||||
});
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
// Routes go from the controller to the node via the repeaters, in order.
|
||||
// Each station measures the hop leaving it: the controller reports
|
||||
// `rssi`, repeater i reports `repeater_rssi[i]`.
|
||||
const hops: [string, RssiError | number | null][] = [];
|
||||
let hopRssi = route.rssi;
|
||||
route.repeaters.forEach((deviceId, i) => {
|
||||
const repeaterNodeId = this._nodeIdsByDeviceId[deviceId];
|
||||
// skip repeaters we can't resolve, so the chain stays connected
|
||||
if (repeaterNodeId !== undefined) {
|
||||
hops.push([String(repeaterNodeId), hopRssi]);
|
||||
}
|
||||
hopRssi = route.repeater_rssi[i];
|
||||
});
|
||||
hops.push([nodeId, hopRssi]);
|
||||
|
||||
let sourceNode = controllerId;
|
||||
hops.forEach(([target, rssi]) => {
|
||||
if (target === sourceNode) {
|
||||
return;
|
||||
}
|
||||
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
|
||||
const existingLink = links.find(
|
||||
(link) => link.source === sourceNode && link.target === target
|
||||
);
|
||||
const width = this._getLineWidth(RSSI);
|
||||
if (existingLink) {
|
||||
existingLink.value = Math.max(existingLink.value!, RSSI);
|
||||
existingLink.lineStyle = {
|
||||
...existingLink.lineStyle,
|
||||
width: Math.max(existingLink.lineStyle!.width!, width),
|
||||
type:
|
||||
route.protocol_data_rate > 1
|
||||
? "solid"
|
||||
: existingLink.lineStyle!.type,
|
||||
};
|
||||
} else {
|
||||
links.push({
|
||||
source: sourceNode,
|
||||
target,
|
||||
value: RSSI,
|
||||
lineStyle: {
|
||||
width,
|
||||
color:
|
||||
sourceNode === controllerId
|
||||
? style.getPropertyValue("--primary-color")
|
||||
: style.getPropertyValue("--disabled-color"),
|
||||
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
|
||||
},
|
||||
symbolSize: width * 3,
|
||||
});
|
||||
}
|
||||
sourceNode = target;
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import { mdiAccount, mdiAccountPlus, mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -19,6 +19,7 @@ import type { PersonMutableParams } from "../../../data/person";
|
||||
import type { User } from "../../../data/user";
|
||||
import {
|
||||
deleteUser,
|
||||
fetchUsers,
|
||||
SYSTEM_GROUP_ID_ADMIN,
|
||||
SYSTEM_GROUP_ID_USER,
|
||||
updateUser,
|
||||
@@ -36,6 +37,8 @@ import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { showAddUserDialog } from "../users/show-dialog-add-user";
|
||||
import { showAdminChangePasswordDialog } from "../users/show-dialog-admin-change-password";
|
||||
import type { PersonDetailDialogParams } from "./show-dialog-person-detail";
|
||||
import { showListItemsDialog } from "../../../dialogs/dialog-list-items/show-list-items-dialog";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
|
||||
const includeDomains = ["device_tracker"];
|
||||
|
||||
@@ -85,6 +88,8 @@ class DialogPersonDetail
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
private _linkedExistingUser = false;
|
||||
|
||||
private _deviceTrackersAvailable = memoizeOne((hass) =>
|
||||
Object.keys(hass.states).some(
|
||||
(entityId) =>
|
||||
@@ -140,7 +145,7 @@ class DialogPersonDetail
|
||||
private _dialogClosed() {
|
||||
// If we do not have a person ID yet (= person creation dialog was just cancelled), but
|
||||
// we already created a user ID for it, delete it now to not have it "free floating".
|
||||
if (!this._personExists && this._userId) {
|
||||
if (!this._personExists && this._userId && !this._linkedExistingUser) {
|
||||
const callback = this._params?.refreshUsers;
|
||||
deleteUser(this.hass, this._userId).then(() => {
|
||||
callback?.();
|
||||
@@ -426,26 +431,71 @@ class DialogPersonDetail
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
private async _linkUser(user: User, newUser: boolean) {
|
||||
this._linkedExistingUser = !newUser;
|
||||
if (this._params!.entry && this._params!.updateEntry) {
|
||||
await this._params!.updateEntry({ user_id: user.id });
|
||||
}
|
||||
if (newUser) {
|
||||
this._params?.refreshUsers?.();
|
||||
}
|
||||
this._user = user;
|
||||
this._userId = user.id;
|
||||
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
|
||||
this._localOnly = user.local_only;
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
private async _allowLoginChanged(ev): Promise<void> {
|
||||
const target = ev.target;
|
||||
if (target.checked) {
|
||||
target.checked = false;
|
||||
showAddUserDialog(this, {
|
||||
userAddedCallback: async (user?: User) => {
|
||||
if (user) {
|
||||
target.checked = true;
|
||||
if (this._params!.entry && this._params!.updateEntry) {
|
||||
await this._params!.updateEntry({ user_id: user.id });
|
||||
|
||||
const users = await fetchUsers(this.hass);
|
||||
const currentLinkedUsers = new Set(
|
||||
Object.values(this.hass.states)
|
||||
.filter(
|
||||
(s) =>
|
||||
computeDomain(s.entity_id) === "person" && s.attributes.user_id
|
||||
)
|
||||
.map((s) => s.attributes.user_id)
|
||||
);
|
||||
const eligibleUsers = users.filter(
|
||||
(u) =>
|
||||
!currentLinkedUsers.has(u.id) && !u.system_generated && u.username
|
||||
);
|
||||
const addUserDialog = () =>
|
||||
showAddUserDialog(this, {
|
||||
userAddedCallback: async (user?: User) => {
|
||||
if (user) {
|
||||
target.checked = true;
|
||||
this._linkUser(user, true);
|
||||
}
|
||||
this._params?.refreshUsers?.();
|
||||
this._user = user;
|
||||
this._userId = user.id;
|
||||
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
|
||||
this._localOnly = user.local_only;
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
},
|
||||
name: this._name,
|
||||
},
|
||||
name: this._name,
|
||||
});
|
||||
|
||||
if (eligibleUsers.length === 0) {
|
||||
addUserDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
showListItemsDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.person.detail.select_user"),
|
||||
items: [
|
||||
{
|
||||
iconPath: mdiAccountPlus,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.person.detail.create_new_user"
|
||||
),
|
||||
action: addUserDialog,
|
||||
},
|
||||
...eligibleUsers.map((user) => ({
|
||||
iconPath: mdiAccount,
|
||||
label: `${user.name} (${user.username})`,
|
||||
action: () => this._linkUser(user, false),
|
||||
})),
|
||||
],
|
||||
});
|
||||
} else if (this._userId) {
|
||||
if (
|
||||
|
||||
@@ -203,9 +203,28 @@ export const showRepairsFlowDialog = (
|
||||
return "";
|
||||
},
|
||||
|
||||
renderCreateEntryDescription(hass, _step) {
|
||||
renderCreateEntryDescription(hass, step) {
|
||||
const description = hass.localize(
|
||||
`component.${issue.domain}.issues.${
|
||||
issue.translation_key || issue.issue_id
|
||||
}.fix_flow.create_entry.${step.description || "default"}`,
|
||||
step.description_placeholders
|
||||
);
|
||||
|
||||
return html`
|
||||
<p>${hass.localize("ui.dialogs.repair_flow.success.description")}</p>
|
||||
${
|
||||
description
|
||||
? html`
|
||||
<ha-markdown
|
||||
allow-svg
|
||||
breaks
|
||||
.content=${description}
|
||||
></ha-markdown>
|
||||
`
|
||||
: html`<p>
|
||||
${hass.localize("ui.dialogs.repair_flow.success.description")}
|
||||
</p>`
|
||||
}
|
||||
`;
|
||||
},
|
||||
|
||||
@@ -291,11 +310,17 @@ export const showRepairsFlowDialog = (
|
||||
},
|
||||
|
||||
renderMenuOption(hass, step, option) {
|
||||
return hass.localize(
|
||||
`component.${issue.domain}.issues.${
|
||||
issue.translation_key || issue.issue_id
|
||||
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
|
||||
mergePlaceholders(issue, step)
|
||||
return (
|
||||
hass.localize(
|
||||
`component.${issue.domain}.issues.${
|
||||
issue.translation_key || issue.issue_id
|
||||
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
|
||||
mergePlaceholders(issue, step)
|
||||
) ||
|
||||
// Newer backends can offer options this frontend has no
|
||||
// translation for yet — show the raw option key instead of
|
||||
// an empty menu entry
|
||||
option
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ type ReloadableDomain = Exclude<
|
||||
"heading" | "introduction" | "reload"
|
||||
>;
|
||||
|
||||
interface TranslatedReloadableDomain {
|
||||
domain: ReloadableDomain;
|
||||
interface TranslatedReloadableItem {
|
||||
domain: ReloadableDomain | "frontend";
|
||||
name: string;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
@customElement("tools-yaml-config")
|
||||
@@ -37,7 +38,7 @@ export class ToolsYamlConfig extends LitElement {
|
||||
|
||||
@state() private _validating = false;
|
||||
|
||||
@state() private _reloadableDomains: TranslatedReloadableDomain[] = [];
|
||||
@state() private _reloadableItems: TranslatedReloadableItem[] = [];
|
||||
|
||||
@state() private _validateResult?: CheckConfigResult;
|
||||
|
||||
@@ -54,10 +55,10 @@ export class ToolsYamlConfig extends LitElement {
|
||||
oldHass.config.components !== this.hass.config.components ||
|
||||
oldHass.localize !== this.hass.localize)
|
||||
) {
|
||||
this._reloadableDomains = (
|
||||
this._reloadableItems = (
|
||||
componentsWithService(this.hass, "reload") as ReloadableDomain[]
|
||||
)
|
||||
.map((domain) => ({
|
||||
.map<TranslatedReloadableItem>((domain) => ({
|
||||
domain,
|
||||
name:
|
||||
this.hass.localize(
|
||||
@@ -68,6 +69,15 @@ export class ToolsYamlConfig extends LitElement {
|
||||
{ domain: domainToName(this.hass.localize, domain) }
|
||||
),
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
domain: "frontend",
|
||||
service: "reload_themes",
|
||||
name: this.hass.localize(
|
||||
`ui.panel.config.tools.tabs.yaml.section.reloading.themes`
|
||||
),
|
||||
},
|
||||
])
|
||||
.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
@@ -190,12 +200,12 @@ export class ToolsYamlConfig extends LitElement {
|
||||
)}
|
||||
</ha-call-service-button>
|
||||
</div>
|
||||
${this._reloadableDomains.map(
|
||||
${this._reloadableItems.map(
|
||||
(reloadable) => html`
|
||||
<div class="card-actions">
|
||||
<ha-call-service-button
|
||||
.domain=${reloadable.domain}
|
||||
service="reload"
|
||||
.service=${reloadable.service || "reload"}
|
||||
>${reloadable.name}
|
||||
</ha-call-service-button>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,11 @@ import type { HomeAssistant } from "../../../../../types";
|
||||
// Devices below this fraction of the home total are grouped into an "Other" node
|
||||
export const MIN_SANKEY_THRESHOLD_FACTOR = 0.001; // 0.1% of home total
|
||||
|
||||
// Readable capacity of the default 400px card: ~18px per node including the
|
||||
// 6px nodeGap. Past this, bars collapse toward the 1px minimum and the labels
|
||||
// shrink with them.
|
||||
export const DEFAULT_MAX_SANKEY_DEVICES = 20;
|
||||
|
||||
// While the graph is assembled, device nodes carry an ad-hoc `parent` field
|
||||
// that is not part of the chart's Node interface. The chart ignores it.
|
||||
export type SankeyDeviceNode = Node & { parent?: string };
|
||||
@@ -36,6 +41,103 @@ export const fireSankeyNodeMoreInfo = (el: HTMLElement, node: Node): void => {
|
||||
}
|
||||
};
|
||||
|
||||
export interface FindDevicesOverCapOptions {
|
||||
devices: DeviceConsumptionEnergyPreference[];
|
||||
/** Max named children per parent. Falsy or non-positive disables the cap. */
|
||||
maxDevices: number | undefined;
|
||||
rootNodeId: string;
|
||||
renderedIds: ReadonlySet<string>;
|
||||
deviceValues: ReadonlyMap<string, number>;
|
||||
getId: (device: DeviceConsumptionEnergyPreference) => string | undefined;
|
||||
/** First ancestor rendered as its own node, else undefined. */
|
||||
getEffectiveParent: (
|
||||
device: DeviceConsumptionEnergyPreference
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node ids to drop from the rendered set because their parent has more rendered
|
||||
* children than `maxDevices`. Callers group the result into the parent's "Other"
|
||||
* node, so the smallest devices merge instead of disappearing.
|
||||
*/
|
||||
export const findDevicesOverCap = ({
|
||||
devices,
|
||||
maxDevices,
|
||||
rootNodeId,
|
||||
renderedIds,
|
||||
deviceValues,
|
||||
getId,
|
||||
getEffectiveParent,
|
||||
}: FindDevicesOverCapOptions): Set<string> => {
|
||||
const grouped = new Set<string>();
|
||||
if (!maxDevices || !Number.isFinite(maxDevices) || maxDevices <= 0) {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<
|
||||
string,
|
||||
{ id: string; value: number; idx: number }[]
|
||||
>();
|
||||
const seenIds = new Set<string>();
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
if (!id || !renderedIds.has(id) || seenIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
seenIds.add(id);
|
||||
const parentKey = getEffectiveParent(device) ?? rootNodeId;
|
||||
if (!childrenByParent.has(parentKey)) {
|
||||
childrenByParent.set(parentKey, []);
|
||||
}
|
||||
childrenByParent.get(parentKey)!.push({
|
||||
id,
|
||||
value: deviceValues.get(id)!,
|
||||
idx,
|
||||
});
|
||||
});
|
||||
|
||||
// Taking the whole subtree stops a grouped device's children from re-parenting
|
||||
// upward while its value, which already includes them, rolls into "Other".
|
||||
const groupSubtree = (id: string): void => {
|
||||
if (grouped.has(id)) {
|
||||
return;
|
||||
}
|
||||
grouped.add(id);
|
||||
childrenByParent.get(id)?.forEach((child) => groupSubtree(child.id));
|
||||
};
|
||||
|
||||
const queue = [rootNodeId];
|
||||
const visited = new Set(queue);
|
||||
while (queue.length) {
|
||||
const children = childrenByParent.get(queue.shift()!);
|
||||
if (!children) {
|
||||
continue;
|
||||
}
|
||||
let kept = children;
|
||||
if (children.length > maxDevices) {
|
||||
// Raw value includes children, so a parent never sorts below its own
|
||||
// child; declaration order breaks ties so the layout is stable.
|
||||
const ranked = [...children].sort(
|
||||
(a, b) => a.value - b.value || a.idx - b.idx
|
||||
);
|
||||
// At least two, because a lone grouped device is rendered by name instead
|
||||
// and would void the cap.
|
||||
ranked
|
||||
.slice(0, Math.max(children.length - maxDevices, 2))
|
||||
.forEach((child) => groupSubtree(child.id));
|
||||
kept = children.filter((child) => !grouped.has(child.id));
|
||||
}
|
||||
kept.forEach((child) => {
|
||||
if (!visited.has(child.id)) {
|
||||
visited.add(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export interface BuildSankeyDeviceNodesOptions {
|
||||
devices: DeviceConsumptionEnergyPreference[];
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
@@ -44,6 +146,12 @@ export interface BuildSankeyDeviceNodesOptions {
|
||||
rootNodeId: string;
|
||||
/** Flows below this value are grouped into an "Other" node. */
|
||||
minThreshold: number;
|
||||
/**
|
||||
* Max named child nodes per parent. Once a parent has more rendered children
|
||||
* than this, the smallest are grouped into that parent's "Other" node.
|
||||
* Undefined means no cap.
|
||||
*/
|
||||
maxDevices?: number;
|
||||
/** Per-parent untracked residual only shown when above this value. */
|
||||
untrackedFloor: number;
|
||||
/** Round the "Other" node value up (instantaneous cards only). */
|
||||
@@ -76,6 +184,7 @@ export const buildSankeyDeviceNodes = (
|
||||
localize,
|
||||
rootNodeId,
|
||||
minThreshold,
|
||||
maxDevices,
|
||||
untrackedFloor,
|
||||
ceilOtherValue,
|
||||
initialUntracked,
|
||||
@@ -146,6 +255,19 @@ export const buildSankeyDeviceNodes = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// The value threshold only catches devices that are a negligible share of the
|
||||
// home total, so a panel of dozens of similar-sized circuits never groups.
|
||||
findDevicesOverCap({
|
||||
devices,
|
||||
maxDevices,
|
||||
rootNodeId,
|
||||
renderedIds,
|
||||
deviceValues,
|
||||
getId,
|
||||
getEffectiveParent: (device) =>
|
||||
findEffectiveParent(device.included_in_stat),
|
||||
}).forEach((id) => renderedIds.delete(id));
|
||||
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
if (!id) {
|
||||
@@ -154,8 +276,9 @@ export const buildSankeyDeviceNodes = (
|
||||
const value = deviceValues.get(id)!;
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minThreshold) {
|
||||
// Collect small consumers instead of folding them into untracked
|
||||
if (!renderedIds.has(id)) {
|
||||
// Below the value threshold or demoted by the count cap. Collect it
|
||||
// instead of folding it into untracked.
|
||||
const parentKey = effectiveParent ?? rootNodeId;
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
@@ -298,6 +299,7 @@ class HuiEnergySankeyCard
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minEnergyThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
|
||||
@@ -271,6 +271,10 @@ export class HuiEnergyUsageGraphCard
|
||||
);
|
||||
|
||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const datasets: BarSeriesOption[] = [];
|
||||
|
||||
let yMin = Infinity;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
@@ -297,6 +298,7 @@ class HuiPowerSankeyCard
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minPowerThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: homeNode.value,
|
||||
|
||||
@@ -175,6 +175,10 @@ export class HuiPowerSourcesGraphCard
|
||||
);
|
||||
|
||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = generatePowerSourcesGraphData({
|
||||
localize: this.hass.localize,
|
||||
states: this.hass.states,
|
||||
|
||||
@@ -36,7 +36,7 @@ import type { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction } from "../common/has-action";
|
||||
import { hasAction, hasAnyAction } from "../common/has-action";
|
||||
import { hasConfigOrEntityChanged } from "../common/has-changed";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
import type {
|
||||
@@ -300,6 +300,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
||||
return html`
|
||||
<ha-card
|
||||
class=${classMap({
|
||||
"no-action": !hasAnyAction(this._config!),
|
||||
[this._sizeController.value?.height]: Boolean(
|
||||
this._sizeController.value
|
||||
),
|
||||
@@ -730,6 +731,10 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
ha-card.no-action {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
@@ -174,6 +174,7 @@ export interface EnergyCardSankeyConfig extends EnergyCardConfig {
|
||||
layout?: "auto" | "vertical" | "horizontal";
|
||||
group_by_floor?: boolean;
|
||||
group_by_area?: boolean;
|
||||
max_devices?: number;
|
||||
}
|
||||
|
||||
export interface EnergyDateSelectorCardConfig extends EnergyCardBaseConfig {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
@@ -260,6 +261,7 @@ class HuiWaterFlowSankeyCard
|
||||
localize: this.hass.localize,
|
||||
rootNodeId,
|
||||
minThreshold: minFlowThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: effectiveTotalInflow,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
@@ -241,6 +242,7 @@ class HuiWaterSankeyCard
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minWaterThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
|
||||
@@ -15,13 +15,22 @@ const _loadLovelaceResource = (
|
||||
hass.auth.data.hassUrl
|
||||
).toString();
|
||||
|
||||
const logLoadError = (err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Failed to load Lovelace resource ${normalizedUrl} (type: ${resource.type}). Check that the URL is correct and the file exists.`,
|
||||
err
|
||||
);
|
||||
};
|
||||
|
||||
switch (resource.type) {
|
||||
case "css": {
|
||||
if (normalizedUrl in CSS_CACHE) {
|
||||
return CSS_CACHE[normalizedUrl];
|
||||
}
|
||||
|
||||
const loadTask = loadCSS(normalizedUrl);
|
||||
// Catch before caching, so a cache hit cannot log the failure again
|
||||
const loadTask = loadCSS(normalizedUrl).catch(logLoadError);
|
||||
CSS_CACHE[normalizedUrl] = loadTask;
|
||||
return loadTask;
|
||||
}
|
||||
@@ -31,13 +40,13 @@ const _loadLovelaceResource = (
|
||||
return JS_CACHE[normalizedUrl];
|
||||
}
|
||||
|
||||
const loadTask = loadJS(normalizedUrl);
|
||||
const loadTask = loadJS(normalizedUrl).catch(logLoadError);
|
||||
JS_CACHE[normalizedUrl] = loadTask;
|
||||
return loadTask;
|
||||
}
|
||||
|
||||
case "module":
|
||||
return loadModule(normalizedUrl);
|
||||
return loadModule(normalizedUrl).catch(logLoadError);
|
||||
|
||||
default:
|
||||
// eslint-disable-next-line
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
assign,
|
||||
boolean,
|
||||
literal,
|
||||
number,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
@@ -36,6 +37,7 @@ const cardConfigStruct = assign(
|
||||
),
|
||||
group_by_floor: optional(boolean()),
|
||||
group_by_area: optional(boolean()),
|
||||
max_devices: optional(number()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -92,6 +94,11 @@ export class HuiEnergySankeyCardEditor
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "max_devices",
|
||||
required: false,
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
name: "collection_key",
|
||||
@@ -134,6 +141,10 @@ export class HuiEnergySankeyCardEditor
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.collection_key_description`
|
||||
);
|
||||
case "max_devices":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.energy-sankey.max_devices_description`
|
||||
);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -144,6 +155,7 @@ export class HuiEnergySankeyCardEditor
|
||||
case "layout":
|
||||
case "group_by_floor":
|
||||
case "group_by_area":
|
||||
case "max_devices":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.energy-sankey.${schema.name}`
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const mdiMqttLogo =
|
||||
"M1.68,0.5325c-0.63,0-1.1475,0.51-1.1475,1.1475v1.125c11.445,0.0675,20.745,9.3,20.82,20.67h0.975c0.63,0,1.1475-0.51,1.1475-1.1475V14.5125C20.8575,8.04,15.5475,2.9175,8.925,0.5325H1.68z M0.5325,6.3075v3.735c7.425,0.0675,13.455,6.0525,13.53,13.4325h3.8775C17.865,13.995,10.0875,6.315,0.5325,6.3075z M0.5325,13.545v8.7825c0,0.63,0.51,1.1475,1.1475,1.1475H10.65C10.575,17.985,6.0675,13.5525,0.5325,13.545z M16.53,0.5325c1.35,0.945,2.715,2.01,3.915,3.2025c1.0875,1.08,2.145,2.3775,3.03,3.585V1.68c0-0.63-0.51-1.1475-1.1475-1.1475H16.53z";
|
||||
@@ -1608,6 +1608,7 @@
|
||||
"labs": "[%key:ui::panel::config::labs::caption%]",
|
||||
"tools": "[%key:ui::panel::config::dashboard::tools::main%]",
|
||||
"matter": "[%key:ui::panel::config::dashboard::matter::main%]",
|
||||
"mqtt": "[%key:ui::panel::config::dashboard::mqtt::main%]",
|
||||
"zha": "[%key:ui::panel::config::dashboard::zha::main%]",
|
||||
"zwave_js": "[%key:ui::panel::config::dashboard::zwave_js::main%]",
|
||||
"thread": "[%key:ui::panel::config::dashboard::thread::main%]",
|
||||
@@ -2736,6 +2737,10 @@
|
||||
"main": "Matter",
|
||||
"secondary": "Cross-vendor smart home standard"
|
||||
},
|
||||
"mqtt": {
|
||||
"main": "MQTT",
|
||||
"secondary": "Lightweight network protocol designed for IoT devices"
|
||||
},
|
||||
"thread": {
|
||||
"main": "Thread",
|
||||
"secondary": "Mesh network often used for Matter devices"
|
||||
@@ -7041,6 +7046,8 @@
|
||||
"confirm_delete_user_text": "The user account for ''{name}'' will be permanently deleted. You can still track the user, but the person will no longer be able to log in.",
|
||||
"allow_login": "Allow login",
|
||||
"allow_login_description": "Allow access using username and password.",
|
||||
"select_user": "Select user account",
|
||||
"create_new_user": "Create a new username",
|
||||
"username": "[%key:ui::panel::config::users::editor::username%]",
|
||||
"password": "[%key:ui::panel::config::users::editor::password%]",
|
||||
"admin": "[%key:ui::panel::config::users::editor::admin%]",
|
||||
@@ -7398,8 +7405,16 @@
|
||||
},
|
||||
"mqtt": {
|
||||
"title": "MQTT",
|
||||
"settings_title": "MQTT settings",
|
||||
"option_flow": "Configure MQTT options",
|
||||
"option_flow": "MQTT options",
|
||||
"option_flow_description": "Set discovery options",
|
||||
"config_flow": "Connection",
|
||||
"config_flow_description": "Broker connection settings",
|
||||
"status_online": "Online",
|
||||
"status_offline": "Offline",
|
||||
"my_network_title": "My network",
|
||||
"devices": "{count, plural,\n one {# device}\n other {# devices}\n}",
|
||||
"device_count": "{count, plural,\n one {# device}\n other {# devices}\n}",
|
||||
"entity_count": "{count, plural,\n one {# entity}\n other {# entities}\n}",
|
||||
"description_publish": "Publish a packet",
|
||||
"topic": "Topic",
|
||||
"payload": "Payload (template allowed)",
|
||||
@@ -9992,6 +10007,8 @@
|
||||
"description": "This card shows the distribution of energy in your home on a Sankey graph.",
|
||||
"group_by_floor": "Group by floor",
|
||||
"group_by_area": "Group by area",
|
||||
"max_devices": "Maximum devices per parent",
|
||||
"max_devices_description": "Devices beyond this limit are grouped into an \"Other\" node, smallest first. The limit applies per upstream device, not per floor or area. Defaults to 20.",
|
||||
"layout": "Graph direction",
|
||||
"layout_directions": {
|
||||
"auto": "Automatic",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getNodeIdFromDevice } from "../../src/data/zwave_js";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
|
||||
const mockDevice = (identifiers: [string, string][]) =>
|
||||
({ identifiers }) as DeviceRegistryEntry;
|
||||
|
||||
describe("getNodeIdFromDevice", () => {
|
||||
it("reads the node ID from the identifier", () => {
|
||||
expect(
|
||||
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-25"]]))
|
||||
).toBe(25);
|
||||
});
|
||||
|
||||
it("reads the node ID from the extended identifier", () => {
|
||||
expect(
|
||||
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-25-798:5:20"]]))
|
||||
).toBe(25);
|
||||
});
|
||||
|
||||
it("returns multi-digit node IDs in full", () => {
|
||||
expect(
|
||||
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-108"]]))
|
||||
).toBe(108);
|
||||
});
|
||||
|
||||
it("ignores provisioning entries, whose DSK blocks parse as numbers", () => {
|
||||
expect(
|
||||
getNodeIdFromDevice(
|
||||
mockDevice([
|
||||
[
|
||||
"zwave_js",
|
||||
"provision_50285-00042-09924-30691-15973-33711-04005-03623",
|
||||
],
|
||||
])
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores identifiers of other integrations", () => {
|
||||
expect(
|
||||
getNodeIdFromDevice(mockDevice([["mqtt", "3245146787-25"]]))
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("picks the Z-Wave identifier when a device has several", () => {
|
||||
expect(
|
||||
getNodeIdFromDevice(
|
||||
mockDevice([
|
||||
["matter", "some-other-id"],
|
||||
["zwave_js", "3245146787-14"],
|
||||
])
|
||||
)
|
||||
).toBe(14);
|
||||
});
|
||||
|
||||
it("returns undefined when there is no node ID", () => {
|
||||
expect(getNodeIdFromDevice(mockDevice([]))).toBeUndefined();
|
||||
expect(
|
||||
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787"]]))
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -279,6 +279,85 @@ test.describe("Lovelace dashboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Energy dashboard", () => {
|
||||
test("returns to Energy after repeatedly opening the device dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors = trackPageErrors(page);
|
||||
await goToPanel(
|
||||
page,
|
||||
"/?historyBack=1&backPath=%2Flovelace#/energy/overview"
|
||||
);
|
||||
const energyRoot = page.locator("ha-panel-energy hui-root");
|
||||
await expect(energyRoot).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
|
||||
const editDashboard = energyRoot.getByRole("button", {
|
||||
name: /^Edit dashboard\b/,
|
||||
});
|
||||
const dashboardMenu = energyRoot.getByRole("button", {
|
||||
name: "Open dashboard menu",
|
||||
});
|
||||
await expect(editDashboard.or(dashboardMenu)).toBeVisible({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
if (await editDashboard.isVisible()) {
|
||||
await editDashboard.click();
|
||||
} else {
|
||||
await dashboardMenu.click();
|
||||
await page.getByRole("menuitem", { name: /^Edit dashboard\b/ }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator("ha-config-energy")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
|
||||
const backLink = page
|
||||
.locator("ha-config-energy hass-tabs-subpage")
|
||||
.getByRole("link", { name: "Back" });
|
||||
await expect(backLink).toHaveAttribute(
|
||||
"href",
|
||||
"/config/lovelace/dashboards"
|
||||
);
|
||||
|
||||
const addDevice = page
|
||||
.locator("ha-energy-device-settings")
|
||||
.locator("ha-button")
|
||||
.first();
|
||||
const openAndCancelDeviceDialog = async () => {
|
||||
await addDevice.click();
|
||||
const dialog = page.locator("dialog-energy-device-settings");
|
||||
const cancel = dialog.locator("ha-dialog-footer ha-button").first();
|
||||
await expect(cancel).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||
await cancel.click();
|
||||
await expect(cancel).toBeHidden({ timeout: QUICK_TIMEOUT });
|
||||
};
|
||||
await openAndCancelDeviceDialog();
|
||||
await openAndCancelDeviceDialog();
|
||||
await openAndCancelDeviceDialog();
|
||||
|
||||
await backLink.click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => ({
|
||||
hash: window.location.hash,
|
||||
search: window.location.search,
|
||||
})),
|
||||
{ timeout: PANEL_TIMEOUT }
|
||||
)
|
||||
.toEqual({
|
||||
hash: "#/energy/overview",
|
||||
search: "?historyBack=1&backPath=%2Flovelace",
|
||||
});
|
||||
await expect(page.locator("ha-panel-energy")).toBeAttached();
|
||||
await expect(
|
||||
energyRoot.getByRole("link", { name: "Back" })
|
||||
).toHaveAttribute("href", "/lovelace");
|
||||
expectNoPageErrors(errors);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// More-info dialog (light)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// HomeAssistantAppEl registers this path; keep it inert in E2E tests.
|
||||
void 0;
|
||||
@@ -1 +1,2 @@
|
||||
import "../../../../src/resources/append-ha-style";
|
||||
import "./ha-test";
|
||||
|
||||
@@ -247,16 +247,12 @@ export class HaTest extends HomeAssistantAppEl {
|
||||
window.__mockHass = hass;
|
||||
|
||||
// SPA navigation
|
||||
document.body.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
const href = isNavigationClick(e);
|
||||
if (!href) return;
|
||||
e.preventDefault();
|
||||
window.addEventListener("click", (e) => {
|
||||
const href = isNavigationClick(e);
|
||||
if (href) {
|
||||
navigate(href);
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.hassConnected();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
findDevicesOverCap,
|
||||
getSankeyDeviceSections,
|
||||
groupSankeyDevicesByFloorAndArea,
|
||||
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
|
||||
@@ -81,6 +83,86 @@ describe("getSankeyDeviceSections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDevicesOverCap", () => {
|
||||
// Values keyed by node id, hierarchy by included_in_stat.
|
||||
const overCapOpts = (
|
||||
values: Record<string, number>,
|
||||
parents: Record<string, string>,
|
||||
// No default: passing `undefined` must mean "no cap", not "fall back to 3".
|
||||
maxDevices: number | undefined,
|
||||
rendered = Object.keys(values)
|
||||
) => {
|
||||
const list: DeviceConsumptionEnergyPreference[] = Object.keys(values).map(
|
||||
(id) => ({ stat_consumption: id, included_in_stat: parents[id] })
|
||||
);
|
||||
const renderedIds = new Set(rendered);
|
||||
return {
|
||||
devices: list,
|
||||
maxDevices,
|
||||
rootNodeId: "home",
|
||||
renderedIds,
|
||||
deviceValues: new Map(Object.entries(values)),
|
||||
getId: (device: DeviceConsumptionEnergyPreference) =>
|
||||
device.stat_consumption,
|
||||
getEffectiveParent: (device: DeviceConsumptionEnergyPreference) => {
|
||||
let current = device.included_in_stat;
|
||||
for (let hops = 0; current && hops < list.length; hops++) {
|
||||
if (renderedIds.has(current)) {
|
||||
return current;
|
||||
}
|
||||
current = parents[current];
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
"groups nothing for a cap of %s",
|
||||
(maxDevices) => {
|
||||
expect(
|
||||
findDevicesOverCap(
|
||||
overCapOpts({ a: 5, b: 4, c: 3, d: 2 }, {}, maxDevices)
|
||||
)
|
||||
).toEqual(new Set());
|
||||
}
|
||||
);
|
||||
|
||||
it("groups the smallest children of an over-cap parent", () => {
|
||||
expect(
|
||||
findDevicesOverCap(overCapOpts({ a: 5, b: 4, c: 3, d: 2, e: 1 }, {}, 3))
|
||||
).toEqual(new Set(["d", "e"]));
|
||||
});
|
||||
|
||||
it("groups a whole subtree, not just the parent", () => {
|
||||
expect(
|
||||
findDevicesOverCap(
|
||||
overCapOpts(
|
||||
{ a: 50, b: 40, c: 30, small: 10, child: 9 },
|
||||
{ child: "small" },
|
||||
3
|
||||
)
|
||||
)
|
||||
).toEqual(new Set(["small", "child", "c"]));
|
||||
});
|
||||
|
||||
it("counts only rendered devices", () => {
|
||||
// four devices, three rendered, so a cap of three does not fire
|
||||
expect(
|
||||
findDevicesOverCap(
|
||||
overCapOpts({ a: 5, b: 4, c: 3, tiny: 0.001 }, {}, 3, ["a", "b", "c"])
|
||||
)
|
||||
).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("terminates on a cyclic parent chain", () => {
|
||||
// both devices parent each other, so neither is reachable from the root
|
||||
expect(
|
||||
findDevicesOverCap(overCapOpts({ a: 5, b: 4 }, { a: "b", b: "a" }, 1))
|
||||
).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSankeyDeviceNodes", () => {
|
||||
it("renders top-level devices and subtracts them from untracked", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
@@ -423,6 +505,385 @@ describe("buildSankeyDeviceNodes", () => {
|
||||
expect(result.deviceNodes).toEqual([]);
|
||||
expect(result.untrackedConsumption).toBe(10);
|
||||
});
|
||||
|
||||
describe("device count cap", () => {
|
||||
// Five devices all comfortably above minThreshold, so only the count cap
|
||||
// can group any of them.
|
||||
const fiveDevices = () =>
|
||||
devices(
|
||||
{ stat_consumption: "a" },
|
||||
{ stat_consumption: "b" },
|
||||
{ stat_consumption: "c" },
|
||||
{ stat_consumption: "d" },
|
||||
{ stat_consumption: "e" }
|
||||
);
|
||||
const fiveValues = { a: 50, b: 40, c: 30, d: 20, e: 10 };
|
||||
|
||||
const namedIds = (result: { deviceNodes: SankeyDeviceNode[] }) =>
|
||||
result.deviceNodes
|
||||
.filter(
|
||||
(n) => !n.id.startsWith("other_") && !n.id.startsWith("untracked_")
|
||||
)
|
||||
.map((n) => n.id);
|
||||
|
||||
it("does not group anything when maxDevices is unset", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: fiveValues,
|
||||
initialUntracked: 150,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
|
||||
false
|
||||
);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("does nothing at exactly the cap", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a" },
|
||||
{ stat_consumption: "b" },
|
||||
{ stat_consumption: "c" }
|
||||
),
|
||||
values: { a: 50, b: 40, c: 30 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 120,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toEqual(["a", "b", "c"]);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("groups the smallest above the cap, keeping the cap many by name", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: fiveValues,
|
||||
maxDevices: 3,
|
||||
initialUntracked: 150,
|
||||
})
|
||||
);
|
||||
// the cap budgets named devices; Other is overhead on top
|
||||
expect(namedIds(result)).toEqual(["a", "b", "c"]);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBeCloseTo(30, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("never demotes exactly one device, which would render it by name", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a" },
|
||||
{ stat_consumption: "b" },
|
||||
{ stat_consumption: "c" },
|
||||
{ stat_consumption: "d" }
|
||||
),
|
||||
values: { a: 40, b: 30, c: 20, d: 10 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 100,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toEqual(["a", "b"]);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBeCloseTo(30, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("demotes a whole subtree so children never re-parent or double count", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "g" },
|
||||
{ stat_consumption: "a", included_in_stat: "g" },
|
||||
{ stat_consumption: "c", included_in_stat: "a" },
|
||||
{ stat_consumption: "b1", included_in_stat: "g" },
|
||||
{ stat_consumption: "b2", included_in_stat: "g" },
|
||||
{ stat_consumption: "b3", included_in_stat: "g" },
|
||||
{ stat_consumption: "b4", included_in_stat: "g" }
|
||||
),
|
||||
values: { g: 100, a: 10, c: 9, b1: 30, b2: 25, b3: 20, b4: 8 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 100,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual([
|
||||
"g",
|
||||
"b1",
|
||||
"b2",
|
||||
"b3",
|
||||
"other_g",
|
||||
"untracked_g",
|
||||
]);
|
||||
// a's value already contains c, so Other is 10 + 8 and not 27
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_g");
|
||||
expect(other?.value).toBeCloseTo(18, 10);
|
||||
expect(result.deviceNodes.some((n) => n.id === "c")).toBe(false);
|
||||
const untracked = result.deviceNodes.find((n) => n.id === "untracked_g");
|
||||
expect(untracked?.value).toBeCloseTo(7, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("ranks by raw value, so a parent is never demoted before its own child", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "d" },
|
||||
{ stat_consumption: "e", included_in_stat: "d" },
|
||||
{ stat_consumption: "f" },
|
||||
{ stat_consumption: "h" },
|
||||
{ stat_consumption: "i" }
|
||||
),
|
||||
values: { d: 50, e: 45, f: 10, h: 40, i: 30 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 130,
|
||||
})
|
||||
);
|
||||
// Ranking on value-excluding-children would demote d (50 - 45 = 5) while
|
||||
// keeping e, counting e both on its own and inside d's rolled-up value.
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual([
|
||||
"d",
|
||||
"e",
|
||||
"h",
|
||||
"other_home",
|
||||
"untracked_d",
|
||||
]);
|
||||
expect(result.parentLinks.e).toBe("d");
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBeCloseTo(40, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("applies the cap per parent and links Other to that parent", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "p" },
|
||||
{ stat_consumption: "c1", included_in_stat: "p" },
|
||||
{ stat_consumption: "c2", included_in_stat: "p" },
|
||||
{ stat_consumption: "c3", included_in_stat: "p" },
|
||||
{ stat_consumption: "c4", included_in_stat: "p" },
|
||||
{ stat_consumption: "c5", included_in_stat: "p" },
|
||||
{ stat_consumption: "r1" },
|
||||
{ stat_consumption: "r2" }
|
||||
),
|
||||
values: {
|
||||
p: 100,
|
||||
c1: 30,
|
||||
c2: 25,
|
||||
c3: 20,
|
||||
c4: 15,
|
||||
c5: 10,
|
||||
r1: 50,
|
||||
r2: 40,
|
||||
},
|
||||
maxDevices: 3,
|
||||
initialUntracked: 190,
|
||||
})
|
||||
);
|
||||
// p is over the cap; the three root-level nodes are exactly at it
|
||||
expect(namedIds(result)).toEqual(["p", "c1", "c2", "c3", "r1", "r2"]);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_p");
|
||||
expect(other?.value).toBeCloseTo(25, 10);
|
||||
expect(result.parentLinks.other_p).toBe("p");
|
||||
expect(result.links).toContainEqual({ source: "p", target: "other_p" });
|
||||
expect(result.deviceNodes.some((n) => n.id === "other_home")).toBe(false);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("counts only rendered children, not ones already below the threshold", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "p" },
|
||||
{ stat_consumption: "c1", included_in_stat: "p" },
|
||||
{ stat_consumption: "c2", included_in_stat: "p" },
|
||||
{ stat_consumption: "c3", included_in_stat: "p" },
|
||||
{ stat_consumption: "s1", included_in_stat: "p" },
|
||||
{ stat_consumption: "s2", included_in_stat: "p" },
|
||||
{ stat_consumption: "s3", included_in_stat: "p" }
|
||||
),
|
||||
values: {
|
||||
p: 100,
|
||||
c1: 30,
|
||||
c2: 25,
|
||||
c3: 20,
|
||||
s1: 0.001,
|
||||
s2: 0.002,
|
||||
s3: 0.003,
|
||||
},
|
||||
maxDevices: 3,
|
||||
initialUntracked: 100,
|
||||
})
|
||||
);
|
||||
// three rendered children is at the cap, so no demotion happens
|
||||
expect(namedIds(result)).toEqual(["p", "c1", "c2", "c3"]);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_p");
|
||||
expect(other?.value).toBeCloseTo(0.006, 10);
|
||||
});
|
||||
|
||||
it("terminates on a cyclic included_in_stat and leaves it unchanged", () => {
|
||||
const cyclic = () =>
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a", included_in_stat: "b" },
|
||||
{ stat_consumption: "b", included_in_stat: "a" }
|
||||
),
|
||||
values: { a: 5, b: 4 },
|
||||
initialUntracked: 10,
|
||||
});
|
||||
// A cycle member always has a rendered parent inside the cycle, so it is
|
||||
// never reachable from the root and the cap cannot touch it.
|
||||
expect(
|
||||
buildSankeyDeviceNodes({ ...cyclic(), maxDevices: 1 })
|
||||
).toStrictEqual(buildSankeyDeviceNodes(cyclic()));
|
||||
});
|
||||
|
||||
it("treats a zero or non-finite cap as no cap", () => {
|
||||
[0, Number.NaN, Number.POSITIVE_INFINITY].forEach((maxDevices) => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: fiveValues,
|
||||
maxDevices,
|
||||
initialUntracked: 150,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count devices without a node id (instantaneous cards)", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a", stat_rate: "ra" },
|
||||
{ stat_consumption: "b", stat_rate: "rb" },
|
||||
{ stat_consumption: "c", stat_rate: "rc" },
|
||||
{ stat_consumption: "d" }
|
||||
),
|
||||
values: { ra: 30, rb: 25, rc: 20 },
|
||||
getId: (device) => device.stat_rate,
|
||||
maxDevices: 3,
|
||||
initialUntracked: 75,
|
||||
})
|
||||
);
|
||||
// d has no stat_rate, so the three rendered nodes are at the cap
|
||||
expect(namedIds(result)).toEqual(["ra", "rb", "rc"]);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("ceils the capped Other value but still subtracts the raw total", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: { ...fiveValues, e: 10.5 },
|
||||
maxDevices: 3,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: 150.5,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBe(31);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("breaks value ties on declaration order, stably across runs", () => {
|
||||
const opts = () =>
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: { a: 50, b: 20, c: 20, d: 20, e: 10 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 120,
|
||||
});
|
||||
expect(namedIds(buildSankeyDeviceNodes(opts()))).toEqual(["a", "c", "d"]);
|
||||
expect(namedIds(buildSankeyDeviceNodes(opts()))).toEqual(["a", "c", "d"]);
|
||||
});
|
||||
|
||||
// The documented promise is "more than 20 devices start grouping", so pin
|
||||
// the boundary against the real default rather than a test-local cap.
|
||||
it.each([
|
||||
[DEFAULT_MAX_SANKEY_DEVICES, DEFAULT_MAX_SANKEY_DEVICES, false],
|
||||
// one over the cap still demotes two, because a lone demoted device would
|
||||
// be rendered by name and void the cap
|
||||
[DEFAULT_MAX_SANKEY_DEVICES + 1, DEFAULT_MAX_SANKEY_DEVICES - 1, true],
|
||||
[DEFAULT_MAX_SANKEY_DEVICES + 2, DEFAULT_MAX_SANKEY_DEVICES, true],
|
||||
[DEFAULT_MAX_SANKEY_DEVICES + 12, DEFAULT_MAX_SANKEY_DEVICES, true],
|
||||
])(
|
||||
"with %i devices at the default cap renders %i named nodes (grouped: %s)",
|
||||
(deviceCount, expectedNamed, expectOther) => {
|
||||
const list = Array.from({ length: deviceCount }, (_, i) => ({
|
||||
stat_consumption: `d${i}`,
|
||||
}));
|
||||
const values = Object.fromEntries(
|
||||
list.map((d, i) => [d.stat_consumption, 10 + i])
|
||||
);
|
||||
const total = Object.values(values).reduce((s, v) => s + v, 0);
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: list,
|
||||
values,
|
||||
maxDevices: DEFAULT_MAX_SANKEY_DEVICES,
|
||||
initialUntracked: total,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toHaveLength(expectedNamed);
|
||||
expect(result.deviceNodes.some((n) => n.id === "other_home")).toBe(
|
||||
expectOther
|
||||
);
|
||||
// grouping never changes the total that comes off the root
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps 32 circuit clamps readable at the default cap", () => {
|
||||
const clamps = Array.from({ length: 32 }, (_, i) => ({
|
||||
stat_consumption: `clamp_${i}`,
|
||||
}));
|
||||
const values = Object.fromEntries(
|
||||
clamps.map((clamp, i) => [clamp.stat_consumption, 20 + i])
|
||||
);
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: clamps,
|
||||
values,
|
||||
maxDevices: DEFAULT_MAX_SANKEY_DEVICES,
|
||||
initialUntracked: 1200,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toHaveLength(DEFAULT_MAX_SANKEY_DEVICES);
|
||||
expect(result.deviceNodes).toHaveLength(DEFAULT_MAX_SANKEY_DEVICES + 1);
|
||||
// the 12 smallest clamps, values 20 through 31
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBeCloseTo(306, 10);
|
||||
|
||||
// flow conservation: every device node plus untracked accounts for home
|
||||
const { nodes } = buildSankeyLayout({
|
||||
hass: createMockHass(),
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: result.deviceNodes,
|
||||
parentLinks: result.parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: result.untrackedConsumption,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
const deviceTotal = nodes
|
||||
.filter((n) => n.index === 4)
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
expect(deviceTotal).toBeCloseTo(1200, 10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSankeyDevicesByFloorAndArea", () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { loadJS, loadModule } from "../../../../src/common/dom/load_resource";
|
||||
import { loadLovelaceResourcesAndWait } from "../../../../src/panels/lovelace/common/load-resources";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
vi.mock("../../../../src/common/dom/load_resource", () => ({
|
||||
loadCSS: vi.fn(),
|
||||
loadJS: vi.fn(),
|
||||
loadModule: vi.fn(),
|
||||
}));
|
||||
|
||||
const hass = {
|
||||
auth: { data: { hassUrl: "http://localhost:8123" } },
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
describe("loadLovelaceResourcesAndWait", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("logs an error naming the resource that failed to load", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
const loadError = new Error("load failed");
|
||||
vi.mocked(loadModule).mockImplementation(() => Promise.reject(loadError));
|
||||
|
||||
await loadLovelaceResourcesAndWait(
|
||||
[{ id: "1", url: "/local/missing.js", type: "module" }],
|
||||
hass
|
||||
);
|
||||
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
expect(consoleError.mock.calls[0][0]).toContain(
|
||||
"http://localhost:8123/local/missing.js"
|
||||
);
|
||||
expect(consoleError.mock.calls[0][0]).toContain("module");
|
||||
// the rejection reason is forwarded, so any detail it carries is not lost
|
||||
expect(consoleError.mock.calls[0][1]).toBe(loadError);
|
||||
});
|
||||
|
||||
it("logs only once for a cached resource that failed to load", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
vi.mocked(loadJS).mockImplementation((url) => Promise.reject(url));
|
||||
const resources = [
|
||||
{ id: "2", url: "/local/broken.js", type: "js" as const },
|
||||
];
|
||||
|
||||
await loadLovelaceResourcesAndWait(resources, hass);
|
||||
await loadLovelaceResourcesAndWait(resources, hass);
|
||||
|
||||
expect(loadJS).toHaveBeenCalledTimes(1);
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user