Merge branch 'dev' of github.com:home-assistant/frontend into ha-button

This commit is contained in:
Wendelin 2025-05-22 09:16:29 +02:00
commit 422f05dc3b
No known key found for this signature in database
124 changed files with 3900 additions and 3638 deletions

View File

@ -68,7 +68,7 @@
} }
#ha-launch-screen .ha-launch-screen-spacer-top { #ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1; flex: 1;
margin-top: calc( 2 * max(env(safe-area-inset-bottom), 48px) + 46px ); margin-top: calc( 2 * max(var(--safe-area-inset-bottom), 48px) + 46px );
padding-top: 48px; padding-top: 48px;
} }
#ha-launch-screen .ha-launch-screen-spacer-bottom { #ha-launch-screen .ha-launch-screen-spacer-bottom {
@ -76,7 +76,7 @@
padding-top: 48px; padding-top: 48px;
} }
.ohf-logo { .ohf-logo {
margin: max(env(safe-area-inset-bottom), 48px) 0; margin: max(var(--safe-area-inset-bottom), 48px) 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View File

@ -132,9 +132,9 @@ class HassioDashboard extends LitElement {
} }
ha-fab.non-tabs { ha-fab.non-tabs {
position: fixed; position: fixed;
right: calc(16px + env(safe-area-inset-right)); right: calc(16px + var(--safe-area-inset-right));
bottom: calc(16px + env(safe-area-inset-bottom)); bottom: calc(16px + var(--safe-area-inset-bottom));
inset-inline-end: calc(16px + env(safe-area-inset-right)); inset-inline-end: calc(16px + var(--safe-area-inset-right));
inset-inline-start: initial; inset-inline-start: initial;
z-index: 1; z-index: 1;
} }

View File

@ -610,7 +610,7 @@ export class DialogHassioNetwork
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 8px; padding: 8px;
padding-bottom: max(env(safe-area-inset-bottom), 8px); padding-bottom: max(var(--safe-area-inset-bottom), 8px);
background-color: var(--mdc-theme-surface, #fff); background-color: var(--mdc-theme-surface, #fff);
} }
.warning { .warning {

View File

@ -32,9 +32,9 @@
"@codemirror/commands": "6.8.1", "@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.0", "@codemirror/language": "6.11.0",
"@codemirror/legacy-modes": "6.5.1", "@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.10", "@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2", "@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.7", "@codemirror/view": "6.36.8",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0", "@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11", "@formatjs/intl-displaynames": "6.8.11",
@ -137,7 +137,6 @@
"tinykeys": "3.0.0", "tinykeys": "3.0.0",
"ua-parser-js": "2.0.3", "ua-parser-js": "2.0.3",
"vis-data": "7.1.9", "vis-data": "7.1.9",
"vis-network": "9.1.9",
"vue": "2.7.16", "vue": "2.7.16",
"vue2-daterange-picker": "0.6.8", "vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0", "weekstart": "2.0.0",
@ -160,8 +159,8 @@
"@octokit/plugin-retry": "7.2.1", "@octokit/plugin-retry": "7.2.1",
"@octokit/rest": "21.1.1", "@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "1.1.2", "@rsdoctor/rspack-plugin": "1.1.2",
"@rspack/cli": "1.3.9", "@rspack/cli": "1.3.10",
"@rspack/core": "1.3.9", "@rspack/core": "1.3.10",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21", "@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11", "@types/chromecast-caf-sender": "1.0.11",
@ -185,7 +184,7 @@
"babel-plugin-template-html-minifier": "4.1.0", "babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3", "browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0", "del": "8.0.0",
"eslint": "9.26.0", "eslint": "9.27.0",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.5", "eslint-config-prettier": "10.1.5",
"eslint-import-resolver-webpack": "0.13.10", "eslint-import-resolver-webpack": "0.13.10",
@ -219,7 +218,7 @@
"terser-webpack-plugin": "5.3.14", "terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.8.3", "typescript": "5.8.3",
"typescript-eslint": "8.32.0", "typescript-eslint": "8.32.1",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"vitest": "3.1.3", "vitest": "3.1.3",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",

View File

@ -0,0 +1,14 @@
import { html } from "lit";
import type { LocalizeFunc } from "./localize";
const MARKDOWN_SUPPORT_URL = "https://commonmark.org/help/";
export const supportsMarkdownHelper = (localize: LocalizeFunc) =>
localize("ui.common.supports_markdown", {
markdown_help_link: html`<a
href=${MARKDOWN_SUPPORT_URL}
target="_blank"
rel="noreferrer"
>${localize("ui.common.markdown")}</a
>`,
});

View File

@ -48,7 +48,8 @@ export class HaChartBase extends LitElement {
@property({ attribute: "expand-legend", type: Boolean }) @property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean; public expandLegend?: boolean;
@property({ attribute: false }) public extraComponents?: any[]; // extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@state() @state()
@consume({ context: themesContext, subscribe: true }) @consume({ context: themesContext, subscribe: true })
@ -106,48 +107,49 @@ export class HaChartBase extends LitElement {
}) })
); );
// Add keyboard event listeners if (!this.options?.dataZoom) {
const handleKeyDown = (ev: KeyboardEvent) => { // Add keyboard event listeners
if ( const handleKeyDown = (ev: KeyboardEvent) => {
!this._modifierPressed && if (
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) !this._modifierPressed &&
) { ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
this._modifierPressed = true; ) {
if (!this.options?.dataZoom) { this._modifierPressed = true;
this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: true,
});
} }
// drag to zoom };
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: true,
});
}
};
const handleKeyUp = (ev: KeyboardEvent) => { const handleKeyUp = (ev: KeyboardEvent) => {
if ( if (
this._modifierPressed && this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) { ) {
this._modifierPressed = false; this._modifierPressed = false;
if (!this.options?.dataZoom) { if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: false,
});
} }
this.chart?.dispatchAction({ };
type: "takeGlobalCursor", window.addEventListener("keydown", handleKeyDown);
key: "dataZoomSelect", window.addEventListener("keyup", handleKeyUp);
dataZoomSelectActive: false, this._listeners.push(
}); () => window.removeEventListener("keydown", handleKeyDown),
} () => window.removeEventListener("keyup", handleKeyUp)
}; );
}
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
this._listeners.push(
() => window.removeEventListener("keydown", handleKeyDown),
() => window.removeEventListener("keyup", handleKeyUp)
);
} }
protected firstUpdated() { protected firstUpdated() {
@ -191,16 +193,19 @@ export class HaChartBase extends LitElement {
<div class="chart"></div> <div class="chart"></div>
</div> </div>
${this._renderLegend()} ${this._renderLegend()}
${this._isZoomed <div class="chart-controls">
? html`<ha-icon-button ${this._isZoomed
class="zoom-reset" ? html`<ha-icon-button
.path=${mdiRestart} class="zoom-reset"
@click=${this._handleZoomReset} .path=${mdiRestart}
title=${this.hass.localize( @click=${this._handleZoomReset}
"ui.components.history_charts.zoom_reset" title=${this.hass.localize(
)} "ui.components.history_charts.zoom_reset"
></ha-icon-button>` )}
: nothing} ></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
</div> </div>
`; `;
} }
@ -210,7 +215,7 @@ export class HaChartBase extends LitElement {
return nothing; return nothing;
} }
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption; const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
if (!legend.show) { if (!legend.show || legend.type !== "custom") {
return nothing; return nothing;
} }
const datasets = ensureArray(this.data); const datasets = ensureArray(this.data);
@ -315,7 +320,9 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => { this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e); fireEvent(this, "chart-click", e);
}); });
this.chart.getZr().on("dblclick", this._handleClickZoom); if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
}
if (this._isTouchDevice) { if (this._isTouchDevice) {
this.chart.getZr().on("click", (e: ECElementEvent) => { this.chart.getZr().on("click", (e: ECElementEvent) => {
if (!e.zrByTouch) { if (!e.zrByTouch) {
@ -410,6 +417,12 @@ export class HaChartBase extends LitElement {
} as XAXisOption; } as XAXisOption;
}); });
} }
let legend = this.options?.legend;
if (legend) {
legend = ensureArray(legend).map((l) =>
l.type === "custom" ? { show: false } : l
);
}
const options = { const options = {
animation: !this._reducedMotion, animation: !this._reducedMotion,
darkMode: this._themes.darkMode ?? false, darkMode: this._themes.darkMode ?? false,
@ -424,7 +437,7 @@ export class HaChartBase extends LitElement {
iconStyle: { opacity: 0 }, iconStyle: { opacity: 0 },
}, },
...this.options, ...this.options,
legend: { show: false }, legend,
xAxis, xAxis,
}; };
@ -725,16 +738,26 @@ export class HaChartBase extends LitElement {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.zoom-reset { .chart-controls {
position: absolute; position: absolute;
top: 16px; top: 16px;
right: 4px; right: 4px;
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-controls ha-icon-button,
.chart-controls ::slotted(ha-icon-button) {
background: var(--card-background-color); background: var(--card-background-color);
border-radius: 4px; border-radius: 4px;
--mdc-icon-button-size: 32px; --mdc-icon-button-size: 32px;
color: var(--primary-color); color: var(--primary-color);
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);
} }
.chart-controls ha-icon-button.inactive,
.chart-controls ::slotted(ha-icon-button.inactive) {
color: var(--state-inactive-color);
}
.chart-legend { .chart-legend {
max-height: 60%; max-height: 60%;
overflow-y: auto; overflow-y: auto;

View File

@ -0,0 +1,299 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { ECOption } from "../../resources/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
export interface NetworkNode {
id: string;
name?: string;
category?: number;
label?: string;
value?: number;
symbolSize?: number;
symbol?: string;
itemStyle?: {
color?: string;
borderColor?: string;
borderWidth?: number;
};
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
*/
polarDistance?: number;
}
export interface NetworkLink {
source: string;
target: string;
value?: number;
reverseValue?: number;
lineStyle?: {
width?: number;
color?: string;
type?: "solid" | "dashed" | "dotted";
};
symbolSize?: number | number[];
symbol?: string;
label?: {
show?: boolean;
formatter?: string;
};
ignoreForceLayout?: boolean;
}
export interface NetworkData {
nodes: NetworkNode[];
links: NetworkLink[];
categories?: { name: string; symbol: string }[];
}
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@customElement("ha-network-graph")
export class HaNetworkGraph extends LitElement {
public chart?: EChartsType;
@property({ attribute: false }) public data!: NetworkData;
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => string;
public hass!: HomeAssistant;
@state() private _reducedMotion = false;
@state() private _physicsEnabled = true;
@state() private _showLabels = true;
private _listeners: (() => void)[] = [];
private _nodePositions: Record<string, { x: number; y: number }> = {};
@query("ha-chart-base") private _baseChart?: HaChartBase;
constructor() {
super();
if (!GraphChart) {
import("echarts/lib/chart/graph/install").then((module) => {
GraphChart = module;
this.requestUpdate();
});
}
}
public async connectedCallback() {
super.connectedCallback();
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) {
this._reducedMotion = matches;
}
})
);
}
public disconnectedCallback() {
super.disconnectedCallback();
while (this._listeners.length) {
this._listeners.pop()!();
}
}
protected render() {
if (!GraphChart) {
return nothing;
}
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._getSeries(
this.data,
this._physicsEnabled,
this._reducedMotion,
this._showLabels
)}
.options=${this._createOptions(this.data?.categories)}
height="100%"
.extraComponents=${[GraphChart]}
>
<slot name="button" slot="button"></slot>
<ha-icon-button
slot="button"
class=${this._physicsEnabled ? "active" : "inactive"}
.path=${mdiGoogleCirclesGroup}
@click=${this._togglePhysics}
label=${this.hass.localize(
"ui.panel.config.common.graph.toggle_physics"
)}
></ha-icon-button>
<ha-icon-button
slot="button"
class=${this._showLabels ? "active" : "inactive"}
.path=${mdiFormatTextVariant}
@click=${this._toggleLabels}
label=${this.hass.localize(
"ui.panel.config.common.graph.toggle_labels"
)}
></ha-icon-button>
</ha-chart-base>`;
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): ECOption => ({
tooltip: {
trigger: "item",
confine: true,
formatter: this.tooltipFormatter,
},
legend: {
show: !!categories?.length,
data: categories?.map((category) => ({
...category,
icon: category.symbol,
})),
top: 8,
},
dataZoom: {
type: "inside",
filterMode: "none",
},
})
);
private _getSeries = memoizeOne(
(
data: NetworkData,
physicsEnabled: boolean,
reducedMotion: boolean,
showLabels: boolean
) => {
const containerWidth = this.clientWidth;
const containerHeight = this.clientHeight;
return [
{
id: "network",
type: "graph",
layout: physicsEnabled ? "force" : "none",
draggable: true,
roam: true,
selectedMode: "single",
label: {
show: showLabels,
position: "right",
},
emphasis: {
focus: "adjacency",
},
force: {
repulsion: [400, 600],
edgeLength: [200, 300],
gravity: 0.1,
layoutAnimation: !reducedMotion && data.nodes.length < 100,
},
edgeSymbol: ["none", "arrow"],
edgeSymbolSize: 10,
data: data.nodes.map((node) => {
const echartsNode: NonNullable<GraphSeriesOption["data"]>[number] =
{
id: node.id,
name: node.name,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
fixed: node.fixed,
};
if (this._nodePositions[node.id]) {
echartsNode.x = this._nodePositions[node.id].x;
echartsNode.y = this._nodePositions[node.id].y;
} else if (typeof node.polarDistance === "number") {
// set the position of the node at polarDistance from the center in a random direction
const angle = Math.random() * 2 * Math.PI;
echartsNode.x =
containerWidth / 2 +
((Math.cos(angle) * containerWidth) / 2) * node.polarDistance;
echartsNode.y =
containerHeight / 2 +
((Math.sin(angle) * containerHeight) / 2) * node.polarDistance;
this._nodePositions[node.id] = {
x: echartsNode.x,
y: echartsNode.y,
};
}
return echartsNode;
}),
links: data.links.map((link) => ({
...link,
value: link.reverseValue
? Math.max(link.value ?? 0, link.reverseValue)
: link.value,
// remove arrow for bidirectional links
symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work
})),
categories: data.categories || [],
},
] as any;
}
);
private _togglePhysics() {
if (this._baseChart?.chart) {
this._baseChart.chart
// @ts-ignore private method but no other way to get the graph positions
.getModel()
.getSeriesByIndex(0)
.getGraph()
.eachNode((node: any) => {
const layout = node.getLayout();
if (layout) {
this._nodePositions[node.id] = {
x: layout[0],
y: layout[1],
};
}
});
}
this._physicsEnabled = !this._physicsEnabled;
}
private _toggleLabels() {
this._showLabels = !this._showLabels;
}
static styles = css`
:host {
display: block;
position: relative;
}
ha-chart-base {
height: 100%;
--chart-max-height: 100%;
}
ha-icon-button,
::slotted(ha-icon-button) {
margin-right: 12px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-network-graph": HaNetworkGraph;
}
interface HASSDomEvents {
"node-selected": { id: string };
}
}

View File

@ -287,6 +287,7 @@ export class StateHistoryChartLine extends LitElement {
}, },
} as YAXisOption, } as YAXisOption,
legend: { legend: {
type: "custom",
show: this.showNames, show: this.showNames,
}, },
grid: { grid: {

View File

@ -308,6 +308,7 @@ export class StatisticsChart extends LitElement {
}, },
}, },
legend: { legend: {
type: "custom",
show: !this.hideLegend, show: !this.hideLegend,
data: this._legendData, data: this._legendData,
}, },

View File

@ -1,33 +1,28 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import { html, LitElement, nothing, type PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { stringCompare } from "../../common/string/compare"; import { getDeviceContext } from "../../common/entity/context/get_device_context";
import type { ScorableTextItem } from "../../common/string/filter/sequence-matching"; import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching"; import {
import type { getDeviceEntityDisplayLookup,
DeviceEntityDisplayLookup, type DeviceEntityDisplayLookup,
DeviceRegistryEntry, type DeviceRegistryEntry,
} from "../../data/device_registry"; } from "../../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../../data/device_registry"; import { domainToName } from "../../data/integration";
import type { EntityRegistryDisplayEntry } from "../../data/entity_registry"; import type { HomeAssistant } from "../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../types"; import { brandsUrl } from "../../util/brands-url";
import "../ha-combo-box"; import "../ha-generic-picker";
import type { HaComboBox } from "../ha-combo-box"; import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-combo-box-item"; import type { PickerComboBoxItem } from "../ha-picker-combo-box";
interface Device {
name: string;
area: string;
id: string;
}
type ScorableDevice = ScorableTextItem & Device;
export type HaDevicePickerDeviceFilterFunc = ( export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry device: DeviceRegistryEntry
@ -35,25 +30,35 @@ export type HaDevicePickerDeviceFilterFunc = (
export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
const rowRenderer: ComboBoxLitRenderer<Device> = (item) => html` interface DevicePickerItem extends PickerComboBoxItem {
<ha-combo-box-item type="button"> domain?: string;
<span slot="headline">${item.name}</span> domain_name?: string;
${item.area }
? html`<span slot="supporting-text">${item.area}</span>`
: nothing}
</ha-combo-box-item>
`;
@customElement("ha-device-picker") @customElement("ha-device-picker")
export class HaDevicePicker extends LitElement { export class HaDevicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public label?: string; @property() public label?: string;
@property() public value?: string; @property() public value?: string;
@property() public helper?: string; @property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: false, type: Array }) public createDomains?: string[];
/** /**
* Show only devices with entities from specific domains. * Show only devices with entities from specific domains.
* @type {Array} * @type {Array}
@ -92,38 +97,52 @@ export class HaDevicePicker extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public entityFilter?: HaDevicePickerEntityFilterFunc; public entityFilter?: HaDevicePickerEntityFilterFunc;
@property({ type: Boolean }) public disabled = false; @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ type: Boolean }) public required = false; @query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _opened?: boolean; @state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@query("ha-combo-box", true) public comboBox!: HaComboBox; protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this._loadConfigEntries();
}
private _init = false; private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private _getItems = () =>
this._getDevices(
this.hass.devices,
this.hass.entities,
this._configEntryLookup,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeDevices
);
private _getDevices = memoizeOne( private _getDevices = memoizeOne(
( (
devices: DeviceRegistryEntry[], haDevices: HomeAssistant["devices"],
areas: HomeAssistant["areas"], haEntities: HomeAssistant["entities"],
entities: EntityRegistryDisplayEntry[], configEntryLookup: Record<string, ConfigEntry>,
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"], deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
excludeDevices: this["excludeDevices"] excludeDevices: this["excludeDevices"]
): ScorableDevice[] => { ): DevicePickerItem[] => {
if (!devices.length) { const devices = Object.values(haDevices);
return [ const entities = Object.values(haEntities);
{
id: "no_devices",
area: "",
name: this.hass.localize("ui.components.device-picker.no_devices"),
strings: [],
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
@ -214,133 +233,158 @@ export class HaDevicePicker extends LitElement {
); );
} }
const outputDevices = inputDevices.map((device) => { const outputDevices = inputDevices.map<DevicePickerItem>((device) => {
const name = computeDeviceNameDisplay( const deviceName = computeDeviceNameDisplay(
device, device,
this.hass, this.hass,
deviceEntityLookup[device.id] deviceEntityLookup[device.id]
); );
const { area } = getDeviceContext(device, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const configEntry = device.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
const domain = configEntry?.domain;
const domainName = domain
? domainToName(this.hass.localize, domain)
: undefined;
return { return {
id: device.id, id: device.id,
name: label: "",
name || primary:
deviceName ||
this.hass.localize("ui.components.device-picker.unnamed_device"), this.hass.localize("ui.components.device-picker.unnamed_device"),
area: secondary: areaName,
device.area_id && areas[device.area_id] domain: configEntry?.domain,
? areas[device.area_id].name domain_name: domainName,
: this.hass.localize("ui.components.device-picker.no_area"), search_labels: [deviceName, areaName, domain, domainName].filter(
strings: [name || ""], Boolean
) as string[],
sorting_label: deviceName || "zzz",
}; };
}); });
if (!outputDevices.length) {
return [ return outputDevices;
{
id: "no_devices",
area: "",
name: this.hass.localize("ui.components.device-picker.no_match"),
strings: [],
},
];
}
if (outputDevices.length === 1) {
return outputDevices;
}
return outputDevices.sort((a, b) =>
stringCompare(a.name || "", b.name || "", this.hass.locale.language)
);
} }
); );
public async open() { private _valueRenderer = memoizeOne(
await this.updateComplete; (configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
await this.comboBox?.open(); const deviceId = value;
} const device = this.hass.devices[deviceId];
public async focus() { if (!device) {
await this.updateComplete; return html`<span slot="headline">${deviceId}</span>`;
await this.comboBox?.focus(); }
}
protected updated(changedProps: PropertyValues) { const { area } = getDeviceContext(device, this.hass);
if (
(!this._init && this.hass) || const deviceName = device ? computeDeviceName(device) : undefined;
(this._init && changedProps.has("_opened") && this._opened) const areaName = area ? computeAreaName(area) : undefined;
) {
this._init = true; const primary = deviceName;
const devices = this._getDevices( const secondary = areaName;
Object.values(this.hass.devices),
this.hass.areas, const configEntry = device.primary_config_entry
Object.values(this.hass.entities), ? configEntriesLookup[device.primary_config_entry]
this.includeDomains, : undefined;
this.excludeDomains,
this.includeDeviceClasses, return html`
this.deviceFilter, ${configEntry
this.entityFilter, ? html`<img
this.excludeDevices slot="start"
); alt=""
this.comboBox.items = devices; crossorigin="anonymous"
this.comboBox.filteredItems = devices; referrerpolicy="no-referrer"
src=${brandsUrl({
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
/>`
: nothing}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
} }
} );
private _rowRenderer: ComboBoxLitRenderer<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
})}
/>
`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.device-picker.no_match"
);
const valueRenderer = this._valueRenderer(this._configEntryLookup);
protected render(): TemplateResult {
return html` return html`
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.label=${this.label === undefined && this.hass .autofocus=${this.autofocus}
? this.hass.localize("ui.components.device-picker.device") .label=${this.label}
: this.label} .searchLabel=${this.searchLabel}
.value=${this._value} .notFoundLabel=${notFoundLabel}
.helper=${this.helper} .placeholder=${placeholder}
.renderer=${rowRenderer} .value=${this.value}
.disabled=${this.disabled} .rowRenderer=${this._rowRenderer}
.required=${this.required} .getItems=${this._getItems}
item-id-path="id" .hideClearIcon=${this.hideClearIcon}
item-value-path="id" .valueRenderer=${valueRenderer}
item-label-path="name" @value-changed=${this._valueChanged}
@opened-changed=${this._openedChanged} >
@value-changed=${this._deviceChanged} </ha-generic-picker>
@filter-changed=${this._filterChanged}
></ha-combo-box>
`; `;
} }
private get _value() { public async open() {
return this.value || ""; await this.updateComplete;
await this._picker?.open();
} }
private _filterChanged(ev: CustomEvent): void { private _valueChanged(ev) {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value.toLowerCase();
target.filteredItems = filterString.length
? fuzzyFilterSort<ScorableDevice>(filterString, target.items || [])
: target.items;
}
private _deviceChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
let newValue = ev.detail.value; const value = ev.detail.value;
if (newValue === "no_devices") {
newValue = "";
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _setValue(value: string) {
this.value = value; this.value = value;
setTimeout(() => { fireEvent(this, "value-changed", { value });
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
} }
} }

View File

@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { ValueChangedEvent, HomeAssistant } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "./ha-device-picker"; import "./ha-device-picker";
import type { import type {
HaDevicePickerDeviceFilterFunc, HaDevicePickerDeviceFilterFunc,

View File

@ -403,7 +403,8 @@ export class HaEntityPicker extends LitElement {
} }
public async open() { public async open() {
this._picker?.open(); await this.updateComplete;
await this._picker?.open();
} }
private _valueChanged(ev) { private _valueChanged(ev) {

View File

@ -1,481 +0,0 @@
import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import Fuse from "fuse.js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import type { StatisticsMetaData } from "../../data/recorder";
import { getStatisticIds, getStatisticLabel } from "../../data/recorder";
import { HaFuse } from "../../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-combo-box-item";
import "../ha-svg-icon";
import "./state-badge";
import { documentationUrl } from "../../util/documentation-url";
type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticItem {
// Force empty label to always display empty value by default in the search field
id: string;
statistic_id?: string;
label: "";
primary: string;
secondary?: string;
search_labels?: string[];
sorting_label?: string;
icon_path?: string;
type?: StatisticItemType;
stateObj?: HassEntity;
}
const MISSING_ID = "___missing-entity___";
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
@customElement("ha-statistic-combo-box")
export class HaStatisticComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property({ attribute: "statistic-types" })
public statisticTypes?: "mean" | "sum";
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[];
@property({ type: Boolean }) public disabled = false;
/**
* Show only statistics natively stored with these units of measurements.
* @type {Array}
* @attr include-statistics-unit-of-measurement
*/
@property({
type: Array,
attribute: "include-statistics-unit-of-measurement",
})
public includeStatisticsUnitOfMeasurement?: string | string[];
/**
* Show only statistics with these unit classes.
* @attr include-unit-class
*/
@property({ attribute: "include-unit-class" })
public includeUnitClass?: string | string[];
/**
* Show only statistics with these device classes.
* @attr include-device-class
*/
@property({ attribute: "include-device-class" })
public includeDeviceClass?: string | string[];
/**
* Show only statistics on entities.
* @type {Boolean}
* @attr entities-only
*/
@property({ type: Boolean, attribute: "entities-only" })
public entitiesOnly = false;
/**
* List of statistics to be excluded.
* @type {Array}
* @attr exclude-statistics
*/
@property({ type: Array, attribute: "exclude-statistics" })
public excludeStatistics?: string[];
@property({ attribute: false }) public helpMissingEntityUrl =
"/more-info/statistics/";
@state() private _opened = false;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _initialItems = false;
private _items: StatisticItem[] = [];
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this.hass.loadBackendTranslation("title");
}
private _rowRenderer: ComboBoxLitRenderer<StatisticItem> = (
item,
{ index }
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html`
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-svg-icon
style="margin: 0 4px"
slot="start"
.path=${item.icon_path}
></ha-svg-icon>
`
: item.stateObj
? html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
<span slot="headline">${item.primary} </span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.id && showEntityId
? html`<span slot="supporting-text" class="code">
${item.statistic_id}
</span>`
: nothing}
</ha-combo-box-item>
`;
};
private _getItems = memoizeOne(
(
_opened: boolean,
hass: this["hass"],
statisticIds: StatisticsMetaData[],
includeStatisticsUnitOfMeasurement?: string | string[],
includeUnitClass?: string | string[],
includeDeviceClass?: string | string[],
entitiesOnly?: boolean,
excludeStatistics?: string[],
value?: string
): StatisticItem[] => {
if (!statisticIds.length) {
return [
{
id: "",
label: "",
primary: this.hass.localize(
"ui.components.statistic-picker.no_statistics"
),
},
];
}
if (includeStatisticsUnitOfMeasurement) {
const includeUnits: (string | null)[] = ensureArray(
includeStatisticsUnitOfMeasurement
);
statisticIds = statisticIds.filter((meta) =>
includeUnits.includes(meta.statistics_unit_of_measurement)
);
}
if (includeUnitClass) {
const includeUnitClasses: (string | null)[] =
ensureArray(includeUnitClass);
statisticIds = statisticIds.filter((meta) =>
includeUnitClasses.includes(meta.unit_class)
);
}
if (includeDeviceClass) {
const includeDeviceClasses: (string | null)[] =
ensureArray(includeDeviceClass);
statisticIds = statisticIds.filter((meta) => {
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
return true;
}
return includeDeviceClasses.includes(
stateObj.attributes.device_class || ""
);
});
}
const isRTL = computeRTL(this.hass);
const output: StatisticItem[] = [];
statisticIds.forEach((meta) => {
if (
excludeStatistics &&
meta.statistic_id !== value &&
excludeStatistics.includes(meta.statistic_id)
) {
return;
}
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
if (!entitiesOnly) {
const id = meta.statistic_id;
const label = getStatisticLabel(this.hass, meta.statistic_id, meta);
const type =
meta.statistic_id.includes(":") &&
!meta.statistic_id.includes(".")
? "external"
: "no_state";
if (type === "no_state") {
output.push({
id,
primary: label,
secondary: this.hass.localize(
"ui.components.statistic-picker.no_state"
),
label: "",
type,
sorting_label: label,
search_labels: [label, id],
icon_path: mdiShape,
});
} else if (type === "external") {
const domain = id.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
output.push({
id,
statistic_id: id,
primary: label,
secondary: domainName,
label: "",
type,
sorting_label: label,
search_labels: [label, domainName, id],
icon_path: mdiChartLine,
});
}
}
return;
}
const id = meta.statistic_id;
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
output.push({
id,
statistic_id: id,
label: "",
primary,
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
id,
].filter(Boolean) as string[],
});
});
if (!output.length) {
return [
{
id: "",
primary: this.hass.localize(
"ui.components.statistic-picker.no_match"
),
label: "",
},
];
}
if (output.length > 1) {
output.sort((a, b) => {
const aPrefix = TYPE_ORDER.indexOf(a.type || "no_state");
const bPrefix = TYPE_ORDER.indexOf(b.type || "no_state");
return caseInsensitiveStringCompare(
`${aPrefix}_${a.sorting_label || ""}`,
`${bPrefix}_${b.sorting_label || ""}`,
this.hass.locale.language
);
});
}
output.push({
id: MISSING_ID,
primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
label: "",
icon_path: mdiHelpCircle,
});
return output;
}
);
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected shouldUpdate(changedProps: PropertyValues) {
if (
changedProps.has("value") ||
changedProps.has("label") ||
changedProps.has("disabled")
) {
return true;
}
return !(!changedProps.has("_opened") && this._opened);
}
public willUpdate(changedProps: PropertyValues) {
if (
(!this.hasUpdated && !this.statisticIds) ||
changedProps.has("statisticTypes")
) {
this._getStatisticIds();
}
if (
this.statisticIds &&
(!this._initialItems || (changedProps.has("_opened") && this._opened))
) {
this._items = this._getItems(
this._opened,
this.hass,
this.statisticIds!,
this.includeStatisticsUnitOfMeasurement,
this.includeUnitClass,
this.includeDeviceClass,
this.entitiesOnly,
this.excludeStatistics,
this.value
);
if (this._initialItems) {
this.comboBox.filteredItems = this._items;
}
this._initialItems = true;
}
}
protected render(): TemplateResult | typeof nothing {
if (this._items.length === 0) {
return nothing;
}
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="label"
.hass=${this.hass}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.statistic-picker.statistic")
: this.label}
.value=${this._value}
.renderer=${this._rowRenderer}
.disabled=${this.disabled}
.allowCustomValue=${this.allowCustomEntity}
.filteredItems=${this._items}
@opened-changed=${this._openedChanged}
@value-changed=${this._statisticChanged}
@filter-changed=${this._filterChanged}
></ha-combo-box>
`;
}
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
}
private get _value() {
return this.value || "";
}
private _statisticChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === MISSING_ID) {
newValue = "";
window.open(
documentationUrl(this.hass, this.helpMissingEntityUrl),
"_blank"
);
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _fuseIndex = memoizeOne((states: StatisticItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox;
const filterString = ev.detail.value.trim().toLowerCase() as string;
const index = this._fuseIndex(this._items);
const fuse = new HaFuse(this._items, {}, index);
const results = fuse.multiTermsSearch(filterString);
if (results) {
target.filteredItems = results.map((result) => result.item);
} else {
target.filteredItems = this._items;
}
}
private _setValue(value: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-statistic-combo-box": HaStatisticComboBox;
}
}

View File

@ -1,45 +1,45 @@
import { mdiChartLine, mdiClose, mdiMenuDown, mdiShape } from "@mdi/js"; import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { import { html, LitElement, nothing, type PropertyValues } from "lit";
css, import { customElement, property, query } from "lit/decorators";
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name"; import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name"; import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context"; import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { domainToName } from "../../data/integration"; import { domainToName } from "../../data/integration";
import { import {
getStatisticIds, getStatisticIds,
getStatisticLabel, getStatisticLabel,
type StatisticsMetaData, type StatisticsMetaData,
} from "../../data/recorder"; } from "../../data/recorder";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item"; import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button"; import "../ha-icon-button";
import "../ha-input-helper-text"; import "../ha-input-helper-text";
import type { HaMdListItem } from "../ha-md-list-item"; import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "./ha-statistic-combo-box";
import type { HaStatisticComboBox } from "./ha-statistic-combo-box";
import "./state-badge"; import "./state-badge";
interface StatisticItem { const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
primary: string;
secondary?: string; const MISSING_ID = "___missing-entity___";
iconPath?: string;
type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticComboBoxItem extends PickerComboBoxItem {
statistic_id?: string;
stateObj?: HassEntity; stateObj?: HassEntity;
type?: StatisticItemType;
} }
@customElement("ha-statistic-picker") @customElement("ha-statistic-picker")
@ -70,6 +70,9 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: false, type: Array }) @property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[]; public statisticIds?: StatisticsMetaData[];
@property({ attribute: false }) public helpMissingEntityUrl =
"/more-info/statistics/";
/** /**
* Show only statistics natively stored with these units of measurements. * Show only statistics natively stored with these units of measurements.
* @type {Array} * @type {Array}
@ -114,11 +117,7 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean }) @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false; public hideClearIcon = false;
@query("#anchor") private _anchor?: HaMdListItem; @query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("#input") private _input?: HaStatisticComboBox;
@state() private _opened = false;
public willUpdate(changedProps: PropertyValues) { public willUpdate(changedProps: PropertyValues) {
if ( if (
@ -133,6 +132,165 @@ export class HaStatisticPicker extends LitElement {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes); this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
} }
private _getItems = () =>
this._getStatisticsItems(
this.hass,
this.statisticIds,
this.includeStatisticsUnitOfMeasurement,
this.includeUnitClass,
this.includeDeviceClass,
this.entitiesOnly,
this.excludeStatistics,
this.value
);
private _getAdditionalItems(): StatisticComboBoxItem[] {
return [
{
id: MISSING_ID,
primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
icon_path: mdiHelpCircle,
},
];
}
private _getStatisticsItems = memoizeOne(
(
hass: HomeAssistant,
statisticIds?: StatisticsMetaData[],
includeStatisticsUnitOfMeasurement?: string | string[],
includeUnitClass?: string | string[],
includeDeviceClass?: string | string[],
entitiesOnly?: boolean,
excludeStatistics?: string[],
value?: string
): StatisticComboBoxItem[] => {
if (!statisticIds) {
return [];
}
if (includeStatisticsUnitOfMeasurement) {
const includeUnits: (string | null)[] = ensureArray(
includeStatisticsUnitOfMeasurement
);
statisticIds = statisticIds.filter((meta) =>
includeUnits.includes(meta.statistics_unit_of_measurement)
);
}
if (includeUnitClass) {
const includeUnitClasses: (string | null)[] =
ensureArray(includeUnitClass);
statisticIds = statisticIds.filter((meta) =>
includeUnitClasses.includes(meta.unit_class)
);
}
if (includeDeviceClass) {
const includeDeviceClasses: (string | null)[] =
ensureArray(includeDeviceClass);
statisticIds = statisticIds.filter((meta) => {
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
return true;
}
return includeDeviceClasses.includes(
stateObj.attributes.device_class || ""
);
});
}
const isRTL = computeRTL(this.hass);
const output: StatisticComboBoxItem[] = [];
statisticIds.forEach((meta) => {
if (
excludeStatistics &&
meta.statistic_id !== value &&
excludeStatistics.includes(meta.statistic_id)
) {
return;
}
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
if (!entitiesOnly) {
const id = meta.statistic_id;
const label = getStatisticLabel(this.hass, meta.statistic_id, meta);
const type =
meta.statistic_id.includes(":") &&
!meta.statistic_id.includes(".")
? "external"
: "no_state";
const sortingPrefix = `${TYPE_ORDER.indexOf(type)}`;
if (type === "no_state") {
output.push({
id,
primary: label,
secondary: this.hass.localize(
"ui.components.statistic-picker.no_state"
),
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, id],
icon_path: mdiShape,
});
} else if (type === "external") {
const domain = id.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
output.push({
id,
statistic_id: id,
primary: label,
secondary: domainName,
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, id],
icon_path: mdiChartLine,
});
}
}
return;
}
const id = meta.statistic_id;
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
id,
statistic_id: id,
primary,
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
id,
].filter(Boolean) as string[],
});
});
return output;
}
);
private _statisticMetaData = memoizeOne( private _statisticMetaData = memoizeOne(
(statisticId: string, statisticIds: StatisticsMetaData[]) => { (statisticId: string, statisticIds: StatisticsMetaData[]) => {
if (!statisticIds) { if (!statisticIds) {
@ -144,26 +302,11 @@ export class HaStatisticPicker extends LitElement {
} }
); );
private _renderContent() { private _valueRenderer: PickerValueRenderer = (value) => {
const statisticId = this.value || ""; const statisticId = value;
if (!this.value) {
return html`
<span slot="headline" class="placeholder"
>${this.placeholder ??
this.hass.localize(
"ui.components.statistic-picker.placeholder"
)}</span
>
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
const item = this._computeItem(statisticId); const item = this._computeItem(statisticId);
const showClearIcon =
!this.required && !this.disabled && !this.hideClearIcon;
return html` return html`
${item.stateObj ${item.stateObj
? html` ? html`
@ -173,29 +316,19 @@ export class HaStatisticPicker extends LitElement {
slot="start" slot="start"
></state-badge> ></state-badge>
` `
: item.iconPath : item.icon_path
? html`<ha-svg-icon ? html`
slot="start" <ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>
.path=${item.iconPath} `
></ha-svg-icon>`
: nothing} : nothing}
<span slot="headline">${item.primary}</span> <span slot="headline">${item.primary}</span>
${item.secondary ${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>` ? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing} : nothing}
${showClearIcon
? html`<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`; `;
} };
private _computeItem(statisticId: string): StatisticItem { private _computeItem(statisticId: string): StatisticComboBoxItem {
const stateObj = this.hass.states[statisticId]; const stateObj = this.hass.states[statisticId];
if (stateObj) { if (stateObj) {
@ -211,11 +344,24 @@ export class HaStatisticPicker extends LitElement {
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean) .filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ "); .join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return { return {
id: statisticId,
statistic_id: statisticId,
primary, primary,
secondary, secondary,
stateObj, stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
statisticId,
].filter(Boolean) as string[],
}; };
} }
@ -230,175 +376,124 @@ export class HaStatisticPicker extends LitElement {
: "no_state"; : "no_state";
if (type === "external") { if (type === "external") {
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(this.hass, statisticId, statistic); const label = getStatisticLabel(this.hass, statisticId, statistic);
const domain = statisticId.split(":")[0]; const domain = statisticId.split(":")[0];
const domainName = domainToName(this.hass.localize, domain); const domainName = domainToName(this.hass.localize, domain);
return { return {
id: statisticId,
statistic_id: statisticId,
primary: label, primary: label,
secondary: domainName, secondary: domainName,
iconPath: mdiChartLine, type: "external",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, statisticId],
icon_path: mdiChartLine,
}; };
} }
} }
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(this.hass, statisticId, statistic);
return { return {
primary: statisticId, id: statisticId,
iconPath: mdiShape, primary: label,
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
type: "no_state",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, statisticId],
icon_path: mdiShape,
}; };
} }
protected render() { private _rowRenderer: ComboBoxLitRenderer<StatisticComboBoxItem> = (
item,
{ index }
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing} <ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
<div class="container"> ${item.icon_path
${!this._opened
? html` ? html`
<ha-combo-box-item <ha-svg-icon
.disabled=${this.disabled} style="margin: 0 4px"
id="anchor" slot="start"
type="button" .path=${item.icon_path}
compact ></ha-svg-icon>
@click=${this._showPicker}
>
${this._renderContent()}
</ha-combo-box-item>
` `
: html` : item.stateObj
<ha-statistic-combo-box ? html`
id="input" <state-badge
.hass=${this.hass} slot="start"
.autofocus=${this.autofocus} .stateObj=${item.stateObj}
.allowCustomEntity=${this.allowCustomEntity} .hass=${this.hass}
.label=${this.hass.localize("ui.common.search")} ></state-badge>
.value=${this.value} `
.includeStatisticsUnitOfMeasurement=${this : nothing}
.includeStatisticsUnitOfMeasurement} <span slot="headline">${item.primary} </span>
.includeUnitClass=${this.includeUnitClass} ${item.secondary || item.type
.includeDeviceClass=${this.includeDeviceClass} ? html`<span slot="supporting-text"
.statisticTypes=${this.statisticTypes} >${item.secondary} - ${item.type}</span
.statisticIds=${this.statisticIds} >`
.excludeStatistics=${this.excludeStatistics} : nothing}
hide-clear-icon ${item.statistic_id && showEntityId
@opened-changed=${this._debounceOpenedChanged} ? html`<span slot="supporting-text" class="code">
@input=${stopPropagation} ${item.statistic_id}
></ha-statistic-combo-box> </span>`
`} : nothing}
${this._renderHelper()} </ha-combo-box-item>
</div> `;
};
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.statistic-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.statistic-picker.no_match"
);
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`; `;
} }
private _renderHelper() { private _valueChanged(ev: ValueChangedEvent<string>) {
return this.helper ev.stopPropagation();
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>` const value = ev.detail.value;
: nothing;
}
private _clear(e) { if (value === MISSING_ID) {
e.stopPropagation(); window.open(
this.value = undefined; documentationUrl(this.hass, this.helpMissingEntityUrl),
fireEvent(this, "value-changed", { value: undefined }); "_blank"
fireEvent(this, "change"); );
}
private async _showPicker() {
if (this.disabled) {
return; return;
} }
this._opened = true;
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete; await this.updateComplete;
this._input?.focus(); await this._picker?.open();
this._input?.open();
}
// Multiple calls to _openedChanged can be triggered in quick succession
// when the menu is opened
private _debounceOpenedChanged = debounce(
(ev) => this._openedChanged(ev),
10
);
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
const opened = ev.detail.value;
if (this._opened && !opened) {
this._opened = false;
await this.updateComplete;
this._anchor?.focus();
}
}
static get styles(): CSSResultGroup {
return [
css`
.container {
position: relative;
display: block;
}
ha-combo-box-item {
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: 4px;
border-end-end-radius: 0;
border-end-start-radius: 0;
--md-list-item-one-line-container-height: 56px;
--md-list-item-two-line-container-height: 56px;
--md-list-item-top-space: 8px;
--md-list-item-bottom-space: 8px;
--md-list-item-leading-space: 8px;
--md-list-item-trailing-space: 8px;
--ha-md-list-item-gap: 8px;
/* Remove the default focus ring */
--md-focus-ring-width: 0px;
--md-focus-ring-duration: 0s;
}
/* Add Similar focus style as the text field */
ha-combo-box-item:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
ha-combo-box-item:focus:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-combo-box-item ha-svg-icon[slot="start"] {
margin: 0 4px;
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.edit {
--mdc-icon-size: 20px;
width: 32px;
}
label {
display: block;
margin: 0 0 8px;
}
.placeholder {
color: var(--secondary-text-color);
padding: 0 8px;
}
`,
];
} }
} }

View File

@ -1,15 +1,14 @@
import { mdiTextureBox } from "@mdi/js"; import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, html } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching"; import { computeFloorName } from "../common/entity/compute_floor_name";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching"; import { getAreaContext } from "../common/entity/context/get_area_context";
import type { AreaRegistryEntry } from "../data/area_registry";
import { createAreaRegistryEntry } from "../data/area_registry"; import { createAreaRegistryEntry } from "../data/area_registry";
import type { import type {
DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
@ -21,26 +20,15 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail"; import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button"; import "./ha-icon-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon"; import "./ha-svg-icon";
type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<AreaRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
${item.name}
</ha-combo-box-item>
`;
const ADD_NEW_ID = "___ADD_NEW___"; const ADD_NEW_ID = "___ADD_NEW___";
const NO_ITEMS_ID = "___NO_ITEMS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
@customElement("ha-area-picker") @customElement("ha-area-picker")
export class HaAreaPicker extends LitElement { export class HaAreaPicker extends LitElement {
@ -99,41 +87,68 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _opened?: boolean; @query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() { public async open() {
await this.updateComplete; await this.updateComplete;
await this.comboBox?.open(); await this._picker?.open();
} }
public async focus() { // Recompute value renderer when the areas change
await this.updateComplete; private _computeValueRenderer = memoizeOne(
await this.comboBox?.focus(); (_haAreas: HomeAssistant["areas"]): PickerValueRenderer =>
} (value) => {
const area = this.hass.areas[value];
if (!area) {
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${area}</span>
`;
}
const { floor } = getAreaContext(area, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const floorName = floor ? computeFloorName(floor) : undefined;
const icon = area.icon;
return html`
${icon
? html`<ha-icon slot="start" .icon=${icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<span slot="headline">${areaName}</span>
${floorName
? html`<span slot="supporting-text">${floorName}</span>`
: nothing}
`;
}
);
private _getAreas = memoizeOne( private _getAreas = memoizeOne(
( (
areas: AreaRegistryEntry[], haAreas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[], haDevices: HomeAssistant["devices"],
entities: EntityRegistryDisplayEntry[], haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"], deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeAreas: this["excludeAreas"] excludeAreas: this["excludeAreas"]
): AreaRegistryEntry[] => { ): PickerComboBoxItem[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined; let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined;
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
if ( if (
includeDomains || includeDomains ||
excludeDomains || excludeDomains ||
@ -263,225 +278,147 @@ export class HaAreaPicker extends LitElement {
); );
} }
if (!outputAreas.length) { const items = outputAreas.map<PickerComboBoxItem>((area) => {
outputAreas = [ const { floor } = getAreaContext(area, this.hass);
{ const floorName = floor ? computeFloorName(floor) : undefined;
area_id: NO_ITEMS_ID, const areaName = computeAreaName(area);
floor_id: null, return {
name: this.hass.localize("ui.components.area-picker.no_areas"), id: area.area_id,
picture: null, primary: areaName || area.area_id,
icon: null, secondary: floorName,
aliases: [], icon: area.icon || undefined,
labels: [], icon_path: area.icon ? undefined : mdiTextureBox,
temperature_entity_id: null, sorting_label: areaName,
humidity_entity_id: null, search_labels: [
created_at: 0, areaName,
modified_at: 0, floorName,
}, area.area_id,
]; ...area.aliases,
} ].filter((v): v is string => Boolean(v)),
};
});
return noAdd return items;
? outputAreas
: [
...outputAreas,
{
area_id: ADD_NEW_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.add_new"),
picture: null,
icon: "mdi:plus",
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
} }
); );
protected updated(changedProps: PropertyValues) { private _getItems = () =>
if ( this._getAreas(
(!this._init && this.hass) || this.hass.areas,
(this._init && changedProps.has("_opened") && this._opened) this.hass.devices,
) { this.hass.entities,
this._init = true; this.includeDomains,
const areas = this._getAreas( this.excludeDomains,
Object.values(this.hass.areas), this.includeDeviceClasses,
Object.values(this.hass.devices), this.deviceFilter,
Object.values(this.hass.entities), this.entityFilter,
this.includeDomains, this.excludeAreas
this.excludeDomains, );
this.includeDeviceClasses,
this.deviceFilter, private _allAreaNames = memoizeOne(
this.entityFilter, (areas: HomeAssistant["areas"]) =>
this.noAdd, Object.values(areas)
this.excludeAreas .map((area) => computeAreaName(area)?.toLowerCase())
).map((area) => ({ .filter(Boolean) as string[]
...area, );
strings: [area.area_id, ...area.aliases, area.name],
})); private _getAdditionalItems = (
this.comboBox.items = areas; searchString?: string
this.comboBox.filteredItems = areas; ): PickerComboBoxItem[] => {
if (this.noAdd) {
return [];
} }
}
const allAreas = this._allAreaNames(this.hass.areas);
if (searchString && !allAreas.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
{
name: searchString,
}
),
icon_path: mdiPlus,
},
];
}
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.area-picker.add_new"),
icon_path: mdiPlus,
},
];
};
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const valueRenderer = this._computeValueRenderer(this.hass.areas);
return html` return html`
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.helper=${this.helper} .autofocus=${this.autofocus}
item-value-path="area_id" .label=${this.label}
item-id-path="area_id" .notFoundLabel=${this.hass.localize(
item-label-path="name" "ui.components.area-picker.no_match"
.value=${this._value} )}
.disabled=${this.disabled} .placeholder=${placeholder}
.required=${this.required} .value=${this.value}
.label=${this.label === undefined && this.hass .getItems=${this._getItems}
? this.hass.localize("ui.components.area-picker.area") .getAdditionalItems=${this._getAdditionalItems}
: this.label} .valueRenderer=${valueRenderer}
.placeholder=${this.placeholder @value-changed=${this._valueChanged}
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
> >
</ha-combo-box> </ha-generic-picker>
`; `;
} }
private _filterChanged(ev: CustomEvent): void { private _valueChanged(ev: ValueChangedEvent<string>) {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableAreaRegistryEntry>(
filterString,
target.items?.filter(
(item) => ![NO_ITEMS_ID, ADD_NEW_ID].includes(item.label_id)
) || []
);
if (filteredItems.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{
area_id: NO_ITEMS_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_match"),
icon: null,
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
] as AreaRegistryEntry[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
area_id: ADD_NEW_SUGGESTION_ID,
floor_id: null,
name: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
] as AreaRegistryEntry[];
}
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
let newValue = ev.detail.value; const value = ev.detail.value;
if (newValue === NO_ITEMS_ID) { if (!value) {
newValue = ""; this._setValue(undefined);
this.comboBox.setInputValue("");
return; return;
} }
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { if (value.startsWith(ADD_NEW_ID)) {
if (newValue !== this._value) { this.hass.loadFragmentTranslation("config");
this._setValue(newValue);
} const suggestedName = value.substring(ADD_NEW_ID.length);
return;
showAreaRegistryDetailDialog(this, {
suggestedName: suggestedName,
createEntry: async (values) => {
try {
const area = await createAreaRegistryEntry(this.hass, values);
this._setValue(area.area_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.area-picker.failed_create_area"
),
text: err.message,
});
}
},
});
} }
(ev.target as any).value = this._value; this._setValue(value);
this.hass.loadFragmentTranslation("config");
showAreaRegistryDetailDialog(this, {
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
createEntry: async (values) => {
try {
const area = await createAreaRegistryEntry(this.hass, values);
const areas = [...Object.values(this.hass.areas), area];
this.comboBox.filteredItems = this._getAreas(
areas,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeAreas
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(area.area_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.area-picker.failed_create_area"
),
text: err.message,
});
}
},
});
this._suggestion = undefined;
this.comboBox.setInputValue("");
} }
private _setValue(value?: string) { private _setValue(value?: string) {
this.value = value; this.value = value;
setTimeout(() => { fireEvent(this, "value-changed", { value });
fireEvent(this, "value-changed", { value }); fireEvent(this, "change");
fireEvent(this, "change");
}, 0);
} }
} }

View File

@ -5,8 +5,11 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { import {
type PipelineRunEvent,
runAssistPipeline, runAssistPipeline,
type AssistPipeline, type AssistPipeline,
type ConversationChatLogAssistantDelta,
type ConversationChatLogToolResultDelta,
} from "../data/assist_pipeline"; } from "../data/assist_pipeline";
import { supportsFeature } from "../common/entity/supports-feature"; import { supportsFeature } from "../common/entity/supports-feature";
import { ConversationEntityFeature } from "../data/conversation"; import { ConversationEntityFeature } from "../data/conversation";
@ -90,7 +93,7 @@ export class HaAssistChat extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
this._audioRecorder?.close(); this._audioRecorder?.close();
this._audioRecorder = undefined; this._audioRecorder = undefined;
this._audio?.pause(); this._unloadAudio();
this._conversation = []; this._conversation = [];
this._conversationId = null; this._conversationId = null;
} }
@ -109,25 +112,24 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech; const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html` return html`
${controlHA <div class="messages" id="scroll-container">
? nothing ${controlHA
: html` ? nothing
<ha-alert> : html`
${this.hass.localize( <ha-alert>
"ui.dialogs.voice_command.conversation_no_control" ${this.hass.localize(
)} "ui.dialogs.voice_command.conversation_no_control"
</ha-alert> )}
`} </ha-alert>
<div class="messages"> `}
<div class="messages-container" id="scroll-container"> <div class="spacer"></div>
${this._conversation!.map( ${this._conversation!.map(
// New lines matter for messages // New lines matter for messages
// prettier-ignore // prettier-ignore
(message) => html` (message) => html`
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div> <div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
` `
)} )}
</div>
</div> </div>
<div class="input" slot="primaryAction"> <div class="input" slot="primaryAction">
<ha-textfield <ha-textfield
@ -273,8 +275,8 @@ export class HaAssistChat extends LitElement {
} }
private async _startListening() { private async _startListening() {
this._unloadAudio();
this._processing = true; this._processing = true;
this._audio?.pause();
if (!this._audioRecorder) { if (!this._audioRecorder) {
this._audioRecorder = new AudioRecorder((audio) => { this._audioRecorder = new AudioRecorder((audio) => {
if (this._audioBuffer) { if (this._audioBuffer) {
@ -293,27 +295,36 @@ export class HaAssistChat extends LitElement {
await this._audioRecorder.start(); await this._audioRecorder.start();
this._addMessage(userMessage); this._addMessage(userMessage);
this.requestUpdate("_audioRecorder");
let continueConversation = false; const hassMessageProcesser = this._createAddHassMessageProcessor();
let hassMessage = {
who: "hass",
text: "…",
error: false,
};
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it
try { try {
const unsub = await runAssistPipeline( const unsub = await runAssistPipeline(
this.hass, this.hass,
(event) => { (event: PipelineRunEvent) => {
if (event.type === "run-start") { if (event.type === "run-start") {
this._stt_binary_handler_id = this._stt_binary_handler_id =
event.data.runner_data.stt_binary_handler_id; event.data.runner_data.stt_binary_handler_id;
this._audio = new Audio(event.data.tts_output!.url);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
if (hassMessageProcesser.continueConversation) {
this._startListening();
}
});
this._audio.addEventListener("pause", this._unloadAudio);
this._audio.addEventListener("canplaythrough", () =>
this._audio?.play()
);
this._audio.addEventListener("error", () => {
this._unloadAudio();
showAlertDialog(this, { title: "Error playing audio." });
});
} }
// When we start STT stage, the WS has a binary handler // When we start STT stage, the WS has a binary handler
if (event.type === "stt-start" && this._audioBuffer) { else if (event.type === "stt-start" && this._audioBuffer) {
// Send the buffer over the WS to the STT engine. // Send the buffer over the WS to the STT engine.
for (const buffer of this._audioBuffer) { for (const buffer of this._audioBuffer) {
this._sendAudioChunk(buffer); this._sendAudioChunk(buffer);
@ -322,91 +333,26 @@ export class HaAssistChat extends LitElement {
} }
// Stop recording if the server is done with STT stage // Stop recording if the server is done with STT stage
if (event.type === "stt-end") { else if (event.type === "stt-end") {
this._stt_binary_handler_id = undefined; this._stt_binary_handler_id = undefined;
this._stopListening(); this._stopListening();
userMessage.text = event.data.stt_output.text; userMessage.text = event.data.stt_output.text;
this.requestUpdate("_conversation"); this.requestUpdate("_conversation");
// To make sure the answer is placed at the right user text, we add it before we process it // Add the response message placeholder to the chat when we know the STT is done
this._addMessage(hassMessage); hassMessageProcesser.addMessage();
} } else if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
if (event.type === "intent-progress") { } else if (event.type === "run-end") {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (currentDeltaRole && delta.role && hassMessage.text !== "…") {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
}
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
continueConversation =
event.data.intent_output.continue_conversation;
const plain = event.data.intent_output.response.speech?.plain;
if (plain) {
hassMessage.text = plain.speech;
}
this.requestUpdate("_conversation");
}
if (event.type === "tts-end") {
const url = event.data.tts_output.url;
this._audio = new Audio(url);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
if (continueConversation) {
this._startListening();
}
});
this._audio.addEventListener("pause", this._unloadAudio);
this._audio.addEventListener("canplaythrough", this._playAudio);
this._audio.addEventListener("error", this._audioError);
}
if (event.type === "run-end") {
this._stt_binary_handler_id = undefined; this._stt_binary_handler_id = undefined;
unsub(); unsub();
} } else if (event.type === "error") {
this._unloadAudio();
if (event.type === "error") {
this._stt_binary_handler_id = undefined; this._stt_binary_handler_id = undefined;
if (userMessage.text === "…") { if (userMessage.text === "…") {
userMessage.text = event.data.message; userMessage.text = event.data.message;
userMessage.error = true; userMessage.error = true;
} else { } else {
hassMessage.text = event.data.message; hassMessageProcesser.setError(event.data.message);
hassMessage.error = true;
} }
this._stopListening(); this._stopListening();
this.requestUpdate("_conversation"); this.requestUpdate("_conversation");
@ -464,90 +410,33 @@ export class HaAssistChat extends LitElement {
this.hass.connection.socket!.send(data); this.hass.connection.socket!.send(data);
} }
private _playAudio = () => {
this._audio?.play();
};
private _audioError = () => {
showAlertDialog(this, { title: "Error playing audio." });
this._audio?.removeAttribute("src");
};
private _unloadAudio = () => { private _unloadAudio = () => {
this._audio?.removeAttribute("src"); if (!this._audio) {
return;
}
this._audio.pause();
this._audio.removeAttribute("src");
this._audio = undefined; this._audio = undefined;
}; };
private async _processText(text: string) { private async _processText(text: string) {
this._unloadAudio();
this._processing = true; this._processing = true;
this._audio?.pause();
this._addMessage({ who: "user", text }); this._addMessage({ who: "user", text });
let hassMessage = { const hassMessageProcesser = this._createAddHassMessageProcessor();
who: "hass", hassMessageProcesser.addMessage();
text: "…",
error: false,
};
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(hassMessage);
try { try {
const unsub = await runAssistPipeline( const unsub = await runAssistPipeline(
this.hass, this.hass,
(event) => { (event) => {
if (event.type === "intent-progress") { if (event.type.startsWith("intent-")) {
const delta = event.data.chat_log_delta; hassMessageProcesser.processEvent(event);
// new message and previous message has content
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (
currentDeltaRole &&
delta.role === "assistant" &&
hassMessage.text !== "…"
) {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
} }
if (event.type === "intent-end") { if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
const plain = event.data.intent_output.response.speech?.plain;
if (plain) {
hassMessage.text = plain.speech;
}
this.requestUpdate("_conversation");
unsub(); unsub();
} }
if (event.type === "error") { if (event.type === "error") {
hassMessage.text = event.data.message; hassMessageProcesser.setError(event.data.message);
hassMessage.error = true;
this.requestUpdate("_conversation");
unsub(); unsub();
} }
}, },
@ -560,20 +449,126 @@ export class HaAssistChat extends LitElement {
} }
); );
} catch { } catch {
hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error"); hassMessageProcesser.setError(
hassMessage.error = true; this.hass.localize("ui.dialogs.voice_command.error")
this.requestUpdate("_conversation"); );
} finally { } finally {
this._processing = false; this._processing = false;
} }
} }
private _createAddHassMessageProcessor() {
let currentDeltaRole = "";
const progressToNextMessage = () => {
if (progress.hassMessage.text === "…") {
return;
}
progress.hassMessage.text = progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
);
progress.hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(progress.hassMessage);
};
const isAssistantDelta = (
_delta: any
): _delta is Partial<ConversationChatLogAssistantDelta> =>
currentDeltaRole === "assistant";
const isToolResult = (
_delta: any
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
const tools: Record<
string,
ConversationChatLogAssistantDelta["tool_calls"][0]
> = {};
const progress = {
continueConversation: false,
hassMessage: {
who: "hass",
text: "…",
error: false,
},
addMessage: () => {
this._addMessage(progress.hassMessage);
},
setError: (error: string) => {
progressToNextMessage();
progress.hassMessage.text = error;
progress.hassMessage.error = true;
this.requestUpdate("_conversation");
},
processEvent: (event: PipelineRunEvent) => {
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
progressToNextMessage();
currentDeltaRole = delta.role;
}
if (isAssistantDelta(delta)) {
if (delta.content) {
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
tools[toolCall.id] = toolCall;
}
}
} else if (isToolResult(delta)) {
if (tools[delta.tool_call_id]) {
delete tools[delta.tool_call_id];
}
}
} else if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
progress.continueConversation =
event.data.intent_output.continue_conversation;
const response =
event.data.intent_output.response.speech?.plain.speech;
if (!response) {
return;
}
if (event.data.intent_output.response.response_type === "error") {
progress.setError(response);
} else {
progress.hassMessage.text = response;
this.requestUpdate("_conversation");
}
}
},
};
return progress;
}
static styles = css` static styles = css`
:host { :host {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
ha-alert {
margin-bottom: 8px;
}
ha-textfield { ha-textfield {
display: block; display: block;
} }
@ -581,17 +576,14 @@ export class HaAssistChat extends LitElement {
flex: 1; flex: 1;
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
position: relative;
}
.messages-container {
position: absolute;
bottom: 0px;
right: 0px;
left: 0px;
padding: 0px 10px 16px;
box-sizing: border-box;
overflow-y: auto; overflow-y: auto;
max-height: 100%; max-height: 100%;
display: flex;
flex-direction: column;
padding: 0 12px 16px;
}
.spacer {
flex: 1;
} }
.message { .message {
white-space: pre-line; white-space: pre-line;
@ -601,6 +593,9 @@ export class HaAssistChat extends LitElement {
padding: 8px; padding: 8px;
border-radius: 15px; border-radius: 15px;
} }
.message:last-child {
margin-bottom: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
.message { .message {
@ -619,7 +614,7 @@ export class HaAssistChat extends LitElement {
margin-left: 24px; margin-left: 24px;
margin-inline-start: 24px; margin-inline-start: 24px;
margin-inline-end: initial; margin-inline-end: initial;
float: var(--float-end); align-self: flex-end;
text-align: right; text-align: right;
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
background-color: var(--chat-background-color-user, var(--primary-color)); background-color: var(--chat-background-color-user, var(--primary-color));
@ -631,7 +626,7 @@ export class HaAssistChat extends LitElement {
margin-right: 24px; margin-right: 24px;
margin-inline-end: 24px; margin-inline-end: 24px;
margin-inline-start: initial; margin-inline-start: initial;
float: var(--float-start); align-self: flex-start;
border-bottom-left-radius: 0px; border-bottom-left-radius: 0px;
background-color: var( background-color: var(
--chat-background-color-hass, --chat-background-color-hass,

View File

@ -81,27 +81,27 @@ export class HaBaseTimeInput extends LitElement {
/** /**
* Label for the day input * Label for the day input
*/ */
@property({ attribute: false }) dayLabel = ""; @property({ type: String, attribute: "day-label" }) dayLabel = "";
/** /**
* Label for the hour input * Label for the hour input
*/ */
@property({ attribute: false }) hourLabel = ""; @property({ type: String, attribute: "hour-label" }) hourLabel = "";
/** /**
* Label for the min input * Label for the min input
*/ */
@property({ attribute: false }) minLabel = ""; @property({ type: String, attribute: "min-label" }) minLabel = "";
/** /**
* Label for the sec input * Label for the sec input
*/ */
@property({ attribute: false }) secLabel = ""; @property({ type: String, attribute: "sec-label" }) secLabel = "";
/** /**
* Label for the milli sec input * Label for the milli sec input
*/ */
@property({ attribute: false }) millisecLabel = ""; @property({ type: String, attribute: "ms-label" }) millisecLabel = "";
/** /**
* show the sec field * show the sec field
@ -342,7 +342,7 @@ export class HaBaseTimeInput extends LitElement {
padding-right: 3px; padding-right: 3px;
} }
ha-textfield { ha-textfield {
width: 55px; width: 60px;
flex-grow: 1; flex-grow: 1;
text-align: center; text-align: center;
--mdc-shape-small: 0; --mdc-shape-small: 0;

View File

@ -90,7 +90,7 @@ export class HaDialog extends DialogBase {
} }
.mdc-dialog__actions { .mdc-dialog__actions {
justify-content: var(--justify-action-buttons, flex-end); justify-content: var(--justify-action-buttons, flex-end);
padding: 12px 24px max(env(safe-area-inset-bottom), 12px) 24px; padding: 12px 24px max(var(--safe-area-inset-bottom), 12px) 24px;
} }
.mdc-dialog__actions span:nth-child(1) { .mdc-dialog__actions span:nth-child(1) {
flex: var(--secondary-action-button-flex, unset); flex: var(--secondary-action-button-flex, unset);
@ -117,7 +117,7 @@ export class HaDialog extends DialogBase {
:host([hideactions]) .mdc-dialog .mdc-dialog__content { :host([hideactions]) .mdc-dialog .mdc-dialog__content {
padding-bottom: max( padding-bottom: max(
var(--dialog-content-padding, 24px), var(--dialog-content-padding, 24px),
env(safe-area-inset-bottom) var(--safe-area-inset-bottom)
); );
} }
.mdc-dialog .mdc-dialog__surface { .mdc-dialog .mdc-dialog__surface {

View File

@ -52,11 +52,11 @@ class HaDurationInput extends LitElement {
.milliseconds=${this._milliseconds} .milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged} @value-changed=${this._durationChanged}
no-hours-limit no-hours-limit
dayLabel="dd" day-label="dd"
hourLabel="hh" hour-label="hh"
minLabel="mm" min-label="mm"
secLabel="ss" sec-label="ss"
millisecLabel="ms" ms-label="ms"
></ha-base-time-input> ></ha-base-time-input>
`; `;
} }

View File

@ -202,6 +202,7 @@ export class HaExpansionPanel extends LitElement {
.header, .header,
::slotted([slot="header"]) { ::slotted([slot="header"]) {
flex: 1; flex: 1;
overflow-wrap: anywhere;
} }
.container { .container {

View File

@ -1,14 +1,13 @@
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching"; import { computeFloorName } from "../common/entity/compute_floor_name";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
import type { AreaRegistryEntry } from "../data/area_registry";
import { updateAreaRegistryEntry } from "../data/area_registry"; import { updateAreaRegistryEntry } from "../data/area_registry";
import type { import type {
DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
@ -16,33 +15,29 @@ import type {
} from "../data/device_registry"; } from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry"; import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { import {
createFloorRegistryEntry, createFloorRegistryEntry,
getFloorAreaLookup, getFloorAreaLookup,
type FloorRegistryEntry,
} from "../data/floor_registry"; } from "../data/floor_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showFloorRegistryDetailDialog } from "../panels/config/areas/show-dialog-floor-registry-detail"; import { showFloorRegistryDetailDialog } from "../panels/config/areas/show-dialog-floor-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-floor-icon"; import "./ha-floor-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button"; import "./ha-icon-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
type ScorableFloorRegistryEntry = ScorableTextItem & FloorRegistryEntry; import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___"; const ADD_NEW_ID = "___ADD_NEW___";
const NO_FLOORS_ID = "___NO_FLOORS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
const rowRenderer: ComboBoxLitRenderer<FloorRegistryEntry> = (item) => html` interface FloorComboBoxItem extends PickerComboBoxItem {
<ha-combo-box-item type="button"> floor?: FloorRegistryEntry;
<ha-floor-icon slot="start" .floor=${item}></ha-floor-icon> }
${item.name}
</ha-combo-box-item>
`;
@customElement("ha-floor-picker") @customElement("ha-floor-picker")
export class HaFloorPicker extends LitElement { export class HaFloorPicker extends LitElement {
@ -88,7 +83,7 @@ export class HaFloorPicker extends LitElement {
* @type {Array} * @type {Array}
* @attr exclude-floors * @attr exclude-floors
*/ */
@property({ type: Array, attribute: "exclude-floor" }) @property({ type: Array, attribute: "exclude-floors" })
public excludeFloors?: string[]; public excludeFloors?: string[];
@property({ attribute: false }) @property({ attribute: false })
@ -101,38 +96,53 @@ export class HaFloorPicker extends LitElement {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _opened?: boolean; @query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() { public async open() {
await this.updateComplete; await this.updateComplete;
await this.comboBox?.open(); await this._picker?.open();
} }
public async focus() { // Recompute value renderer when the areas change
await this.updateComplete; private _computeValueRenderer = memoizeOne(
await this.comboBox?.focus(); (_haAreas: HomeAssistant["floors"]): PickerValueRenderer =>
} (value) => {
const floor = this.hass.floors[value];
if (!floor) {
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${floor}</span>
`;
}
const floorName = floor ? computeFloorName(floor) : undefined;
return html`
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
<span slot="headline">${floorName}</span>
`;
}
);
private _getFloors = memoizeOne( private _getFloors = memoizeOne(
( (
floors: FloorRegistryEntry[], haFloors: HomeAssistant["floors"],
areas: AreaRegistryEntry[], haAreas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[], haDevices: HomeAssistant["devices"],
entities: EntityRegistryDisplayEntry[], haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"], deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeFloors: this["excludeFloors"] excludeFloors: this["excludeFloors"]
): FloorRegistryEntry[] => { ): FloorComboBoxItem[] => {
const floors = Object.values(haFloors);
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined; let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined;
@ -269,216 +279,169 @@ export class HaFloorPicker extends LitElement {
); );
} }
if (!outputFloors.length) { const items = outputFloors.map<FloorComboBoxItem>((floor) => {
outputFloors = [ const floorName = computeFloorName(floor);
{ return {
floor_id: NO_FLOORS_ID, id: floor.floor_id,
name: this.hass.localize("ui.components.floor-picker.no_floors"), primary: floorName,
icon: null, floor: floor,
level: null, sorting_label: floor.level?.toString() || "zzzzz",
aliases: [], search_labels: [floorName, floor.floor_id, ...floor.aliases].filter(
created_at: 0, (v): v is string => Boolean(v)
modified_at: 0, ),
}, };
]; });
}
return noAdd return items;
? outputFloors
: [
...outputFloors,
{
floor_id: ADD_NEW_ID,
name: this.hass.localize("ui.components.floor-picker.add_new"),
icon: "mdi:plus",
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
];
} }
); );
protected updated(changedProps: PropertyValues) { private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (item) => html`
if ( <ha-combo-box-item type="button" compact>
(!this._init && this.hass) || ${item.icon_path
(this._init && changedProps.has("_opened") && this._opened) ? html`
) { <ha-svg-icon
this._init = true; slot="start"
const floors = this._getFloors( style="margin: 0 4px"
Object.values(this.hass.floors), .path=${item.icon_path}
Object.values(this.hass.areas), ></ha-svg-icon>
Object.values(this.hass.devices), `
Object.values(this.hass.entities), : html`
this.includeDomains, <ha-floor-icon
this.excludeDomains, slot="start"
this.includeDeviceClasses, .floor=${item.floor}
this.deviceFilter, style="margin: 0 4px"
this.entityFilter, ></ha-floor-icon>
this.noAdd, `}
this.excludeFloors <span slot="headline">${item.primary}</span>
).map((floor) => ({ </ha-combo-box-item>
...floor, `;
strings: [floor.floor_id, floor.name, ...floor.aliases],
})); private _getItems = () =>
this.comboBox.items = floors; this._getFloors(
this.comboBox.filteredItems = floors; this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeFloors
);
private _allFloorNames = memoizeOne(
(floors: HomeAssistant["floors"]) =>
Object.values(floors)
.map((floor) => computeFloorName(floor)?.toLowerCase())
.filter(Boolean) as string[]
);
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (this.noAdd) {
return [];
} }
}
const allFloors = this._allFloorNames(this.hass.floors);
if (searchString && !allFloors.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.floor-picker.add_new_sugestion",
{
name: searchString,
}
),
icon_path: mdiPlus,
},
];
}
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.floor-picker.add_new"),
icon_path: mdiPlus,
},
];
};
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.floor-picker.floor");
const valueRenderer = this._computeValueRenderer(this.hass.floors);
return html` return html`
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.helper=${this.helper} .autofocus=${this.autofocus}
item-value-path="floor_id" .label=${this.label}
item-id-path="floor_id" .notFoundLabel=${this.hass.localize(
item-label-path="name" "ui.components.floor-picker.no_match"
.value=${this._value} )}
.disabled=${this.disabled} .placeholder=${placeholder}
.required=${this.required} .value=${this.value}
.label=${this.label === undefined && this.hass .getItems=${this._getItems}
? this.hass.localize("ui.components.floor-picker.floor") .getAdditionalItems=${this._getAdditionalItems}
: this.label} .valueRenderer=${valueRenderer}
.placeholder=${this.placeholder .rowRenderer=${this._rowRenderer}
? this.hass.floors[this.placeholder]?.name @value-changed=${this._valueChanged}
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._floorChanged}
> >
</ha-combo-box> </ha-generic-picker>
`; `;
} }
private _filterChanged(ev: CustomEvent): void { private _valueChanged(ev: ValueChangedEvent<string>) {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableFloorRegistryEntry>(
filterString,
target.items?.filter(
(item) => ![NO_FLOORS_ID, ADD_NEW_ID].includes(item.label_id)
) || []
);
if (filteredItems.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{
floor_id: NO_FLOORS_ID,
name: this.hass.localize("ui.components.floor-picker.no_match"),
icon: null,
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
] as FloorRegistryEntry[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
floor_id: ADD_NEW_SUGGESTION_ID,
name: this.hass.localize(
"ui.components.floor-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
] as FloorRegistryEntry[];
}
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _floorChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
let newValue = ev.detail.value; const value = ev.detail.value;
if (newValue === NO_FLOORS_ID) { if (!value) {
newValue = ""; this._setValue(undefined);
this.comboBox.setInputValue("");
return; return;
} }
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { if (value.startsWith(ADD_NEW_ID)) {
if (newValue !== this._value) { this.hass.loadFragmentTranslation("config");
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value; const suggestedName = value.substring(ADD_NEW_ID.length);
this.hass.loadFragmentTranslation("config"); showFloorRegistryDetailDialog(this, {
suggestedName: suggestedName,
showFloorRegistryDetailDialog(this, { createEntry: async (values, addedAreas) => {
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", try {
createEntry: async (values, addedAreas) => { const floor = await createFloorRegistryEntry(this.hass, values);
try { addedAreas.forEach((areaId) => {
const floor = await createFloorRegistryEntry(this.hass, values); updateAreaRegistryEntry(this.hass, areaId, {
addedAreas.forEach((areaId) => { floor_id: floor.floor_id,
updateAreaRegistryEntry(this.hass, areaId, { });
floor_id: floor.floor_id,
}); });
}); this._setValue(floor.floor_id);
const floors = [...Object.values(this.hass.floors), floor]; } catch (err: any) {
this.comboBox.filteredItems = this._getFloors( showAlertDialog(this, {
floors, title: this.hass.localize(
Object.values(this.hass.areas)!, "ui.components.floor-picker.failed_create_floor"
Object.values(this.hass.devices)!, ),
Object.values(this.hass.entities)!, text: err.message,
this.includeDomains, });
this.excludeDomains, }
this.includeDeviceClasses, },
this.deviceFilter, });
this.entityFilter, }
this.noAdd,
this.excludeFloors
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(floor.floor_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.floor-picker.failed_create_floor"
),
text: err.message,
});
}
},
});
this._suggestion = undefined; this._setValue(value);
this.comboBox.setInputValue("");
} }
private _setValue(value?: string) { private _setValue(value?: string) {
this.value = value; this.value = value;
setTimeout(() => { fireEvent(this, "value-changed", { value });
fireEvent(this, "value-changed", { value }); fireEvent(this, "change");
fireEvent(this, "change");
}, 0);
} }
} }

View File

@ -25,6 +25,7 @@ export interface DisplayItem {
value: string; value: string;
label: string; label: string;
description?: string; description?: string;
disableSorting?: boolean;
} }
export interface DisplayValue { export interface DisplayValue {
@ -50,6 +51,9 @@ export class HaItemDisplayEditor extends LitElement {
@property({ type: Boolean, attribute: "show-navigation-button" }) @property({ type: Boolean, attribute: "show-navigation-button" })
public showNavigationButton = false; public showNavigationButton = false;
@property({ type: Boolean, attribute: "dont-sort-visible" })
public dontSortVisible = false;
@property({ attribute: false }) @property({ attribute: false })
public value: DisplayValue = { public value: DisplayValue = {
order: [], order: [],
@ -122,9 +126,15 @@ export class HaItemDisplayEditor extends LitElement {
private _visibleItems = memoizeOne( private _visibleItems = memoizeOne(
(items: DisplayItem[], hidden: string[], order: string[]) => { (items: DisplayItem[], hidden: string[], order: string[]) => {
const compare = orderCompare(order); const compare = orderCompare(order);
return items
.filter((item) => !hidden.includes(item.value)) const visibleItems = items.filter((item) => !hidden.includes(item.value));
.sort((a, b) => compare(a.value, b.value)); if (this.dontSortVisible) {
return visibleItems;
}
return items.sort((a, b) =>
a.disableSorting && !b.disableSorting ? -1 : compare(a.value, b.value)
);
} }
); );
@ -160,7 +170,14 @@ export class HaItemDisplayEditor extends LitElement {
(item) => item.value, (item) => item.value,
(item: DisplayItem, _idx) => { (item: DisplayItem, _idx) => {
const isVisible = !this.value.hidden.includes(item.value); const isVisible = !this.value.hidden.includes(item.value);
const { label, value, description, icon, iconPath } = item; const {
label,
value,
description,
icon,
iconPath,
disableSorting,
} = item;
return html` return html`
<ha-md-list-item <ha-md-list-item
type=${ifDefined( type=${ifDefined(
@ -172,14 +189,14 @@ export class HaItemDisplayEditor extends LitElement {
.value=${value} .value=${value}
class=${classMap({ class=${classMap({
hidden: !isVisible, hidden: !isVisible,
draggable: isVisible, draggable: isVisible && !disableSorting,
})} })}
> >
<span slot="headline">${label}</span> <span slot="headline">${label}</span>
${description ${description
? html`<span slot="supporting-text">${description}</span>` ? html`<span slot="supporting-text">${description}</span>`
: nothing} : nothing}
${isVisible ${isVisible && !disableSorting
? html` ? html`
<ha-svg-icon <ha-svg-icon
class="handle" class="handle"

View File

@ -1,13 +1,11 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import { mdiLabel, mdiPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit"; import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
import type { import type {
DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
DeviceRegistryEntry, DeviceRegistryEntry,
@ -19,30 +17,19 @@ import {
createLabelRegistryEntry, createLabelRegistryEntry,
subscribeLabelRegistry, subscribeLabelRegistry,
} from "../data/label_registry"; } from "../data/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box"; import "./ha-generic-picker";
import type { HaComboBox } from "./ha-combo-box"; import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-combo-box-item"; import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-icon-button"; import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon"; import "./ha-svg-icon";
type ScorableLabelItem = ScorableTextItem & LabelRegistryEntry;
const ADD_NEW_ID = "___ADD_NEW___"; const ADD_NEW_ID = "___ADD_NEW___";
const NO_LABELS_ID = "___NO_LABELS___"; const NO_LABELS = "___NO_LABELS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
const rowRenderer: ComboBoxLitRenderer<LabelRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-combo-box-item>
`;
@customElement("ha-label-picker") @customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) { export class HaLabelPicker extends SubscribeMixin(LitElement) {
@ -101,24 +88,13 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _labels?: LabelRegistryEntry[]; @state() private _labels?: LabelRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox; @query("ha-generic-picker") private _picker?: HaGenericPicker;
private _suggestion?: string;
private _init = false;
public async open() { public async open() {
await this.updateComplete; await this.updateComplete;
await this.comboBox?.open(); await this._picker?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
} }
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] { protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
@ -129,20 +105,61 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
]; ];
} }
private _labelMap = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined
): Map<string, LabelRegistryEntry> => {
if (!labels) {
return new Map();
}
return new Map(labels.map((label) => [label.label_id, label]));
}
);
private _valueRenderer: PickerValueRenderer = (value) => {
const label = this._labelMap(this._labels).get(value);
if (!label) {
return html`
<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
return html`
${label.icon
? html`<ha-icon slot="start" .icon=${label.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>`}
<span slot="headline">${label.name}</span>
`;
};
private _getLabels = memoizeOne( private _getLabels = memoizeOne(
( (
labels: LabelRegistryEntry[], labels: LabelRegistryEntry[] | undefined,
areas: HomeAssistant["areas"], haAreas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[], haDevices: HomeAssistant["devices"],
entities: EntityRegistryDisplayEntry[], haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"], deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeLabels: this["excludeLabels"] excludeLabels: this["excludeLabels"]
): LabelRegistryEntry[] => { ): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) {
return [
{
id: NO_LABELS,
primary: this.hass.localize("ui.components.label-picker.no_labels"),
icon_path: mdiLabel,
},
];
}
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined; let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined;
@ -274,7 +291,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
if (areaIds) { if (areaIds) {
areaIds.forEach((areaId) => { areaIds.forEach((areaId) => {
const area = areas[areaId]; const area = haAreas[areaId];
area.labels.forEach((label) => usedLabels.add(label)); area.labels.forEach((label) => usedLabels.add(label));
}); });
} }
@ -291,192 +308,144 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
); );
} }
if (!outputLabels.length) { const items = outputLabels.map<PickerComboBoxItem>((label) => ({
outputLabels = [ id: label.label_id,
{ primary: label.name,
label_id: NO_LABELS_ID, icon: label.icon || undefined,
name: this.hass.localize("ui.components.label-picker.no_match"), icon_path: label.icon ? undefined : mdiLabel,
icon: null, sorting_label: label.name,
color: null, search_labels: [label.name, label.label_id, label.description].filter(
description: null, (v): v is string => Boolean(v)
created_at: 0, ),
modified_at: 0, }));
},
];
}
return noAdd return items;
? outputLabels
: [
...outputLabels,
{
label_id: ADD_NEW_ID,
name: this.hass.localize("ui.components.label-picker.add_new"),
icon: "mdi:plus",
color: null,
description: null,
created_at: 0,
modified_at: 0,
},
];
} }
); );
protected updated(changedProps: PropertyValues) { private _getItems = () =>
if ( this._getLabels(
(!this._init && this.hass && this._labels) || this._labels,
(this._init && changedProps.has("_opened") && this._opened) this.hass.areas,
) { this.hass.devices,
this._init = true; this.hass.entities,
const items = this._getLabels( this.includeDomains,
this._labels!, this.excludeDomains,
this.hass.areas, this.includeDeviceClasses,
Object.values(this.hass.devices), this.deviceFilter,
Object.values(this.hass.entities), this.entityFilter,
this.includeDomains, this.excludeLabels
this.excludeDomains, );
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
).map((label) => ({
...label,
strings: [label.label_id, label.name],
}));
this.comboBox.items = items; private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
this.comboBox.filteredItems = items; if (!labels) {
return [];
} }
} return [
...new Set(
labels
.map((label) => label.name.toLowerCase())
.filter(Boolean) as string[]
),
];
});
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (this.noAdd) {
return [];
}
const allLabelNames = this._allLabelNames(this._labels);
if (searchString && !allLabelNames.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.label-picker.add_new_sugestion",
{
name: searchString,
}
),
icon_path: mdiPlus,
},
];
}
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.label-picker.add_new"),
icon_path: mdiPlus,
},
];
};
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
return html` return html`
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.helper=${this.helper} .autofocus=${this.autofocus}
item-value-path="label_id" .label=${this.label}
item-id-path="label_id" .notFoundLabel=${this.hass.localize(
item-label-path="name" "ui.components.label-picker.no_match"
.value=${this._value} )}
.disabled=${this.disabled} .placeholder=${placeholder}
.required=${this.required} .value=${this.value}
.label=${this.label === undefined && this.hass .getItems=${this._getItems}
? this.hass.localize("ui.components.label-picker.label") .getAdditionalItems=${this._getAdditionalItems}
: this.label} .valueRenderer=${this._valueRenderer}
.placeholder=${this.placeholder @value-changed=${this._valueChanged}
? this._labels?.find((label) => label.label_id === this.placeholder)
?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._labelChanged}
> >
</ha-combo-box> </ha-generic-picker>
`; `;
} }
private _filterChanged(ev: CustomEvent): void { private _valueChanged(ev: ValueChangedEvent<string>) {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableLabelItem>(
filterString,
target.items?.filter(
(item) => ![NO_LABELS_ID, ADD_NEW_ID].includes(item.label_id)
) || []
);
if (filteredItems.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{
label_id: NO_LABELS_ID,
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
color: null,
},
] as ScorableLabelItem[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
label_id: ADD_NEW_SUGGESTION_ID,
name: this.hass.localize(
"ui.components.label-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
color: null,
},
] as ScorableLabelItem[];
}
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === NO_LABELS_ID) { const value = ev.detail.value;
newValue = "";
this.comboBox.setInputValue(""); if (value === NO_LABELS) {
return; return;
} }
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { if (!value) {
if (newValue !== this._value) { this._setValue(undefined);
this._setValue(newValue);
}
return; return;
} }
(ev.target as any).value = this._value; if (value.startsWith(ADD_NEW_ID)) {
this.hass.loadFragmentTranslation("config");
this.hass.loadFragmentTranslation("config"); const suggestedName = value.substring(ADD_NEW_ID.length);
showLabelDetailDialog(this, { showLabelDetailDialog(this, {
entry: undefined, suggestedName: suggestedName,
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", createEntry: async (values) => {
createEntry: async (values) => { try {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
const labels = [...this._labels!, label]; this._setValue(label.label_id);
this.comboBox.filteredItems = this._getLabels( } catch (err: any) {
labels, showAlertDialog(this, {
this.hass.areas!, title: this.hass.localize(
Object.values(this.hass.devices)!, "ui.components.label-picker.failed_create_label"
Object.values(this.hass.entities)!, ),
this.includeDomains, text: err.message,
this.excludeDomains, });
this.includeDeviceClasses, }
this.deviceFilter, },
this.entityFilter, });
this.noAdd, return;
this.excludeLabels }
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(label.label_id);
return label;
},
});
this._suggestion = undefined; this._setValue(value);
this.comboBox.setInputValue("");
} }
private _setValue(value?: string) { private _setValue(value?: string) {

View File

@ -122,6 +122,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
this.hass.locale.language this.hass.locale.language
); );
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${labels?.length ${labels?.length
? html`<ha-chip-set> ? html`<ha-chip-set>
${repeat( ${repeat(
@ -157,9 +158,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.add_label")
: this.label}
.placeholder=${this.placeholder} .placeholder=${this.placeholder}
.excludeLabels=${this.value} .excludeLabels=${this.value}
@value-changed=${this._labelChanged} @value-changed=${this._labelChanged}
@ -182,12 +180,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
showLabelDetailDialog(this, { showLabelDetailDialog(this, {
entry: label, entry: label,
updateEntry: async (values) => { updateEntry: async (values) => {
const updated = await updateLabelRegistryEntry( await updateLabelRegistryEntry(this.hass, label.label_id, values);
this.hass,
label.label_id,
values
);
return updated;
}, },
}); });
} }
@ -219,6 +212,10 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
--ha-input-chip-selected-container-opacity: 0.5; --ha-input-chip-selected-container-opacity: 0.5;
--md-input-chip-selected-outline-width: 1px; --md-input-chip-selected-outline-width: 1px;
} }
label {
display: block;
margin: 0 0 8px;
}
`; `;
} }

View File

@ -168,10 +168,10 @@ export class HaMdDialog extends Dialog {
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
:host(:not([type="alert"])) { :host(:not([type="alert"])) {
min-width: calc( min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
max-width: calc( max-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
min-height: 100%; min-height: 100%;
max-height: 100%; max-height: 100%;

View File

@ -212,6 +212,10 @@ export class HaPickerComboBox extends LitElement {
this.comboBox.setTextFieldValue(""); this.comboBox.setTextFieldValue("");
const newValue = ev.detail.value?.trim(); const newValue = ev.detail.value?.trim();
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
return;
}
if (newValue !== this._value) { if (newValue !== this._value) {
this._setValue(newValue); this._setValue(newValue);
} }

View File

@ -1,11 +1,9 @@
import "@material/mwc-button/mwc-button";
import { import {
mdiBell, mdiBell,
mdiCalendar, mdiCalendar,
mdiCellphoneCog, mdiCellphoneCog,
mdiChartBox, mdiChartBox,
mdiClipboardList, mdiClipboardList,
mdiClose,
mdiCog, mdiCog,
mdiFormatListBulletedType, mdiFormatListBulletedType,
mdiHammer, mdiHammer,
@ -13,12 +11,11 @@ import {
mdiMenu, mdiMenu,
mdiMenuOpen, mdiMenuOpen,
mdiPlayBoxMultiple, mdiPlayBoxMultiple,
mdiPlus,
mdiTooltipAccount, mdiTooltipAccount,
mdiViewDashboard, mdiViewDashboard,
} from "@mdi/js"; } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResult, CSSResultGroup, PropertyValues } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { import {
customElement, customElement,
@ -29,7 +26,6 @@ import {
} from "lit/decorators"; } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { storage } from "../common/decorators/storage";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { toggleAttribute } from "../common/dom/toggle_attribute"; import { toggleAttribute } from "../common/dom/toggle_attribute";
import { stringCompare } from "../common/string/compare"; import { stringCompare } from "../common/string/compare";
@ -40,6 +36,7 @@ import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs"; import { subscribeRepairsIssueRegistry } from "../data/repairs";
import type { UpdateEntity } from "../data/update"; import type { UpdateEntity } from "../data/update";
import { updateCanInstall } from "../data/update"; import { updateCanInstall } from "../data/update";
import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
@ -49,8 +46,6 @@ import "./ha-icon-button";
import "./ha-md-list"; import "./ha-md-list";
import "./ha-md-list-item"; import "./ha-md-list-item";
import type { HaMdListItem } from "./ha-md-list-item"; import type { HaMdListItem } from "./ha-md-list-item";
import "./ha-menu-button";
import "./ha-sortable";
import "./ha-svg-icon"; import "./ha-svg-icon";
import "./user/ha-user-badge"; import "./user/ha-user-badge";
@ -67,7 +62,7 @@ const SORT_VALUE_URL_PATHS = {
config: 11, config: 11,
}; };
const PANEL_ICONS = { export const PANEL_ICONS = {
calendar: mdiCalendar, calendar: mdiCalendar,
"developer-tools": mdiHammer, "developer-tools": mdiHammer,
energy: mdiLightningBolt, energy: mdiLightningBolt,
@ -140,7 +135,7 @@ const defaultPanelSorter = (
return stringCompare(a.title!, b.title!, language); return stringCompare(a.title!, b.title!, language);
}; };
const computePanels = memoizeOne( export const computePanels = memoizeOne(
( (
panels: HomeAssistant["panels"], panels: HomeAssistant["panels"],
defaultPanel: HomeAssistant["defaultPanel"], defaultPanel: HomeAssistant["defaultPanel"],
@ -192,8 +187,11 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@property({ attribute: "always-expand", type: Boolean }) @property({ attribute: "always-expand", type: Boolean })
public alwaysExpand = false; public alwaysExpand = false;
@property({ attribute: "edit-mode", type: Boolean }) @property({ attribute: false })
public editMode = false; public panelOrder!: string[];
@property({ attribute: false })
public hiddenPanels!: string[];
@state() private _notifications?: PersistentNotification[]; @state() private _notifications?: PersistentNotification[];
@ -207,26 +205,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private _recentKeydownActiveUntil = 0; private _recentKeydownActiveUntil = 0;
private _editStyleLoaded = false;
private _unsubPersistentNotifications: UnsubscribeFunc | undefined; private _unsubPersistentNotifications: UnsubscribeFunc | undefined;
@state()
@storage({
key: "sidebarPanelOrder",
state: true,
subscribe: true,
})
private _panelOrder: string[] = [];
@state()
@storage({
key: "sidebarHiddenPanels",
state: true,
subscribe: true,
})
private _hiddenPanels: string[] = [];
@query(".tooltip") private _tooltip!: HTMLDivElement; @query(".tooltip") private _tooltip!: HTMLDivElement;
public hassSubscribe(): UnsubscribeFunc[] { public hassSubscribe(): UnsubscribeFunc[] {
@ -270,13 +250,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
changedProps.has("expanded") || changedProps.has("expanded") ||
changedProps.has("narrow") || changedProps.has("narrow") ||
changedProps.has("alwaysExpand") || changedProps.has("alwaysExpand") ||
changedProps.has("editMode") ||
changedProps.has("_externalConfig") || changedProps.has("_externalConfig") ||
changedProps.has("_updatesCount") || changedProps.has("_updatesCount") ||
changedProps.has("_issuesCount") || changedProps.has("_issuesCount") ||
changedProps.has("_notifications") || changedProps.has("_notifications") ||
changedProps.has("_hiddenPanels") || changedProps.has("hiddenPanels") ||
changedProps.has("_panelOrder") changedProps.has("panelOrder")
) { ) {
return true; return true;
} }
@ -322,9 +301,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
if (changedProps.has("alwaysExpand")) { if (changedProps.has("alwaysExpand")) {
toggleAttribute(this, "expanded", this.alwaysExpand); toggleAttribute(this, "expanded", this.alwaysExpand);
} }
if (changedProps.has("editMode") && this.editMode) {
this._editModeActivated();
}
if (!changedProps.has("hass")) { if (!changedProps.has("hass")) {
return; return;
} }
@ -374,8 +350,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
class="menu" class="menu"
@action=${this._handleAction} @action=${this._handleAction}
.actionHandler=${actionHandler({ .actionHandler=${actionHandler({
hasHold: !this.editMode, hasHold: true,
disabled: this.editMode,
})} })}
> >
${!this.narrow ${!this.narrow
@ -389,11 +364,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
></ha-icon-button> ></ha-icon-button>
` `
: ""} : ""}
${this.editMode <div class="title">Home Assistant</div>
? html`<mwc-button outlined @click=${this._closeEditMode}>
${this.hass.localize("ui.sidebar.done")}
</mwc-button>`
: html`<div class="title">Home Assistant</div>`}
</div>`; </div>`;
} }
@ -401,14 +372,13 @@ class HaSidebar extends SubscribeMixin(LitElement) {
const [beforeSpacer, afterSpacer] = computePanels( const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels, this.hass.panels,
this.hass.defaultPanel, this.hass.defaultPanel,
this._panelOrder, this.panelOrder,
this._hiddenPanels, this.hiddenPanels,
this.hass.locale this.hass.locale
); );
// prettier-ignore // prettier-ignore
return html` return html`
<ha-sortable .disabled=${!this.editMode} draggable-selector=".draggable" @item-moved=${this._panelMoved}>
<ha-md-list <ha-md-list
class="ha-scrollbar" class="ha-scrollbar"
@focusin=${this._listboxFocusIn} @focusin=${this._listboxFocusIn}
@ -416,22 +386,15 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll} @scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown} @keydown=${this._listboxKeydown}
> >
${this.editMode ${this._renderPanels(beforeSpacer, selectedPanel)}
? this._renderPanelsEdit(beforeSpacer, selectedPanel)
: this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()} ${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel)} ${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderExternalConfiguration()} ${this._renderExternalConfiguration()}
</ha-md-list> </ha-md-list>
</ha-sortable>
`; `;
} }
private _renderPanels( private _renderPanels(panels: PanelInfo[], selectedPanel: string) {
panels: PanelInfo[],
selectedPanel: string,
sortable = false
) {
return panels.map((panel) => return panels.map((panel) =>
this._renderPanel( this._renderPanel(
panel.url_path, panel.url_path,
@ -444,36 +407,26 @@ class HaSidebar extends SubscribeMixin(LitElement) {
: panel.url_path in PANEL_ICONS : panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path] ? PANEL_ICONS[panel.url_path]
: undefined, : undefined,
selectedPanel, selectedPanel
sortable
) )
); );
} }
private _renderPanelsEdit(beforeSpacer: PanelInfo[], selectedPanel: string) {
return html`
${this._renderPanels(beforeSpacer, selectedPanel, true)}
${this._renderSpacer()}${this._renderHiddenPanels()}
`;
}
private _renderPanel( private _renderPanel(
urlPath: string, urlPath: string,
title: string | null, title: string | null,
icon: string | null | undefined, icon: string | null | undefined,
iconPath: string | null | undefined, iconPath: string | null | undefined,
selectedPanel: string, selectedPanel: string
sortable = false
) { ) {
return urlPath === "config" return urlPath === "config"
? this._renderConfiguration(title, selectedPanel) ? this._renderConfiguration(title, selectedPanel)
: html` : html`
<ha-md-list-item <ha-md-list-item
.href=${this.editMode ? undefined : `/${urlPath}`} .href=${`/${urlPath}`}
type="link" type="link"
class=${classMap({ class=${classMap({
selected: selectedPanel === urlPath, selected: selectedPanel === urlPath,
draggable: this.editMode && sortable,
})} })}
@mouseenter=${this._itemMouseEnter} @mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave} @mouseleave=${this._itemMouseLeave}
@ -482,81 +435,10 @@ class HaSidebar extends SubscribeMixin(LitElement) {
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>` ? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`} : html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span> <span class="item-text" slot="headline">${title}</span>
${this.editMode
? html`<ha-icon-button
.label=${this.hass.localize("ui.sidebar.hide_panel")}
.path=${mdiClose}
class="hide-panel"
.panel=${urlPath}
@click=${this._hidePanel}
slot="end"
></ha-icon-button>`
: nothing}
</ha-md-list-item> </ha-md-list-item>
`; `;
} }
private _panelMoved(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const [beforeSpacer] = computePanels(
this.hass.panels,
this.hass.defaultPanel,
this._panelOrder,
this._hiddenPanels,
this.hass.locale
);
const panelOrder = beforeSpacer.map((panel) => panel.url_path);
const panel = panelOrder.splice(oldIndex, 1)[0];
panelOrder.splice(newIndex, 0, panel);
this._panelOrder = panelOrder;
}
private _renderHiddenPanels() {
return html`${this._hiddenPanels.length
? html`${this._hiddenPanels.map((url) => {
const panel = this.hass.panels[url];
if (!panel) {
return "";
}
return html`<ha-md-list-item
@click=${this._unhidePanel}
class="hidden-panel"
.panel=${url}
type="button"
>
${panel.url_path === this.hass.defaultPanel && !panel.icon
? html`<ha-svg-icon
slot="start"
.path=${PANEL_ICONS.lovelace}
></ha-svg-icon>`
: panel.url_path in PANEL_ICONS
? html`<ha-svg-icon
slot="start"
.path=${PANEL_ICONS[panel.url_path]}
></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${panel.icon}></ha-icon>`}
<span class="item-text" slot="headline"
>${panel.url_path === this.hass.defaultPanel
? this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) ||
panel.title}</span
>
<ha-icon-button
.label=${this.hass.localize("ui.sidebar.show_panel")}
.path=${mdiPlus}
class="show-panel"
slot="end"
></ha-icon-button>
</ha-md-list-item>`;
})}
${this._renderSpacer()}`
: ""}`;
}
private _renderDivider() { private _renderDivider() {
return html`<div class="divider"></div>`; return html`<div class="divider"></div>`;
} }
@ -677,48 +559,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return; return;
} }
fireEvent(this, "hass-edit-sidebar", { editMode: true }); showEditSidebarDialog(this, {
saveCallback: this._saveSidebar,
});
} }
private async _editModeActivated() { private _saveSidebar = (order: string[], hidden: string[]) => {
await this._loadEditStyle(); fireEvent(this, "hass-edit-sidebar", {
} order,
hidden,
private async _loadEditStyle() { });
if (this._editStyleLoaded) return; };
const editStylesImport = await import("../resources/ha-sidebar-edit-style");
const style = document.createElement("style");
style.innerHTML = (editStylesImport.sidebarEditStyle as CSSResult).cssText;
this.shadowRoot!.appendChild(style);
await this.updateComplete;
}
private _closeEditMode() {
fireEvent(this, "hass-edit-sidebar", { editMode: false });
}
private async _hidePanel(ev: Event) {
ev.preventDefault();
const panel = (ev.currentTarget as any).panel;
if (this._hiddenPanels.includes(panel)) {
return;
}
// Make a copy for Memoize
this._hiddenPanels = [...this._hiddenPanels, panel];
// Remove it from the panel order
this._panelOrder = this._panelOrder.filter((order) => order !== panel);
}
private async _unhidePanel(ev: Event) {
ev.preventDefault();
const panel = (ev.currentTarget as any).panel;
this._hiddenPanels = this._hiddenPanels.filter(
(hidden) => hidden !== panel
);
}
private _itemMouseEnter(ev: MouseEvent) { private _itemMouseEnter(ev: MouseEvent) {
// On keypresses on the listbox, we're going to ignore mouse enter events // On keypresses on the listbox, we're going to ignore mouse enter events
@ -851,12 +702,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
); );
font-size: var(--ha-font-size-xl); font-size: var(--ha-font-size-xl);
align-items: center; align-items: center;
padding-left: calc(4px + env(safe-area-inset-left)); padding-left: calc(4px + var(--safe-area-inset-left));
padding-inline-start: calc(4px + env(safe-area-inset-left)); padding-inline-start: calc(4px + var(--safe-area-inset-left));
padding-inline-end: initial; padding-inline-end: initial;
} }
:host([expanded]) .menu { :host([expanded]) .menu {
width: calc(256px + env(safe-area-inset-left)); width: calc(256px + var(--safe-area-inset-left));
} }
.menu ha-icon-button { .menu ha-icon-button {
color: var(--sidebar-icon-color); color: var(--sidebar-icon-color);
@ -875,12 +726,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
:host([expanded]) .title { :host([expanded]) .title {
display: initial; display: initial;
} }
:host([expanded]) .menu mwc-button {
margin: 0 8px;
}
.menu mwc-button {
width: 100%;
}
.hidden-panel { .hidden-panel {
display: none; display: none;
} }
@ -890,11 +735,11 @@ class HaSidebar extends SubscribeMixin(LitElement) {
box-sizing: border-box; box-sizing: border-box;
height: calc(100% - var(--header-height) - 132px); height: calc(100% - var(--header-height) - 132px);
height: calc( height: calc(
100% - var(--header-height) - 132px - env(safe-area-inset-bottom) 100% - var(--header-height) - 132px - var(--safe-area-inset-bottom)
); );
overflow-x: hidden; overflow-x: hidden;
background: none; background: none;
margin-left: env(safe-area-inset-left); margin-left: var(--safe-area-inset-left);
} }
ha-md-list-item { ha-md-list-item {
@ -914,7 +759,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
} }
:host([expanded]) ha-md-list-item { :host([expanded]) ha-md-list-item {
width: 248px; width: 248px;
width: calc(248px - env(safe-area-inset-left)); width: calc(248px - var(--safe-area-inset-left));
} }
ha-md-list-item.selected { ha-md-list-item.selected {
@ -949,7 +794,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
} }
ha-md-list-item .item-text { ha-md-list-item .item-text {
font-family: var(--ha-font-family-body);
display: none; display: none;
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);

View File

@ -419,7 +419,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
id="input" id="input"
.type=${"device_id"} .type=${"device_id"}
.label=${this.hass.localize( .placeholder=${this.hass.localize(
"ui.components.target-picker.add_device_id"
)}
.searchLabel=${this.hass.localize(
"ui.components.target-picker.add_device_id" "ui.components.target-picker.add_device_id"
)} )}
.deviceFilter=${this.deviceFilter} .deviceFilter=${this.deviceFilter}
@ -438,7 +441,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
id="input" id="input"
.type=${"label_id"} .type=${"label_id"}
.label=${this.hass.localize( .placeholder=${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}
.searchLabel=${this.hass.localize(
"ui.components.target-picker.add_label_id" "ui.components.target-picker.add_label_id"
)} )}
no-add no-add

View File

@ -28,22 +28,30 @@ export class HaTimeInput extends LitElement {
protected render() { protected render() {
const useAMPM = useAmPm(this.locale); const useAMPM = useAmPm(this.locale);
const parts = this.value?.split(":") || []; let hours = NaN;
let hours = parts[0]; let minutes = NaN;
const numberHours = Number(parts[0]); let seconds = NaN;
if (numberHours && useAMPM && numberHours > 12 && numberHours < 24) { let numberHours = 0;
hours = String(numberHours - 12).padStart(2, "0"); if (this.value) {
} const parts = this.value?.split(":") || [];
if (useAMPM && numberHours === 0) { minutes = parts[1] ? Number(parts[1]) : 0;
hours = "12"; seconds = parts[2] ? Number(parts[2]) : 0;
hours = parts[0] ? Number(parts[0]) : 0;
numberHours = hours;
if (numberHours && useAMPM && numberHours > 12 && numberHours < 24) {
hours = numberHours - 12;
}
if (useAMPM && numberHours === 0) {
hours = 12;
}
} }
return html` return html`
<ha-base-time-input <ha-base-time-input
.label=${this.label} .label=${this.label}
.hours=${Number(hours)} .hours=${hours}
.minutes=${Number(parts[1])} .minutes=${minutes}
.seconds=${Number(parts[2])} .seconds=${seconds}
.format=${useAMPM ? 12 : 24} .format=${useAMPM ? 12 : 24}
.amPm=${useAMPM && numberHours >= 12 ? "PM" : "AM"} .amPm=${useAMPM && numberHours >= 12 ? "PM" : "AM"}
.disabled=${this.disabled} .disabled=${this.disabled}
@ -52,6 +60,11 @@ export class HaTimeInput extends LitElement {
.required=${this.required} .required=${this.required}
.clearable=${this.clearable && this.value !== undefined} .clearable=${this.clearable && this.value !== undefined}
.helper=${this.helper} .helper=${this.helper}
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
></ha-base-time-input> ></ha-base-time-input>
`; `;
} }

View File

@ -14,9 +14,9 @@ export class HaToast extends Snackbar {
.mdc-snackbar { .mdc-snackbar {
margin: 8px; margin: 8px;
right: calc(8px + env(safe-area-inset-right)); right: calc(8px + var(--safe-area-inset-right));
bottom: calc(8px + env(safe-area-inset-bottom)); bottom: calc(8px + var(--safe-area-inset-bottom));
left: calc(8px + env(safe-area-inset-left)); left: calc(8px + var(--safe-area-inset-left));
} }
.mdc-snackbar__surface { .mdc-snackbar__surface {
@ -37,9 +37,9 @@ export class HaToast extends Snackbar {
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
.mdc-snackbar { .mdc-snackbar {
right: env(safe-area-inset-right); right: var(--safe-area-inset-right);
bottom: env(safe-area-inset-bottom); bottom: var(--safe-area-inset-bottom);
left: env(safe-area-inset-left); left: var(--safe-area-inset-left);
} }
.mdc-snackbar__surface { .mdc-snackbar__surface {
min-width: 100%; min-width: 100%;

View File

@ -214,6 +214,7 @@ class BrowseMediaTTS extends LitElement {
item.media_content_id = `${ item.media_content_id = `${
item.media_content_id.split("?")[0] item.media_content_id.split("?")[0]
}?${query.toString()}`; }?${query.toString()}`;
item.media_content_type = "audio/mp3";
item.can_play = true; item.can_play = true;
item.title = message; item.title = message;
fireEvent(this, "tts-picked", { item }); fireEvent(this, "tts-picked", { item });

View File

@ -1,21 +1,30 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { html, LitElement, nothing } from "lit";
import { property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { User } from "../../data/user"; import type { User } from "../../data/user";
import { fetchUsers } from "../../data/user"; import { fetchUsers } from "../../data/user";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-select"; import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "./ha-user-badge"; import "./ha-user-badge";
import "../ha-list-item";
interface UserComboBoxItem extends PickerComboBoxItem {
user?: User;
}
@customElement("ha-user-picker")
class HaUserPicker extends LitElement { class HaUserPicker extends LitElement {
public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string; @property() public label?: string;
@property() public placeholder?: string;
@property({ attribute: false }) public noUserLabel?: string; @property({ attribute: false }) public noUserLabel?: string;
@property() public value = ""; @property() public value = "";
@ -24,78 +33,124 @@ class HaUserPicker extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
private _sortedUsers = memoizeOne((users?: User[]) => { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (!this.users) {
this._fetchUsers();
}
}
private async _fetchUsers() {
this.users = await fetchUsers(this.hass);
}
private usersMap = memoizeOne((users?: User[]): Map<string, User> => {
if (!users) {
return new Map();
}
return new Map(users.map((user) => [user.id, user]));
});
private _valueRenderer: PickerValueRenderer = (value) => {
const user = this.usersMap(this.users).get(value);
if (!user) {
return html` <span slot="headline">${value}</span> `;
}
return html`
<ha-user-badge
slot="start"
.hass=${this.hass}
.user=${user}
></ha-user-badge>
<span slot="headline">${user.name}</span>
`;
};
private _rowRenderer: ComboBoxLitRenderer<UserComboBoxItem> = (item) => {
const user = item.user;
if (!user) {
return html`<ha-combo-box-item type="button" compact>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>`;
}
return html`
<ha-combo-box-item type="button" compact>
<ha-user-badge
slot="start"
.hass=${this.hass}
.user=${item.user}
></ha-user-badge>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
};
private _getUsers = memoizeOne((users?: User[]) => {
if (!users) { if (!users) {
return []; return [];
} }
return users return users
.filter((user) => !user.system_generated) .filter((user) => !user.system_generated)
.sort((a, b) => .map<UserComboBoxItem>((user) => ({
stringCompare(a.name, b.name, this.hass!.locale.language) id: user.id,
); primary: user.name,
domain_name: user.name,
search_labels: [user.name, user.id, user.username].filter(
Boolean
) as string[],
sorting_label: user.name,
user,
}));
}); });
private _getItems = () => this._getUsers(this.users);
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.user-picker.user");
return html` return html`
<ha-select <ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.disabled=${this.disabled} .notFoundLabel=${this.hass.localize(
.value=${this.value} "ui.components.user-picker.no_match"
@selected=${this._userChanged}
>
${this.users?.length === 0
? html`<ha-list-item value="">
${this.noUserLabel ||
this.hass?.localize("ui.components.user-picker.no_user")}
</ha-list-item>`
: ""}
${this._sortedUsers(this.users).map(
(user) => html`
<ha-list-item graphic="avatar" .value=${user.id}>
<ha-user-badge
.hass=${this.hass}
.user=${user}
slot="graphic"
></ha-user-badge>
${user.name}
</ha-list-item>
`
)} )}
</ha-select> .placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`; `;
} }
protected firstUpdated(changedProps) { private _valueChanged(ev) {
super.firstUpdated(changedProps); const value = ev.detail.value;
if (this.users === undefined) {
fetchUsers(this.hass!).then((users) => { this.value = value;
this.users = users; fireEvent(this, "value-changed", { value });
}); fireEvent(this, "change");
}
} }
private _userChanged(ev) {
const newValue = ev.target.value;
if (newValue !== this.value) {
this.value = newValue;
setTimeout(() => {
fireEvent(this, "value-changed", { value: newValue });
fireEvent(this, "change");
}, 0);
}
}
static styles = css`
:host {
display: inline-block;
}
`;
} }
customElements.define("ha-user-picker", HaUserPicker);
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ha-user-picker": HaUserPicker; "ha-user-picker": HaUserPicker;

View File

@ -1,4 +1,3 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { guard } from "lit/directives/guard"; import { guard } from "lit/directives/guard";
@ -6,13 +5,15 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { User } from "../../data/user"; import type { User } from "../../data/user";
import { fetchUsers } from "../../data/user"; import { fetchUsers } from "../../data/user";
import type { ValueChangedEvent, HomeAssistant } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-icon-button"; import "../ha-icon-button";
import "./ha-user-picker"; import "./ha-user-picker";
@customElement("ha-users-picker") @customElement("ha-users-picker")
class HaUsersPickerLight extends LitElement { class HaUsersPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[]; @property({ attribute: false }) public value?: string[];
@ -29,13 +30,15 @@ class HaUsersPickerLight extends LitElement {
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
if (this.users === undefined) { if (!this.users) {
fetchUsers(this.hass!).then((users) => { this._fetchUsers();
this.users = users;
});
} }
} }
private async _fetchUsers() {
this.users = await fetchUsers(this.hass);
}
protected render() { protected render() {
if (!this.hass || !this.users) { if (!this.hass || !this.users) {
return nothing; return nothing;
@ -43,15 +46,13 @@ class HaUsersPickerLight extends LitElement {
const notSelectedUsers = this._notSelectedUsers(this.users, this.value); const notSelectedUsers = this._notSelectedUsers(this.users, this.value);
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${guard([notSelectedUsers], () => ${guard([notSelectedUsers], () =>
this.value?.map( this.value?.map(
(user_id, idx) => html` (user_id, idx) => html`
<div> <div>
<ha-user-picker <ha-user-picker
.label=${this.pickedUserLabel} .placeholder=${this.pickedUserLabel}
.noUserLabel=${this.hass!.localize(
"ui.components.user-picker.remove_user"
)}
.index=${idx} .index=${idx}
.hass=${this.hass} .hass=${this.hass}
.value=${user_id} .value=${user_id}
@ -63,28 +64,20 @@ class HaUsersPickerLight extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
@value-changed=${this._userChanged} @value-changed=${this._userChanged}
></ha-user-picker> ></ha-user-picker>
<ha-icon-button
.userId=${user_id}
.label=${this.hass!.localize(
"ui.components.user-picker.remove_user"
)}
.path=${mdiClose}
@click=${this._removeUser}
>
></ha-icon-button
>
</div> </div>
` `
) )
)} )}
<ha-user-picker <div>
.label=${this.pickUserLabel || <ha-user-picker
this.hass!.localize("ui.components.user-picker.add_user")} .placeholder=${this.pickUserLabel ||
.hass=${this.hass} this.hass!.localize("ui.components.user-picker.add_user")}
.users=${notSelectedUsers} .hass=${this.hass}
.disabled=${this.disabled || !notSelectedUsers?.length} .users=${notSelectedUsers}
@value-changed=${this._addUser} .disabled=${this.disabled || !notSelectedUsers?.length}
></ha-user-picker> @value-changed=${this._addUser}
></ha-user-picker>
</div>
`; `;
} }
@ -120,12 +113,12 @@ class HaUsersPickerLight extends LitElement {
}); });
} }
private _userChanged(event: ValueChangedEvent<string>) { private _userChanged(ev: ValueChangedEvent<string | undefined>) {
event.stopPropagation(); ev.stopPropagation();
const index = (event.currentTarget as any).index; const index = (ev.currentTarget as any).index;
const newValue = event.detail.value; const newValue = ev.detail.value;
const newUsers = [...this._currentUsers]; const newUsers = [...this._currentUsers];
if (newValue === "") { if (!newValue) {
newUsers.splice(index, 1); newUsers.splice(index, 1);
} else { } else {
newUsers.splice(index, 1, newValue); newUsers.splice(index, 1, newValue);
@ -148,24 +141,15 @@ class HaUsersPickerLight extends LitElement {
this._updateUsers([...currentUsers, toAdd]); this._updateUsers([...currentUsers, toAdd]);
} }
private _removeUser(event) { static override styles = css`
const userId = (event.currentTarget as any).userId;
this._updateUsers(this._currentUsers.filter((user) => user !== userId));
}
static styles = css`
:host {
display: block;
}
div { div {
display: flex; margin-top: 8px;
align-items: center;
} }
`; `;
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ha-users-picker": HaUsersPickerLight; "ha-users-picker": HaUsersPicker;
} }
} }

View File

@ -1,6 +1,5 @@
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { ConversationResult } from "./conversation"; import type { ConversationResult } from "./conversation";
import type { ResolvedMediaSource } from "./media_source";
import type { SpeechMetadata } from "./stt"; import type { SpeechMetadata } from "./stt";
export interface AssistPipeline { export interface AssistPipeline {
@ -53,10 +52,16 @@ interface PipelineRunStartEvent extends PipelineEventBase {
data: { data: {
pipeline: string; pipeline: string;
language: string; language: string;
conversation_id: string;
runner_data: { runner_data: {
stt_binary_handler_id: number | null; stt_binary_handler_id: number | null;
timeout: number; timeout: number;
}; };
tts_output?: {
token: string;
url: string;
mime_type: string;
};
}; };
} }
interface PipelineRunEndEvent extends PipelineEventBase { interface PipelineRunEndEvent extends PipelineEventBase {
@ -109,7 +114,7 @@ interface PipelineIntentStartEvent extends PipelineEventBase {
}; };
} }
interface ConversationChatLogAssistantDelta { export interface ConversationChatLogAssistantDelta {
role: "assistant"; role: "assistant";
content: string; content: string;
tool_calls: { tool_calls: {
@ -119,7 +124,7 @@ interface ConversationChatLogAssistantDelta {
}[]; }[];
} }
interface ConversationChatLogToolResultDelta { export interface ConversationChatLogToolResultDelta {
role: "tool_result"; role: "tool_result";
agent_id: string; agent_id: string;
tool_call_id: string; tool_call_id: string;
@ -156,7 +161,12 @@ interface PipelineTTSStartEvent extends PipelineEventBase {
interface PipelineTTSEndEvent extends PipelineEventBase { interface PipelineTTSEndEvent extends PipelineEventBase {
type: "tts-end"; type: "tts-end";
data: { data: {
tts_output: ResolvedMediaSource; tts_output: {
media_id: string;
token: string;
url: string;
mime_type: string;
};
}; };
} }

View File

@ -471,8 +471,8 @@ class MoreInfoUpdate extends LitElement {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
margin: 0 -24px 0 -24px; margin: 0 -24px 0 -24px;
margin-bottom: calc(-1 * max(env(safe-area-inset-bottom), 24px)); margin-bottom: calc(-1 * max(var(--safe-area-inset-bottom), 24px));
padding-bottom: env(safe-area-inset-bottom); padding-bottom: var(--safe-area-inset-bottom);
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -128,7 +128,7 @@ export class MoreInfoInfo extends LitElement {
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
padding: 24px; padding: 24px;
padding-bottom: max(env(safe-area-inset-bottom), 24px); padding-bottom: max(var(--safe-area-inset-bottom), 24px);
} }
[data-domain="camera"] .content { [data-domain="camera"] .content {

View File

@ -159,11 +159,11 @@ export class HuiNotificationDrawer extends LitElement {
.notifications { .notifications {
overflow-y: auto; overflow-y: auto;
padding-top: 16px; padding-top: 16px;
padding-left: env(safe-area-inset-left); padding-left: var(--safe-area-inset-left);
padding-right: env(safe-area-inset-right); padding-right: var(--safe-area-inset-right);
padding-inline-start: env(safe-area-inset-left); padding-inline-start: var(--safe-area-inset-left);
padding-inline-end: env(safe-area-inset-right); padding-inline-end: var(--safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom); padding-bottom: var(--safe-area-inset-bottom);
height: calc(100% - 1px - var(--header-height)); height: calc(100% - 1px - var(--header-height));
box-sizing: border-box; box-sizing: border-box;
background-color: var(--primary-background-color); background-color: var(--primary-background-color);

View File

@ -28,6 +28,7 @@ import {
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name"; import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context"; import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { navigate } from "../../common/navigate"; import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare"; import { caseInsensitiveStringCompare } from "../../common/string/compare";
@ -41,6 +42,7 @@ import "../../components/ha-md-list-item";
import "../../components/ha-spinner"; import "../../components/ha-spinner";
import "../../components/ha-textfield"; import "../../components/ha-textfield";
import "../../components/ha-tip"; import "../../components/ha-tip";
import { getConfigEntries } from "../../data/config_entries";
import { fetchHassioAddonsInfo } from "../../data/hassio/addon"; import { fetchHassioAddonsInfo } from "../../data/hassio/addon";
import { domainToName } from "../../data/integration"; import { domainToName } from "../../data/integration";
import { getPanelNameTranslationKey } from "../../data/panel"; import { getPanelNameTranslationKey } from "../../data/panel";
@ -50,6 +52,7 @@ import { HaFuse } from "../../resources/fuse";
import { haStyleDialog, haStyleScrollbar } from "../../resources/styles"; import { haStyleDialog, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer"; import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { showConfirmationDialog } from "../generic/show-dialog-box"; import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog"; import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar"; import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar";
@ -75,6 +78,8 @@ interface EntityItem extends QuickBarItem {
interface DeviceItem extends QuickBarItem { interface DeviceItem extends QuickBarItem {
deviceId: string; deviceId: string;
domain?: string;
translatedDomain?: string;
area?: string; area?: string;
} }
@ -297,7 +302,8 @@ export class QuickBar extends LitElement {
this._commandItems = this._commandItems =
this._commandItems || (await this._generateCommandItems()); this._commandItems || (await this._generateCommandItems());
} else if (this._mode === QuickBarMode.Device) { } else if (this._mode === QuickBarMode.Device) {
this._deviceItems = this._deviceItems || this._generateDeviceItems(); this._deviceItems =
this._deviceItems || (await this._generateDeviceItems());
} else { } else {
this._entityItems = this._entityItems =
this._entityItems || (await this._generateEntityItems()); this._entityItems || (await this._generateEntityItems());
@ -344,10 +350,28 @@ export class QuickBar extends LitElement {
tabindex="0" tabindex="0"
type="button" type="button"
> >
${item.domain
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
/>`
: nothing}
<span slot="headline">${item.primaryText}</span> <span slot="headline">${item.primaryText}</span>
${item.area ${item.area
? html` <span slot="supporting-text">${item.area}</span> ` ? html` <span slot="supporting-text">${item.area}</span> `
: nothing} : nothing}
${item.translatedDomain
? html`<div slot="trailing-supporting-text" class="domain">
${item.translatedDomain}
</div>`
: nothing}
</ha-md-list-item> </ha-md-list-item>
`; `;
} }
@ -549,23 +573,44 @@ export class QuickBar extends LitElement {
); );
} }
private _generateDeviceItems(): DeviceItem[] { private async _generateDeviceItems(): Promise<DeviceItem[]> {
const configEntries = await getConfigEntries(this.hass);
const configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
return Object.values(this.hass.devices) return Object.values(this.hass.devices)
.filter((device) => !device.disabled_by) .filter((device) => !device.disabled_by)
.map((device) => { .map((device) => {
const area = device.area_id const deviceName = computeDeviceNameDisplay(device, this.hass);
? this.hass.areas[device.area_id]
: undefined; const { area } = getDeviceContext(device, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const deviceItem = { const deviceItem = {
primaryText: computeDeviceNameDisplay(device, this.hass), primaryText: deviceName,
deviceId: device.id, deviceId: device.id,
area: area?.name, area: areaName,
action: () => navigate(`/config/devices/device/${device.id}`), action: () => navigate(`/config/devices/device/${device.id}`),
}; };
const configEntry = device.primary_config_entry
? configEntryLookup[device.primary_config_entry]
: undefined;
const domain = configEntry?.domain;
const translatedDomain = domain
? domainToName(this.hass.localize, domain)
: undefined;
return { return {
...deviceItem, ...deviceItem,
strings: [deviceItem.primaryText], domain,
translatedDomain,
strings: [deviceName, areaName, domain, domainToName].filter(
Boolean
) as string[],
}; };
}) })
.sort((a, b) => .sort((a, b) =>
@ -1036,6 +1081,11 @@ export class QuickBar extends LitElement {
white-space: nowrap; white-space: nowrap;
} }
ha-md-list-item img {
width: 32px;
height: 32px;
}
ha-tip { ha-tip {
padding: 20px; padding: 20px;
} }

View File

@ -0,0 +1,159 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-dialog-header";
import "../../components/ha-icon-button";
import "../../components/ha-items-display-editor";
import type { DisplayValue } from "../../components/ha-items-display-editor";
import "../../components/ha-md-dialog";
import type { HaMdDialog } from "../../components/ha-md-dialog";
import { computePanels, PANEL_ICONS } from "../../components/ha-sidebar";
import type { HomeAssistant } from "../../types";
import type { EditSidebarDialogParams } from "./show-dialog-edit-sidebar";
@customElement("dialog-edit-sidebar")
class DialogEditSidebar extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _order: string[] = [];
@state() private _hidden: string[] = [];
private _saveCallback?: (order: string[], hidden: string[]) => void;
public async showDialog(params: EditSidebarDialogParams): Promise<void> {
this._open = true;
const storedOrder = localStorage.getItem("sidebarPanelOrder");
const storedHidden = localStorage.getItem("sidebarHiddenPanels");
this._order = storedOrder ? JSON.parse(storedOrder) : this._order;
this._hidden = storedHidden ? JSON.parse(storedHidden) : this._hidden;
this._saveCallback = params.saveCallback;
}
private _dialogClosed(): void {
this._open = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog(): void {
this._dialog?.close();
}
private _panels = memoizeOne((panels: HomeAssistant["panels"]) =>
panels ? Object.values(panels) : []
);
protected render() {
if (!this._open) {
return nothing;
}
const dialogTitle = this.hass.localize("ui.sidebar.edit_sidebar");
const panels = this._panels(this.hass.panels);
const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels,
this.hass.defaultPanel,
this._order,
this._hidden,
this.hass.locale
);
const items = [
...beforeSpacer,
...panels.filter((panel) => this._hidden.includes(panel.url_path)),
...afterSpacer.filter((panel) => panel.url_path !== "config"),
].map((panel) => ({
value: panel.url_path,
label:
panel.url_path === this.hass.defaultPanel
? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title || "?",
icon: panel.icon || undefined,
iconPath:
panel.url_path === this.hass.defaultPanel && !panel.icon
? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]
: undefined,
disableSorting: panel.url_path === "developer-tools",
}));
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}> ${dialogTitle} </span>
</ha-dialog-header>
<div slot="content" class="content">
<ha-items-display-editor
.hass=${this.hass}
.value=${{
order: this._order,
hidden: this._hidden,
}}
.items=${items}
@value-changed=${this._changed}
dont-sort-visible
>
</ha-items-display-editor>
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._save}>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
</ha-md-dialog>
`;
}
private _changed(ev: CustomEvent<{ value: DisplayValue }>): void {
const { order = [], hidden = [] } = ev.detail.value;
this._order = [...order];
this._hidden = [...hidden];
}
private _save(): void {
this._saveCallback?.(this._order ?? [], this._hidden ?? []);
this.closeDialog();
}
static styles = css`
ha-md-dialog {
min-width: 600px;
max-height: 90%;
}
@media all and (max-width: 600px), all and (max-height: 500px) {
ha-md-dialog {
--md-dialog-container-shape: 0;
min-width: 100%;
min-height: 100%;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-edit-sidebar": DialogEditSidebar;
}
}

View File

@ -0,0 +1,18 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface EditSidebarDialogParams {
saveCallback: (order: string[], hidden: string[]) => void;
}
export const loadEditSidebarDialog = () => import("./dialog-edit-sidebar");
export const showEditSidebarDialog = (
element: HTMLElement,
dialogParams: EditSidebarDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-edit-sidebar",
dialogImport: loadEditSidebarDialog,
dialogParams,
});
};

View File

@ -7,6 +7,7 @@ This is the entry point for providing external app stuff from app entrypoint.
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window"; import { mainWindow } from "../common/dom/get_main_window";
import { navigate } from "../common/navigate";
import { showAutomationEditor } from "../data/automation"; import { showAutomationEditor } from "../data/automation";
import type { HomeAssistantMain } from "../layouts/home-assistant-main"; import type { HomeAssistantMain } from "../layouts/home-assistant-main";
import type { import type {
@ -50,7 +51,7 @@ export const addExternalBarCodeListener = (
}; };
}; };
const handleExternalMessage = ( export const handleExternalMessage = (
hassMainEl: HomeAssistantMain, hassMainEl: HomeAssistantMain,
msg: EMIncomingMessageCommands msg: EMIncomingMessageCommands
): boolean => { ): boolean => {
@ -64,6 +65,14 @@ const handleExternalMessage = (
success: true, success: true,
result: null, result: null,
}); });
} else if (msg.command === "navigate") {
navigate(msg.payload.path, msg.payload.options);
bus.fireMessage({
id: msg.id,
type: "result",
success: true,
result: null,
});
} else if (msg.command === "notifications/show") { } else if (msg.command === "notifications/show") {
fireEvent(hassMainEl, "hass-show-notifications"); fireEvent(hassMainEl, "hass-show-notifications");
bus.fireMessage({ bus.fireMessage({

View File

@ -1,3 +1,4 @@
import type { NavigateOptions } from "../common/navigate";
import type { AutomationConfig } from "../data/automation"; import type { AutomationConfig } from "../data/automation";
const CALLBACK_EXTERNAL_BUS = "externalBus"; const CALLBACK_EXTERNAL_BUS = "externalBus";
@ -178,31 +179,40 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageImprovScan | EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice; | EMOutgoingMessageImprovConfigureDevice;
interface EMIncomingMessageRestart { export interface EMIncomingMessageRestart {
id: number; id: number;
type: "command"; type: "command";
command: "restart"; command: "restart";
} }
export interface EMIncomingMessageNavigate {
id: number;
type: "command";
command: "navigate";
payload: {
path: string;
options?: NavigateOptions;
};
}
interface EMIncomingMessageShowNotifications { export interface EMIncomingMessageShowNotifications {
id: number; id: number;
type: "command"; type: "command";
command: "notifications/show"; command: "notifications/show";
} }
interface EMIncomingMessageToggleSidebar { export interface EMIncomingMessageToggleSidebar {
id: number; id: number;
type: "command"; type: "command";
command: "sidebar/toggle"; command: "sidebar/toggle";
} }
interface EMIncomingMessageShowSidebar { export interface EMIncomingMessageShowSidebar {
id: number; id: number;
type: "command"; type: "command";
command: "sidebar/show"; command: "sidebar/show";
} }
interface EMIncomingMessageShowAutomationEditor { export interface EMIncomingMessageShowAutomationEditor {
id: number; id: number;
type: "command"; type: "command";
command: "automation/editor/show"; command: "automation/editor/show";
@ -250,14 +260,14 @@ export interface ImprovDiscoveredDevice {
name: string; name: string;
} }
interface EMIncomingMessageImprovDeviceDiscovered extends EMMessage { export interface EMIncomingMessageImprovDeviceDiscovered extends EMMessage {
id: number; id: number;
type: "command"; type: "command";
command: "improv/discovered_device"; command: "improv/discovered_device";
payload: ImprovDiscoveredDevice; payload: ImprovDiscoveredDevice;
} }
interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage { export interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage {
id: number; id: number;
type: "command"; type: "command";
command: "improv/device_setup_done"; command: "improv/device_setup_done";
@ -265,6 +275,7 @@ interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage {
export type EMIncomingMessageCommands = export type EMIncomingMessageCommands =
| EMIncomingMessageRestart | EMIncomingMessageRestart
| EMIncomingMessageNavigate
| EMIncomingMessageShowNotifications | EMIncomingMessageShowNotifications
| EMIncomingMessageToggleSidebar | EMIncomingMessageToggleSidebar
| EMIncomingMessageShowSidebar | EMIncomingMessageShowSidebar

View File

@ -1,6 +1,5 @@
<script> <script>
if (navigator.userAgent.indexOf("Android") === -1 && if (navigator.userAgent.indexOf("Android") === -1) {
navigator.userAgent.indexOf("CrOS") === -1) {
function _pf(src, type) { function _pf(src, type) {
var el = document.createElement("link"); var el = document.createElement("link");
el.rel = "preload"; el.rel = "preload";

View File

@ -44,7 +44,7 @@
} }
#ha-launch-screen .ha-launch-screen-spacer-top { #ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1; flex: 1;
margin-top: calc( 2 * max(env(safe-area-inset-bottom), 48px) + 46px ); margin-top: calc( 2 * max(var(--safe-area-inset-bottom), 48px) + 46px );
padding-top: 48px; padding-top: 48px;
} }
#ha-launch-screen .ha-launch-screen-spacer-bottom { #ha-launch-screen .ha-launch-screen-spacer-bottom {
@ -52,7 +52,7 @@
padding-top: 48px; padding-top: 48px;
} }
.ohf-logo { .ohf-logo {
margin: max(env(safe-area-inset-bottom), 48px) 0; margin: max(var(--safe-area-inset-bottom), 48px) 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View File

@ -148,10 +148,10 @@ class HassSubpage extends LitElement {
#fab { #fab {
position: absolute; position: absolute;
right: calc(16px + env(safe-area-inset-right)); right: calc(16px + var(--safe-area-inset-right));
inset-inline-end: calc(16px + env(safe-area-inset-right)); inset-inline-end: calc(16px + var(--safe-area-inset-right));
inset-inline-start: initial; inset-inline-start: initial;
bottom: calc(16px + env(safe-area-inset-bottom)); bottom: calc(16px + var(--safe-area-inset-bottom));
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -159,7 +159,7 @@ class HassSubpage extends LitElement {
gap: 8px; gap: 8px;
} }
:host([narrow]) #fab.tabs { :host([narrow]) #fab.tabs {
bottom: calc(84px + env(safe-area-inset-bottom)); bottom: calc(84px + var(--safe-area-inset-bottom));
} }
#fab[is-wide] { #fab[is-wide] {
bottom: 24px; bottom: 24px;

View File

@ -878,10 +878,10 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: calc( --mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-max-width: calc( --mdc-dialog-max-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-min-height: 100%; --mdc-dialog-min-height: 100%;
--mdc-dialog-max-height: 100%; --mdc-dialog-max-height: 100%;

View File

@ -3,15 +3,15 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators"; import { customElement, eventOptions, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { canShowPage } from "../common/config/can_show_page";
import { restoreScroll } from "../common/decorators/restore-scroll"; import { restoreScroll } from "../common/decorators/restore-scroll";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-button-arrow-prev"; import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button"; import "../components/ha-menu-button";
import "../components/ha-svg-icon"; import "../components/ha-svg-icon";
import "../components/ha-tab"; import "../components/ha-tab";
import type { HomeAssistant, Route } from "../types";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import { canShowPage } from "../common/config/can_show_page"; import type { HomeAssistant, Route } from "../types";
export interface PageNavigation { export interface PageNavigation {
path: string; path: string;
@ -52,6 +52,12 @@ class HassTabsSubpage extends LitElement {
@property({ type: Boolean }) public pane = false; @property({ type: Boolean }) public pane = false;
/**
* Do we need to add padding for a fab.
* @type {Boolean}
*/
@property({ type: Boolean, attribute: "has-fab" }) public hasFab = false;
@state() private _activeTab?: PageNavigation; @state() private _activeTab?: PageNavigation;
// @ts-ignore // @ts-ignore
@ -178,6 +184,7 @@ class HassTabsSubpage extends LitElement {
@scroll=${this._saveScrollPos} @scroll=${this._saveScrollPos}
> >
<slot></slot> <slot></slot>
${this.hasFab ? html`<div class="fab-bottom-space"></div>` : nothing}
</div> </div>
</div> </div>
<div id="fab" class=${classMap({ tabs: showTabs })}> <div id="fab" class=${classMap({ tabs: showTabs })}>
@ -280,7 +287,7 @@ class HassTabsSubpage extends LitElement {
z-index: 2; z-index: 2;
font-size: var(--ha-font-size-s); font-size: var(--ha-font-size-s);
width: 100%; width: 100%;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: var(--safe-area-inset-bottom);
} }
#tabbar:not(.bottom-bar) { #tabbar:not(.bottom-bar) {
@ -312,12 +319,12 @@ class HassTabsSubpage extends LitElement {
.content { .content {
position: relative; position: relative;
width: calc( width: calc(
100% - env(safe-area-inset-left) - env(safe-area-inset-right) 100% - var(--safe-area-inset-left) - var(--safe-area-inset-right)
); );
margin-left: env(safe-area-inset-left); margin-left: var(--safe-area-inset-left);
margin-right: env(safe-area-inset-right); margin-right: var(--safe-area-inset-right);
margin-inline-start: env(safe-area-inset-left); margin-inline-start: var(--safe-area-inset-left);
margin-inline-end: env(safe-area-inset-right); margin-inline-end: var(--safe-area-inset-right);
overflow: auto; overflow: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@ -325,23 +332,31 @@ class HassTabsSubpage extends LitElement {
:host([narrow]) .content { :host([narrow]) .content {
height: calc(100% - var(--header-height)); height: calc(100% - var(--header-height));
height: calc( height: calc(
100% - var(--header-height) - env(safe-area-inset-bottom) 100% - var(--header-height) - var(--safe-area-inset-bottom)
); );
} }
:host([narrow]) .content.tabs { :host([narrow]) .content.tabs {
height: calc(100% - 2 * var(--header-height)); height: calc(100% - 2 * var(--header-height));
height: calc( height: calc(
100% - 2 * var(--header-height) - env(safe-area-inset-bottom) 100% - 2 * var(--header-height) - var(--safe-area-inset-bottom)
); );
} }
.content .fab-bottom-space {
height: calc(64px + var(--safe-area-inset-bottom));
}
:host([narrow]) .content.tabs .fab-bottom-space {
height: calc(80px + var(--safe-area-inset-bottom));
}
#fab { #fab {
position: fixed; position: fixed;
right: calc(16px + env(safe-area-inset-right)); right: calc(16px + var(--safe-area-inset-right));
inset-inline-end: calc(16px + env(safe-area-inset-right)); inset-inline-end: calc(16px + var(--safe-area-inset-right));
inset-inline-start: initial; inset-inline-start: initial;
bottom: calc(16px + env(safe-area-inset-bottom)); bottom: calc(16px + var(--safe-area-inset-bottom));
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -349,7 +364,7 @@ class HassTabsSubpage extends LitElement {
gap: 8px; gap: 8px;
} }
:host([narrow]) #fab.tabs { :host([narrow]) #fab.tabs {
bottom: calc(84px + env(safe-area-inset-bottom)); bottom: calc(84px + var(--safe-area-inset-bottom));
} }
#fab[is-wide] { #fab[is-wide] {
bottom: 24px; bottom: 24px;

View File

@ -10,6 +10,7 @@ import { showNotificationDrawer } from "../dialogs/notifications/show-notificati
import type { HomeAssistant, Route } from "../types"; import type { HomeAssistant, Route } from "../types";
import "./partial-panel-resolver"; import "./partial-panel-resolver";
import { computeRTLDirection } from "../common/util/compute_rtl"; import { computeRTLDirection } from "../common/util/compute_rtl";
import { storage } from "../common/decorators/storage";
declare global { declare global {
// for fire event // for fire event
@ -25,7 +26,8 @@ declare global {
} }
interface EditSideBarEvent { interface EditSideBarEvent {
editMode: boolean; order: string[];
hidden: string[];
} }
@customElement("home-assistant-main") @customElement("home-assistant-main")
@ -42,6 +44,22 @@ export class HomeAssistantMain extends LitElement {
@state() private _drawerOpen = false; @state() private _drawerOpen = false;
@state()
@storage({
key: "sidebarPanelOrder",
state: true,
subscribe: true,
})
private _panelOrder: string[] = [];
@state()
@storage({
key: "sidebarHiddenPanels",
state: true,
subscribe: true,
})
private _hiddenPanels: string[] = [];
constructor() { constructor() {
super(); super();
listenMediaQuery("(max-width: 870px)", (matches) => { listenMediaQuery("(max-width: 870px)", (matches) => {
@ -63,7 +81,8 @@ export class HomeAssistantMain extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.narrow=${sidebarNarrow} .narrow=${sidebarNarrow}
.route=${this.route} .route=${this.route}
.editMode=${this._sidebarEditMode} .panelOrder=${this._panelOrder}
.hiddenPanels=${this._hiddenPanels}
.alwaysExpand=${sidebarNarrow || this.hass.dockedSidebar === "docked"} .alwaysExpand=${sidebarNarrow || this.hass.dockedSidebar === "docked"}
></ha-sidebar> ></ha-sidebar>
<partial-panel-resolver <partial-panel-resolver
@ -90,17 +109,8 @@ export class HomeAssistantMain extends LitElement {
this.addEventListener( this.addEventListener(
"hass-edit-sidebar", "hass-edit-sidebar",
(ev: HASSDomEvent<EditSideBarEvent>) => { (ev: HASSDomEvent<EditSideBarEvent>) => {
this._sidebarEditMode = ev.detail.editMode; this._panelOrder = ev.detail.order;
this._hiddenPanels = ev.detail.hidden;
if (this._sidebarEditMode) {
if (this._sidebarNarrow) {
this._drawerOpen = true;
} else {
fireEvent(this, "hass-dock-sidebar", {
dock: "docked",
});
}
}
} }
); );
@ -172,7 +182,7 @@ export class HomeAssistantMain extends LitElement {
--mdc-top-app-bar-width: calc(100% - var(--mdc-drawer-width)); --mdc-top-app-bar-width: calc(100% - var(--mdc-drawer-width));
} }
:host([expanded]) { :host([expanded]) {
--mdc-drawer-width: calc(256px + env(safe-area-inset-left)); --mdc-drawer-width: calc(256px + var(--safe-area-inset-left));
} }
:host([modal]) { :host([modal]) {
--mdc-drawer-width: unset; --mdc-drawer-width: unset;

View File

@ -142,6 +142,9 @@ class DialogAreaDetail extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this._labels} .value=${this._labels}
@value-changed=${this._labelsChanged} @value-changed=${this._labelsChanged}
.placeholder=${this.hass.localize(
"ui.panel.config.areas.editor.add_labels"
)}
></ha-labels-picker> ></ha-labels-picker>
<ha-picture-upload <ha-picture-upload

View File

@ -148,6 +148,7 @@ export class HaConfigAreasDashboard extends LitElement {
back-path="/config" back-path="/config"
.tabs=${configSections.areas} .tabs=${configSections.areas}
.route=${this.route} .route=${this.route}
has-fab
> >
<ha-icon-button <ha-icon-button
slot="toolbar-icon" slot="toolbar-icon"

View File

@ -23,6 +23,7 @@ import type {
EntityRegistryUpdate, EntityRegistryUpdate,
SaveDialogParams, SaveDialogParams,
} from "./show-dialog-automation-save"; } from "./show-dialog-automation-save";
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
@customElement("ha-dialog-automation-save") @customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog { class DialogAutomationSave extends LitElement implements HassDialog {
@ -156,6 +157,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
name="description" name="description"
autogrow autogrow
.value=${this._newDescription} .value=${this._newDescription}
.helper=${supportsMarkdownHelper(this.hass.localize)}
@input=${this._valueChanged} @input=${this._valueChanged}
></ha-textarea>` ></ha-textarea>`
: nothing} : nothing}

View File

@ -1117,7 +1117,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
} }
ha-fab { ha-fab {
position: relative; position: relative;
bottom: calc(-80px - env(safe-area-inset-bottom)); bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s; transition: bottom 0.3s;
} }
ha-fab.dirty { ha-fab.dirty {

View File

@ -1415,7 +1415,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -48,12 +48,6 @@ export class HaEventTrigger extends LitElement implements TriggerElement {
"ui.panel.config.automation.editor.triggers.type.event.context_users" "ui.panel.config.automation.editor.triggers.type.event.context_users"
)} )}
<ha-users-picker <ha-users-picker
.pickedUserLabel=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.event.context_user_picked"
)}
.pickUserLabel=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.event.context_user_pick"
)}
.hass=${this.hass} .hass=${this.hass}
.disabled=${this.disabled} .disabled=${this.disabled}
.value=${this._wrapUsersInArray(context?.user_id)} .value=${this._wrapUsersInArray(context?.user_id)}

View File

@ -122,7 +122,7 @@ class HaBackupOverviewBackups extends LitElement {
gap: 24px; gap: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-bottom: calc(72px + env(safe-area-inset-bottom)); margin-bottom: calc(72px + var(--safe-area-inset-bottom));
} }
.card-actions { .card-actions {
display: flex; display: flex;

View File

@ -251,7 +251,7 @@ class HaConfigBackupOverview extends LitElement {
gap: 24px; gap: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-bottom: calc(env(safe-area-inset-bottom) + 72px); margin-bottom: calc(var(--safe-area-inset-bottom) + 72px);
} }
.card-actions { .card-actions {
display: flex; display: flex;

View File

@ -195,7 +195,7 @@ class HaConfigSectionUpdates extends LitElement {
justify-content: space-between; justify-content: space-between;
flex-direction: column; flex-direction: column;
display: flex; display: flex;
margin-bottom: max(24px, env(safe-area-inset-bottom)); margin-bottom: max(24px, var(--safe-area-inset-bottom));
} }
ha-config-updates { ha-config-updates {
margin-bottom: 8px; margin-bottom: 8px;

View File

@ -223,7 +223,7 @@ class HaConfigSystemNavigation extends LitElement {
haStyle, haStyle,
css` css`
:host(:not([narrow])) ha-card { :host(:not([narrow])) ha-card {
margin-bottom: max(24px, env(safe-area-inset-bottom)); margin-bottom: max(24px, var(--safe-area-inset-bottom));
} }
ha-config-section { ha-config-section {
@ -235,7 +235,7 @@ class HaConfigSystemNavigation extends LitElement {
ha-card { ha-card {
overflow: hidden; overflow: hidden;
margin-bottom: 24px; margin-bottom: 24px;
margin-bottom: max(24px, env(safe-area-inset-bottom)); margin-bottom: max(24px, var(--safe-area-inset-bottom));
} }
ha-card a { ha-card a {

View File

@ -387,10 +387,10 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
haStyle, haStyle,
css` css`
ha-card:last-child { ha-card:last-child {
margin-bottom: env(safe-area-inset-bottom); margin-bottom: var(--safe-area-inset-bottom);
} }
:host(:not([narrow])) ha-card:last-child { :host(:not([narrow])) ha-card:last-child {
margin-bottom: max(24px, env(safe-area-inset-bottom)); margin-bottom: max(24px, var(--safe-area-inset-bottom));
} }
ha-config-section { ha-config-section {
margin: auto; margin: auto;
@ -425,7 +425,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
} }
ha-tip { ha-tip {
margin-bottom: max(env(safe-area-inset-bottom), 8px); margin-bottom: max(var(--safe-area-inset-bottom), 8px);
} }
.new { .new {

View File

@ -240,10 +240,10 @@ class DialogMQTTDeviceDebugInfo extends LitElement {
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: calc( --mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-max-width: calc( --mdc-dialog-max-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
} }
} }

View File

@ -1110,7 +1110,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -252,7 +252,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
display: flex; display: flex;
padding: 8px 16px 8px 24px; padding: 8px 16px 8px 24px;
justify-content: space-between; justify-content: space-between;
padding-bottom: max(env(safe-area-inset-bottom), 8px); padding-bottom: max(var(--safe-area-inset-bottom), 8px);
background-color: var(--mdc-theme-surface, #fff); background-color: var(--mdc-theme-surface, #fff);
border-top: 1px solid var(--divider-color); border-top: 1px solid var(--divider-color);
position: sticky; position: sticky;

View File

@ -1367,7 +1367,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -1259,7 +1259,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -366,7 +366,7 @@ class HaConfigInfo extends LitElement {
} }
.pages { .pages {
margin-bottom: max(24px, env(safe-area-inset-bottom)); margin-bottom: max(24px, var(--safe-area-inset-bottom));
padding: 4px 0; padding: 4px 0;
} }

View File

@ -447,6 +447,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
back-path="/config" back-path="/config"
.route=${this.route} .route=${this.route}
.tabs=${configSections.devices} .tabs=${configSections.devices}
has-fab
> >
${this.narrow ${this.narrow
? html` ? html`
@ -984,9 +985,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
grid-gap: 8px 8px; grid-gap: 8px 8px;
padding: 8px 16px 16px; padding: 8px 16px 16px;
} }
.container:last-of-type {
margin-bottom: 64px;
}
.empty-message { .empty-message {
margin: auto; margin: auto;
text-align: center; text-align: center;

View File

@ -27,6 +27,18 @@ import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles"; import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types"; import type { HomeAssistant, Route } from "../../../../../types";
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info"; import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
export const bluetoothAdvertisementMonitorTabs: PageNavigation[] = [
{
translationKey: "ui.panel.config.bluetooth.advertisement_monitor",
path: "advertisement-monitor",
},
{
translationKey: "ui.panel.config.bluetooth.visualization",
path: "visualization",
},
];
@customElement("bluetooth-advertisement-monitor") @customElement("bluetooth-advertisement-monitor")
export class BluetoothAdvertisementMonitorPanel extends LitElement { export class BluetoothAdvertisementMonitorPanel extends LitElement {
@ -220,6 +232,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
@collapsed-changed=${this._handleCollapseChanged} @collapsed-changed=${this._handleCollapseChanged}
filter=${this.address || ""} filter=${this.address || ""}
clickable clickable
.tabs=${bluetoothAdvertisementMonitorTabs}
></hass-tabs-subpage-data-table> ></hass-tabs-subpage-data-table>
`; `;
} }

View File

@ -31,6 +31,10 @@ class BluetoothConfigDashboardRouter extends HassRouterPage {
tag: "bluetooth-connection-monitor", tag: "bluetooth-connection-monitor",
load: () => import("./bluetooth-connection-monitor"), load: () => import("./bluetooth-connection-monitor"),
}, },
visualization: {
tag: "bluetooth-network-visualization",
load: () => import("./bluetooth-network-visualization"),
},
}, },
}; };

View File

@ -106,6 +106,13 @@ export class BluetoothConfigDashboard extends LitElement {
)} )}
</ha-button></a </ha-button></a
> >
<a href="/config/bluetooth/visualization"
><ha-button>
${this.hass.localize(
"ui.panel.config.bluetooth.visualization"
)}
</ha-button></a
>
</div> </div>
</ha-card> </ha-card>
<ha-card <ha-card
@ -208,6 +215,10 @@ export class BluetoothConfigDashboard extends LitElement {
ha-card { ha-card {
margin-bottom: 16px; margin-bottom: 16px;
} }
.card-actions {
display: flex;
justify-content: space-between;
}
`, `,
]; ];
} }

View File

@ -0,0 +1,318 @@
import { html, LitElement, css } from "lit";
import type { CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import memoizeOne from "memoize-one";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../../../components/chart/ha-network-graph";
import type {
NetworkData,
NetworkNode,
NetworkLink,
} from "../../../../../components/chart/ha-network-graph";
import type {
BluetoothDeviceData,
BluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import {
subscribeBluetoothAdvertisements,
subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
import "../../../../../layouts/hass-subpage";
import { colorVariables } from "../../../../../resources/theme/color.globals";
import { navigate } from "../../../../../common/navigate";
import { bluetoothAdvertisementMonitorTabs } from "./bluetooth-advertisement-monitor";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import { throttle } from "../../../../../common/util/throttle";
const UPDATE_THROTTLE_TIME = 10000;
@customElement("bluetooth-network-visualization")
export class BluetoothNetworkVisualization extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public route!: Route;
@state() private _data: BluetoothDeviceData[] = [];
@state() private _scanners: BluetoothScannersDetails = {};
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
private _unsub_advertisements?: UnsubscribeFunc;
private _unsub_scanners?: UnsubscribeFunc;
private _throttledUpdateData = throttle((data: BluetoothDeviceData[]) => {
this._data = data;
}, UPDATE_THROTTLE_TIME);
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._unsub_advertisements = subscribeBluetoothAdvertisements(
this.hass.connection,
(data) => {
if (!this._data.length) {
this._data = data;
} else {
this._throttledUpdateData(data);
}
}
);
this._unsub_scanners = subscribeBluetoothScannersDetails(
this.hass.connection,
(scanners) => {
this._scanners = scanners;
}
);
const devices = Object.values(this.hass.devices);
const bluetoothDevices = devices.filter((device) =>
device.connections.find((connection) => connection[0] === "bluetooth")
);
this._sourceDevices = Object.fromEntries(
bluetoothDevices.map((device) => {
const connection = device.connections.find(
(c) => c[0] === "bluetooth"
)!;
return [connection[1], device];
})
);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsub_advertisements) {
this._unsub_advertisements();
this._unsub_advertisements = undefined;
}
this._throttledUpdateData.cancel();
if (this._unsub_scanners) {
this._unsub_scanners();
this._unsub_scanners = undefined;
}
}
protected render() {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
header=${this.hass.localize("ui.panel.config.bluetooth.visualization")}
.tabs=${bluetoothAdvertisementMonitorTabs}
>
<ha-network-graph
.hass=${this.hass}
.data=${this._formatNetworkData(this._data, this._scanners)}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
></ha-network-graph>
</hass-tabs-subpage>
`;
}
private _formatNetworkData = memoizeOne(
(
data: BluetoothDeviceData[],
scanners: BluetoothScannersDetails
): NetworkData => {
const categories = [
{
name: this.hass.localize("ui.panel.config.bluetooth.core"),
symbol: "roundRect",
itemStyle: {
color: colorVariables["primary-color"],
},
},
{
name: this.hass.localize("ui.panel.config.bluetooth.scanners"),
symbol: "circle",
itemStyle: {
color: colorVariables["cyan-color"],
},
},
{
name: this.hass.localize("ui.panel.config.bluetooth.known_devices"),
symbol: "circle",
itemStyle: {
color: colorVariables["teal-color"],
},
},
{
name: this.hass.localize("ui.panel.config.bluetooth.unknown_devices"),
symbol: "circle",
itemStyle: {
color: colorVariables["disabled-color"],
},
},
];
const nodes: NetworkNode[] = [
{
id: "ha",
name: this.hass.localize("ui.panel.config.bluetooth.core"),
category: 0,
value: 4,
symbol: "roundRect",
symbolSize: 40,
polarDistance: 0,
},
];
const links: NetworkLink[] = [];
Object.values(scanners).forEach((scanner) => {
const scannerDevice = this._sourceDevices[scanner.source];
nodes.push({
id: scanner.source,
name:
scannerDevice?.name_by_user || scannerDevice?.name || scanner.name,
category: 1,
value: 5,
symbol: "circle",
symbolSize: 30,
polarDistance: 0.25,
});
links.push({
source: "ha",
target: scanner.source,
value: 0,
symbol: "none",
lineStyle: {
width: 3,
color: colorVariables["primary-color"],
},
});
});
data.forEach((node) => {
if (scanners[node.address]) {
// proxies sometimes appear as end devices too
links.push({
source: node.source,
target: node.address,
value: node.rssi,
symbol: "none",
lineStyle: {
width: this._getLineWidth(node.rssi),
color: colorVariables["primary-color"],
},
});
return;
}
const device = this._sourceDevices[node.address];
nodes.push({
id: node.address,
name: this._getBluetoothDeviceName(node.address),
value: device ? 1 : 0,
category: device ? 2 : 3,
symbolSize: 20,
});
links.push({
source: node.source,
target: node.address,
value: node.rssi,
symbol: "none",
lineStyle: {
width: this._getLineWidth(node.rssi),
color: device
? colorVariables["primary-color"]
: colorVariables["disabled-color"],
},
});
});
return { nodes, links, categories };
}
);
private _getBluetoothDeviceName(id: string): string {
if (id === "ha") {
return this.hass.localize("ui.panel.config.bluetooth.core");
}
if (this._sourceDevices[id]) {
return (
this._sourceDevices[id]?.name_by_user ||
this._sourceDevices[id]?.name ||
id
);
}
if (this._scanners[id]) {
return this._scanners[id]?.name || id;
}
return this._data.find((d) => d.address === id)?.name || id;
}
private _getLineWidth(rssi: number): number {
return rssi > -33 ? 3 : rssi > -66 ? 2 : 1;
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data } = params as CallbackDataParams;
let tooltipText = "";
if (dataType === "edge") {
const { source, target, value } = data as any;
const sourceName = this._getBluetoothDeviceName(source);
const targetName = this._getBluetoothDeviceName(target);
tooltipText = `${sourceName}${targetName}`;
if (source !== "ha") {
tooltipText += ` <b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${value}`;
}
} else {
const { id: address } = data as any;
const name = this._getBluetoothDeviceName(address);
const btDevice = this._data.find((d) => d.address === address);
if (btDevice) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}<br><b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${btDevice.rssi}<br><b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b> ${btDevice.source}<br><b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b> ${relativeTime(new Date(btDevice.time * 1000), this.hass.locale)}`;
} else {
const device = this._sourceDevices[address];
if (device) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}`;
if (device.area_id) {
const area = this.hass.areas[device.area_id];
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
}
}
}
}
return tooltipText;
};
private _handleChartClick(e: CustomEvent): void {
if (
e.detail.dataType === "node" &&
e.detail.event.target.cursor === "pointer"
) {
const { id } = e.detail.data;
const device = this._sourceDevices[id];
if (device) {
navigate(`/config/devices/device/${device.id}`);
}
}
}
static get styles(): CSSResultGroup {
return [
css`
ha-network-graph {
height: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"bluetooth-network-visualization": BluetoothNetworkVisualization;
}
}

View File

@ -222,10 +222,10 @@ class DialogMatterAddDevice extends LitElement {
} }
ha-dialog { ha-dialog {
--mdc-dialog-min-width: calc( --mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-max-width: calc( --mdc-dialog-max-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
} }
} }

View File

@ -1,48 +1,48 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import { import {
mdiAlertCircle,
mdiCheckCircle,
mdiFolderMultipleOutline, mdiFolderMultipleOutline,
mdiLan, mdiLan,
mdiNetwork, mdiNetwork,
mdiPlus,
mdiPencil, mdiPencil,
mdiCheckCircle, mdiPlus,
mdiAlertCircle,
} from "@mdi/js"; } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import type { ConfigEntry } from "../../../../../data/config_entries"; import "../../../../../components/buttons/ha-progress-button";
import { getConfigEntries } from "../../../../../data/config_entries"; import "../../../../../components/ha-alert";
import "../../../../../components/ha-card"; import "../../../../../components/ha-card";
import "../../../../../components/ha-fab"; import "../../../../../components/ha-fab";
import "../../../../../components/ha-icon-button";
import { fileDownload } from "../../../../../util/file_download";
import "../../../../../components/ha-icon-next";
import "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../ha-config-section";
import "../../../../../components/ha-form/ha-form"; import "../../../../../components/ha-form/ha-form";
import "../../../../../components/buttons/ha-progress-button"; import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-settings-row"; import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon"; import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-alert"; import type { ConfigEntry } from "../../../../../data/config_entries";
import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel"; import { getConfigEntries } from "../../../../../data/config_entries";
import type { import type {
ZHAConfiguration, ZHAConfiguration,
ZHANetworkSettings,
ZHANetworkBackupAndMetadata, ZHANetworkBackupAndMetadata,
ZHANetworkSettings,
} from "../../../../../data/zha"; } from "../../../../../data/zha";
import { import {
fetchZHAConfiguration,
updateZHAConfiguration,
fetchZHANetworkSettings,
createZHANetworkBackup, createZHANetworkBackup,
fetchDevices, fetchDevices,
fetchZHAConfiguration,
fetchZHANetworkSettings,
updateZHAConfiguration,
} from "../../../../../data/zha"; } from "../../../../../data/zha";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { fileDownload } from "../../../../../util/file_download";
import "../../../ha-config-section";
import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel";
const MULTIPROTOCOL_ADDON_URL = "socket://core-silabs-multiprotocol:9999"; const MULTIPROTOCOL_ADDON_URL = "socket://core-silabs-multiprotocol:9999";
@ -108,6 +108,7 @@ class ZHAConfigDashboard extends LitElement {
.route=${this.route} .route=${this.route}
.tabs=${zhaTabs} .tabs=${zhaTabs}
back-path="/config/integrations" back-path="/config/integrations"
has-fab
> >
<ha-card class="content network-status"> <ha-card class="content network-status">
${this._error ${this._error

View File

@ -1,27 +1,26 @@
import "@material/mwc-button"; import "@material/mwc-button";
import type { CSSResultGroup, PropertyValues } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import type { Edge, EdgeOptions, Node } from "vis-network/peer/esm/vis-network"; import type {
import { Network } from "vis-network/peer/esm/vis-network"; CallbackDataParams,
import { navigate } from "../../../../../common/navigate"; TopLevelFormatterParams,
import "../../../../../components/search-input"; } from "echarts/types/dist/shared";
import "../../../../../components/device/ha-device-picker"; import { mdiRefresh } from "@mdi/js";
import "../../../../../components/ha-button-menu"; import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-checkbox"; import type {
import type { HaCheckbox } from "../../../../../components/ha-checkbox"; NetworkData,
import "../../../../../components/ha-formfield"; NetworkNode,
import type { DeviceRegistryEntry } from "../../../../../data/device_registry"; NetworkLink,
} from "../../../../../components/chart/ha-network-graph";
import type { ZHADevice } from "../../../../../data/zha"; import type { ZHADevice } from "../../../../../data/zha";
import { fetchDevices, refreshTopology } from "../../../../../data/zha"; import { fetchDevices, refreshTopology } from "../../../../../data/zha";
import "../../../../../layouts/hass-tabs-subpage"; import "../../../../../layouts/hass-tabs-subpage";
import type { import type { HomeAssistant, Route } from "../../../../../types";
ValueChangedEvent,
HomeAssistant,
Route,
} from "../../../../../types";
import { formatAsPaddedHex } from "./functions"; import { formatAsPaddedHex } from "./functions";
import { zhaTabs } from "./zha-config-dashboard"; import { zhaTabs } from "./zha-config-dashboard";
import { colorVariables } from "../../../../../resources/theme/color.globals";
import { navigate } from "../../../../../common/navigate";
@customElement("zha-network-visualization-page") @customElement("zha-network-visualization-page")
export class ZHANetworkVisualizationPage extends LitElement { export class ZHANetworkVisualizationPage extends LitElement {
@ -33,103 +32,22 @@ export class ZHANetworkVisualizationPage extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false; @property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) @state()
public zoomedDeviceIdFromURL?: string; private _networkData: NetworkData = {
nodes: [],
links: [],
categories: [],
};
@state() @state()
private zoomedDeviceId?: string; private _devices: ZHADevice[] = [];
@query("#visualization", true)
private _visualization?: HTMLElement;
@state()
private _devices = new Map<string, ZHADevice>();
@state()
private _devicesByDeviceId = new Map<string, ZHADevice>();
@state()
private _nodes: Node[] = [];
@state()
private _network?: Network;
@state()
private _filter?: string;
private _autoZoom = true;
private _enablePhysics = true;
protected firstUpdated(changedProperties: PropertyValues): void { protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties); super.firstUpdated(changedProperties);
// prevent zoomedDeviceIdFromURL from being restored to zoomedDeviceId after the user clears it
if (this.zoomedDeviceIdFromURL) {
this.zoomedDeviceId = this.zoomedDeviceIdFromURL;
}
if (this.hass) { if (this.hass) {
this._fetchData(); this._fetchData();
} }
this._network = new Network(
this._visualization!,
{},
{
autoResize: true,
layout: {
improvedLayout: true,
},
physics: {
barnesHut: {
springConstant: 0,
avoidOverlap: 10,
damping: 0.09,
},
},
nodes: {
font: {
multi: "html",
},
},
edges: {
smooth: {
enabled: true,
type: "continuous",
forceDirection: "none",
roundness: 0.6,
},
},
}
);
this._network.on("doubleClick", (properties) => {
const ieee = properties.nodes[0];
if (ieee) {
const device = this._devices.get(ieee);
if (device) {
navigate(`/config/devices/device/${device.device_reg_id}`);
}
}
});
this._network.on("click", (properties) => {
const ieee = properties.nodes[0];
if (ieee) {
const device = this._devices.get(ieee);
if (device && this._autoZoom) {
this.zoomedDeviceId = device.device_reg_id;
this._zoomToDevice();
}
}
});
this._network.on("stabilized", () => {
if (this.zoomedDeviceId) {
this._zoomToDevice();
}
});
} }
protected render() { protected render() {
@ -140,363 +58,311 @@ export class ZHANetworkVisualizationPage extends LitElement {
.narrow=${this.narrow} .narrow=${this.narrow}
.isWide=${this.isWide} .isWide=${this.isWide}
.route=${this.route} .route=${this.route}
.header=${this.hass.localize( header=${this.hass.localize("ui.panel.config.zha.visualization.header")}
"ui.panel.config.zha.visualization.header"
)}
> >
${this.narrow <ha-network-graph
? html` .hass=${this.hass}
<div slot="header"> .data=${this._networkData}
<search-input .tooltipFormatter=${this._tooltipFormatter}
.hass=${this.hass} @chart-click=${this._handleChartClick}
class="header" >
@value-changed=${this._handleSearchChange} <ha-icon-button
.filter=${this._filter} slot="button"
.label=${this.hass.localize( class="refresh-button"
"ui.panel.config.zha.visualization.highlight_label" .path=${mdiRefresh}
)} @click=${this._refreshTopology}
> label=${this.hass.localize(
</search-input> "ui.panel.config.zha.visualization.refresh_topology"
</div>
`
: ""}
<div class="header">
${!this.narrow
? html`<search-input
.hass=${this.hass}
@value-changed=${this._handleSearchChange}
.filter=${this._filter}
.label=${this.hass.localize(
"ui.panel.config.zha.visualization.highlight_label"
)}
></search-input>`
: ""}
<ha-device-picker
.hass=${this.hass}
.value=${this.zoomedDeviceId}
.label=${this.hass.localize(
"ui.panel.config.zha.visualization.zoom_label"
)} )}
.deviceFilter=${this._filterDevices} ></ha-icon-button>
@value-changed=${this._onZoomToDevice} </ha-network-graph>
></ha-device-picker>
<div class="controls">
<ha-formfield
.label=${this.hass!.localize(
"ui.panel.config.zha.visualization.auto_zoom"
)}
>
<ha-checkbox
@change=${this._handleAutoZoomCheckboxChange}
.checked=${this._autoZoom}
>
</ha-checkbox>
</ha-formfield>
<ha-formfield
.label=${this.hass!.localize(
"ui.panel.config.zha.visualization.enable_physics"
)}
><ha-checkbox
@change=${this._handlePhysicsCheckboxChange}
.checked=${this._enablePhysics}
>
</ha-checkbox
></ha-formfield>
<mwc-button @click=${this._refreshTopology}>
${this.hass!.localize(
"ui.panel.config.zha.visualization.refresh_topology"
)}
</mwc-button>
</div>
</div>
<div id="visualization"></div>
</hass-tabs-subpage> </hass-tabs-subpage>
`; `;
} }
private async _fetchData() { private async _fetchData() {
const devices = await fetchDevices(this.hass!); this._devices = await fetchDevices(this.hass!);
this._devices = new Map( this._networkData = this._createChartData(this._devices);
devices.map((device: ZHADevice) => [device.ieee, device])
);
this._devicesByDeviceId = new Map(
devices.map((device: ZHADevice) => [device.device_reg_id, device])
);
this._updateDevices(devices);
} }
private _updateDevices(devices: ZHADevice[]) { private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
this._nodes = []; const { dataType, data, name } = params as CallbackDataParams;
const edges: Edge[] = []; if (dataType === "edge") {
const { source, target, value } = data as any;
const targetName = this._networkData.nodes.find(
(node) => node.id === target
)!.name;
const sourceName = this._networkData.nodes.find(
(node) => node.id === source
)!.name;
const tooltipText = `${sourceName}${targetName}${value ? ` <b>LQI:</b> ${value}` : ""}`;
devices.forEach((device) => { const reverseValue = this._networkData.links.find(
this._nodes.push({ (link) => link.source === source && link.target === target
id: device.ieee, )?.reverseValue;
label: this._buildLabel(device), if (reverseValue) {
shape: this._getShape(device), return `${tooltipText}<br>${targetName}${sourceName} <b>LQI:</b> ${reverseValue}`;
mass: this._getMass(device),
fixed: device.device_type === "Coordinator",
color: {
background: device.available ? "#66FF99" : "#FF9999",
},
});
if (device.neighbors && device.neighbors.length > 0) {
device.neighbors.forEach((neighbor) => {
const idx = edges.findIndex(
(e) => device.ieee === e.to && neighbor.ieee === e.from
);
if (idx === -1) {
const edge_options = this._getEdgeOptions(parseInt(neighbor.lqi));
edges.push({
from: device.ieee,
to: neighbor.ieee,
label: neighbor.lqi + "",
color: edge_options.color,
width: edge_options.width,
length: edge_options.length,
physics: edge_options.physics,
arrows: {
from: {
enabled: neighbor.relationship !== "Child",
},
},
dashes: neighbor.relationship !== "Child",
});
} else {
const edge_options = this._getEdgeOptions(
Math.min(parseInt(edges[idx].label!), parseInt(neighbor.lqi))
);
edges[idx].label += " & " + neighbor.lqi;
edges[idx].color = edge_options.color;
edges[idx].width = edge_options.width;
edges[idx].length = edge_options.length;
edges[idx].physics = edge_options.physics;
delete edges[idx].arrows;
delete edges[idx].dashes;
}
});
} }
}); return tooltipText;
this._network?.setData({ nodes: this._nodes, edges: edges });
}
private _getEdgeOptions(lqi: number): EdgeOptions {
const length = 2000 - 4 * lqi;
if (lqi > 192) {
return {
color: { color: "#17ab00", highlight: "#17ab00" },
width: lqi / 20,
length: length,
physics: false,
};
} }
if (lqi > 128) { const device = this._devices.find((d) => d.ieee === (data as any).id);
return { if (!device) {
color: { color: "#e6b402", highlight: "#e6b402" }, return name;
width: 9,
length: length,
physics: false,
};
} }
return { let label = `<b>IEEE: </b>${device.ieee}`;
color: { color: "#bfbfbf", highlight: "#bfbfbf" }, label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b>${device.device_type.replace("_", " ")}`;
width: 1,
length: length,
physics: false,
};
}
private _getMass(device: ZHADevice): number {
if (!device.available) {
return 6;
}
if (device.device_type === "Coordinator") {
return 2;
}
if (device.device_type === "Router") {
return 4;
}
return 5;
}
private _getShape(device: ZHADevice): string {
if (device.device_type === "Coordinator") {
return "box";
}
if (device.device_type === "Router") {
return "ellipse";
}
return "circle";
}
private _buildLabel(device: ZHADevice): string {
let label =
device.user_given_name !== null
? `<b>${device.user_given_name}</b>\n`
: "";
label += `<b>IEEE: </b>${device.ieee}`;
label += `\n<b>Device Type: </b>${device.device_type.replace("_", " ")}`;
if (device.nwk != null) { if (device.nwk != null) {
label += `\n<b>NWK: </b>${formatAsPaddedHex(device.nwk)}`; label += `<br><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
} }
if (device.manufacturer != null && device.model != null) { if (device.manufacturer != null && device.model != null) {
label += `\n<b>Device: </b>${device.manufacturer} ${device.model}`; label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device")}: </b>${device.manufacturer} ${device.model}`;
} else { } else {
label += "\n<b>Device is not in <i>'zigbee.db'</i></b>"; label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_not_in_db")}</b>`;
} }
if (device.area_id) { if (device.area_id) {
label += `\n<b>Area ID: </b>${device.area_id}`; const area = this.hass.areas[device.area_id];
} if (area) {
return label; label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.area")}: </b>${area.name}`;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
const filterText = this._filter!.toLowerCase();
if (!this._network) {
return;
}
if (this._filter) {
const filteredNodeIds: (string | number)[] = [];
this._nodes.forEach((node) => {
if (node.label && node.label.toLowerCase().includes(filterText)) {
filteredNodeIds.push(node.id!);
}
});
this.zoomedDeviceId = "";
this._zoomOut();
this._network.selectNodes(filteredNodeIds, true);
} else {
this._network.unselectAll();
}
}
private _onZoomToDevice(event: ValueChangedEvent<string>) {
event.stopPropagation();
this.zoomedDeviceId = event.detail.value;
if (!this._network) {
return;
}
this._zoomToDevice();
}
private _zoomToDevice() {
this._filter = "";
if (!this.zoomedDeviceId) {
this._zoomOut();
} else {
const device: ZHADevice | undefined = this._devicesByDeviceId.get(
this.zoomedDeviceId
);
if (device) {
this._network!.fit({
nodes: [device.ieee],
animation: { duration: 500, easingFunction: "easeInQuad" },
});
} }
} }
} return label;
};
private _zoomOut() {
this._network!.fit({
nodes: [],
animation: { duration: 500, easingFunction: "easeOutQuad" },
});
}
private async _refreshTopology(): Promise<void> { private async _refreshTopology(): Promise<void> {
await refreshTopology(this.hass); await refreshTopology(this.hass);
await this._fetchData();
} }
private _filterDevices = (device: DeviceRegistryEntry): boolean => { private _handleChartClick(e: CustomEvent): void {
if (!this.hass) { if (
return false; e.detail.dataType === "node" &&
} e.detail.event.target.cursor === "pointer"
for (const parts of device.identifiers) { ) {
for (const part of parts) { const { id } = e.detail.data;
if (part === "zha") { const device = this._devices.find((d) => d.ieee === id);
return true; if (device) {
} navigate(`/config/devices/device/${device.device_reg_id}`);
} }
} }
return false;
};
private _handleAutoZoomCheckboxChange(ev: Event) {
this._autoZoom = (ev.target as HaCheckbox).checked;
}
private _handlePhysicsCheckboxChange(ev: Event) {
this._enablePhysics = (ev.target as HaCheckbox).checked;
this._network!.setOptions(
this._enablePhysics
? { physics: { enabled: true } }
: { physics: { enabled: false } }
);
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
css` css`
.header { ha-network-graph {
border-bottom: 1px solid var(--divider-color); height: 100%;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-height);
box-sizing: border-box;
}
.header > * {
padding: 0 8px;
}
:host([narrow]) .header {
flex-direction: column;
align-items: stretch;
height: var(--header-height) * 2;
}
.search-toolbar {
display: flex;
align-items: center;
color: var(--secondary-text-color);
padding: 0 16px;
}
search-input {
flex: 1;
display: block;
}
search-input.header {
color: var(--secondary-text-color);
}
ha-device-picker {
flex: 1;
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
}
#visualization {
height: calc(100% - var(--header-height));
width: 100%;
}
:host([narrow]) #visualization {
height: calc(100% - (var(--header-height) * 2));
} }
`, `,
]; ];
} }
private _createChartData(devices: ZHADevice[]): NetworkData {
const primaryColor = colorVariables["primary-color"];
const routerColor = colorVariables["cyan-color"];
const endDeviceColor = colorVariables["teal-color"];
const offlineColor = colorVariables["error-color"];
const nodes: NetworkNode[] = [];
const links: NetworkLink[] = [];
const categories = [
{
name: this.hass.localize(
"ui.panel.config.zha.visualization.coordinator"
),
symbol: "roundRect",
itemStyle: { color: primaryColor },
},
{
name: this.hass.localize("ui.panel.config.zha.visualization.router"),
symbol: "circle",
itemStyle: { color: routerColor },
},
{
name: this.hass.localize(
"ui.panel.config.zha.visualization.end_device"
),
symbol: "circle",
itemStyle: { color: endDeviceColor },
},
{
name: this.hass.localize("ui.panel.config.zha.visualization.offline"),
symbol: "circle",
itemStyle: { color: offlineColor },
},
];
// Create all the nodes and links
devices.forEach((device) => {
const isCoordinator = device.device_type === "Coordinator";
let category: number;
if (!device.available) {
category = 3; // Offline
} else if (isCoordinator) {
category = 0;
} else if (device.device_type === "Router") {
category = 1;
} else {
category = 2; // End Device
}
// Create node
nodes.push({
id: device.ieee,
name: device.user_given_name || device.name || device.ieee,
category,
value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1,
symbolSize: isCoordinator
? 40
: device.device_type === "Router"
? 30
: 20,
symbol: isCoordinator ? "roundRect" : "circle",
itemStyle: {
color: device.available
? isCoordinator
? primaryColor
: device.device_type === "Router"
? routerColor
: endDeviceColor
: offlineColor,
},
polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9,
});
// Create links (edges)
const existingLinks = links.filter(
(link) => link.source === device.ieee || link.target === device.ieee
);
if (device.routes && device.routes.length > 0) {
device.routes.forEach((route) => {
const neighbor = device.neighbors.find(
(n) => n.nwk === route.next_hop
);
if (!neighbor) {
return;
}
const existingLink = existingLinks.find(
(link) =>
link.source === neighbor.ieee || link.target === neighbor.ieee
);
if (existingLink) {
if (existingLink.source === device.ieee) {
existingLink.value = Math.max(
existingLink.value!,
parseInt(neighbor.lqi)
);
} else {
existingLink.reverseValue = Math.max(
existingLink.reverseValue ?? 0,
parseInt(neighbor.lqi)
);
}
const width = this._getLQIWidth(parseInt(neighbor.lqi));
existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9
existingLink.lineStyle = {
...existingLink.lineStyle,
width,
color:
route.route_status === "Active"
? primaryColor
: existingLink.lineStyle!.color,
type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid"
: existingLink.lineStyle!.type,
};
} else {
// Create a new link
const width = this._getLQIWidth(parseInt(neighbor.lqi));
const link: NetworkLink = {
source: device.ieee,
target: neighbor.ieee,
value: parseInt(neighbor.lqi),
lineStyle: {
width,
color:
route.route_status === "Active"
? primaryColor
: colorVariables["disabled-color"],
type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid"
: "dotted",
},
symbolSize: (width / 4) * 6 + 3, // range 3-9
// By default, all links should be ignored for force layout
ignoreForceLayout: true,
};
links.push(link);
existingLinks.push(link);
}
});
} else if (existingLinks.length === 0) {
// If there are no links, create a link to the closest neighbor
const neighbors: { ieee: string; lqi: string }[] =
device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
});
}
const closestNeighbor = neighbors.sort(
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
)[0];
if (closestNeighbor) {
links.push({
source: device.ieee,
target: closestNeighbor.ieee,
value: parseInt(closestNeighbor.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: colorVariables["disabled-color"],
type: "dotted",
},
ignoreForceLayout: true,
});
}
}
});
// Now set ignoreForceLayout to false for the strongest connection of each device
// Except for the coordinator which can have multiple strong connections
devices.forEach((device) => {
if (device.device_type === "Coordinator") {
links.forEach((link) => {
if (link.source === device.ieee || link.target === device.ieee) {
link.ignoreForceLayout = false;
}
});
} else {
// Find the link that corresponds to this strongest connection
let strongestLink: NetworkLink | undefined;
links.forEach((link) => {
if (
(link.source === device.ieee || link.target === device.ieee) &&
link.value! > (strongestLink?.value ?? 0)
) {
strongestLink = link;
}
});
if (strongestLink) {
strongestLink.ignoreForceLayout = false;
}
}
});
return { nodes, links, categories };
}
private _getLQIWidth(lqi: number): number {
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
}
} }
declare global { declare global {

View File

@ -1109,10 +1109,10 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
@media all and (max-width: 500px), all and (max-height: 500px) { @media all and (max-width: 500px), all and (max-height: 500px) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: calc( --mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-max-width: calc( --mdc-dialog-max-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-min-height: 100%; --mdc-dialog-min-height: 100%;
--mdc-dialog-max-height: 100%; --mdc-dialog-max-height: 100%;

View File

@ -43,6 +43,7 @@ import {
subscribeZwaveControllerStatistics, subscribeZwaveControllerStatistics,
subscribeZwaveNVMBackup, subscribeZwaveNVMBackup,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-tabs-subpage"; import "../../../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
@ -53,7 +54,6 @@ import { showZWaveJSAddNodeDialog } from "./add-node/show-dialog-zwave_js-add-no
import { showZWaveJSRebuildNetworkRoutesDialog } from "./show-dialog-zwave_js-rebuild-network-routes"; import { showZWaveJSRebuildNetworkRoutesDialog } from "./show-dialog-zwave_js-rebuild-network-routes";
import { showZWaveJSRemoveNodeDialog } from "./show-dialog-zwave_js-remove-node"; import { showZWaveJSRemoveNodeDialog } from "./show-dialog-zwave_js-remove-node";
import { configTabs } from "./zwave_js-config-router"; import { configTabs } from "./zwave_js-config-router";
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
@customElement("zwave_js-config-dashboard") @customElement("zwave_js-config-dashboard")
class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) { class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
@ -142,6 +142,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow} .narrow=${this.narrow}
.route=${this.route} .route=${this.route}
.tabs=${configTabs} .tabs=${configTabs}
has-fab
> >
<ha-icon-button <ha-icon-button
slot="toolbar-icon" slot="toolbar-icon"

View File

@ -4,20 +4,17 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-switch";
import "../../../components/ha-textfield";
import "../../../components/ha-textarea";
import "../../../components/ha-icon-picker";
import "../../../components/ha-color-picker"; import "../../../components/ha-color-picker";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-picker";
import "../../../components/ha-switch";
import "../../../components/ha-textarea";
import "../../../components/ha-textfield";
import type { LabelRegistryEntryMutableParams } from "../../../data/label_registry";
import type { HassDialog } from "../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../resources/styles"; import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import type { LabelDetailDialogParams } from "./show-dialog-label-detail"; import type { LabelDetailDialogParams } from "./show-dialog-label-detail";
import type {
LabelRegistryEntry,
LabelRegistryEntryMutableParams,
} from "../../../data/label_registry";
@customElement("dialog-label-detail") @customElement("dialog-label-detail")
class DialogLabelDetail class DialogLabelDetail
@ -177,7 +174,6 @@ class DialogLabelDetail
private async _updateEntry() { private async _updateEntry() {
this._submitting = true; this._submitting = true;
let newValue: LabelRegistryEntry | undefined;
try { try {
const values: LabelRegistryEntryMutableParams = { const values: LabelRegistryEntryMutableParams = {
name: this._name.trim(), name: this._name.trim(),
@ -186,9 +182,9 @@ class DialogLabelDetail
description: this._description.trim() || null, description: this._description.trim() || null,
}; };
if (this._params!.entry) { if (this._params!.entry) {
newValue = await this._params!.updateEntry!(values); await this._params!.updateEntry!(values);
} else { } else {
newValue = await this._params!.createEntry!(values); await this._params!.createEntry!(values);
} }
this.closeDialog(); this.closeDialog();
} catch (err: any) { } catch (err: any) {
@ -196,7 +192,6 @@ class DialogLabelDetail
} finally { } finally {
this._submitting = false; this._submitting = false;
} }
return newValue;
} }
private async _deleteEntry() { private async _deleteEntry() {

View File

@ -10,10 +10,10 @@ export interface LabelDetailDialogParams {
createEntry?: ( createEntry?: (
values: LabelRegistryEntryMutableParams, values: LabelRegistryEntryMutableParams,
labelId?: string labelId?: string
) => Promise<LabelRegistryEntry>; ) => Promise<unknown>;
updateEntry?: ( updateEntry?: (
updates: Partial<LabelRegistryEntryMutableParams> updates: Partial<LabelRegistryEntryMutableParams>
) => Promise<LabelRegistryEntry>; ) => Promise<unknown>;
removeEntry?: () => Promise<boolean>; removeEntry?: () => Promise<boolean>;
} }

View File

@ -65,6 +65,7 @@ export class HaConfigPerson extends LitElement {
.route=${this.route} .route=${this.route}
back-path="/config" back-path="/config"
.tabs=${configSections.persons} .tabs=${configSections.persons}
has-fab
> >
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
<span slot="header" <span slot="header"

View File

@ -183,7 +183,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
justify-content: space-between; justify-content: space-between;
flex-direction: column; flex-direction: column;
display: flex; display: flex;
margin-bottom: max(24px, env(safe-area-inset-bottom)); margin-bottom: max(24px, var(--safe-area-inset-bottom));
} }
.card-content { .card-content {

View File

@ -1158,7 +1158,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -1251,7 +1251,7 @@ export class HaSceneEditor extends PreventUnsavedMixin(
} }
ha-fab { ha-fab {
position: relative; position: relative;
bottom: calc(-80px - env(safe-area-inset-bottom)); bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s; transition: bottom 0.3s;
} }
ha-alert { ha-alert {

View File

@ -1056,7 +1056,7 @@ export class HaScriptEditor extends SubscribeMixin(
} }
ha-fab { ha-fab {
position: relative; position: relative;
bottom: calc(-80px - env(safe-area-inset-bottom)); bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s; transition: bottom 0.3s;
} }
ha-fab.dirty { ha-fab.dirty {

View File

@ -1214,7 +1214,6 @@ ${rejected
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add"); this._bulkLabel(label.label_id, "add");
return label;
}, },
}); });
}; };

View File

@ -236,10 +236,10 @@ class DialogExposeEntity extends LitElement {
@media all and (max-width: 500px), all and (max-height: 500px) { @media all and (max-width: 500px), all and (max-height: 500px) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: calc( --mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-max-width: calc( --mdc-dialog-max-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-min-height: 100%; --mdc-dialog-min-height: 100%;
--mdc-dialog-max-height: 100%; --mdc-dialog-max-height: 100%;

View File

@ -130,7 +130,7 @@ class DialogHomeZoneDetail extends LitElement {
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: calc( --mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
} }
} }

View File

@ -215,7 +215,7 @@ class DialogZoneDetail extends LitElement {
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: calc( --mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
} }
} }

View File

@ -238,6 +238,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
? undefined ? undefined
: "/config"} : "/config"}
.tabs=${configSections.areas} .tabs=${configSections.areas}
has-fab
> >
${this.narrow ${this.narrow
? html` ? html`
@ -581,9 +582,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
min-height: 100%; min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
ha-card {
margin-bottom: 100px;
}
ha-tooltip { ha-tooltip {
display: block; display: block;
} }

View File

@ -601,19 +601,19 @@ class HaPanelDevAction extends LitElement {
css` css`
.content { .content {
padding: 16px; padding: 16px;
padding: max(16px, env(safe-area-inset-top)) padding: max(16px, var(--safe-area-inset-top))
max(16px, env(safe-area-inset-right)) max(16px, var(--safe-area-inset-right))
max(16px, env(safe-area-inset-bottom)) max(16px, var(--safe-area-inset-bottom))
max(16px, env(safe-area-inset-left)); max(16px, var(--safe-area-inset-left));
max-width: 1200px; max-width: 1200px;
margin: auto; margin: auto;
} }
.button-row { .button-row {
padding: 8px 16px; padding: 8px 16px;
padding: max(8px, env(safe-area-inset-top)) padding: max(8px, var(--safe-area-inset-top))
max(16px, env(safe-area-inset-right)) max(16px, var(--safe-area-inset-right))
max(8px, env(safe-area-inset-bottom)) max(8px, var(--safe-area-inset-bottom))
max(16px, env(safe-area-inset-left)); max(16px, var(--safe-area-inset-left));
border-top: 1px solid var(--divider-color); border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
background: var(--card-background-color); background: var(--card-background-color);

View File

@ -236,10 +236,10 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
css` css`
.content { .content {
padding: 28px 20px 16px; padding: 28px 20px 16px;
padding: max(28px, calc(12px + env(safe-area-inset-top))) padding: max(28px, calc(12px + var(--safe-area-inset-top)))
max(20px, calc(4px + env(safe-area-inset-right))) max(20px, calc(4px + var(--safe-area-inset-right)))
max(16px, env(safe-area-inset-bottom)) max(16px, var(--safe-area-inset-bottom))
max(20px, calc(4px + env(safe-area-inset-left))); max(20px, calc(4px + var(--safe-area-inset-left)));
max-width: 1040px; max-width: 1040px;
margin: 0 auto; margin: 0 auto;
} }

View File

@ -148,10 +148,10 @@ class HaPanelDevEvent extends LitElement {
.content { .content {
gap: 16px; gap: 16px;
padding: 16px; padding: 16px;
padding: max(16px, env(safe-area-inset-top)) padding: max(16px, var(--safe-area-inset-top))
max(16px, env(safe-area-inset-right)) max(16px, var(--safe-area-inset-right))
max(16px, env(safe-area-inset-bottom)) max(16px, var(--safe-area-inset-bottom))
max(16px, env(safe-area-inset-left)); max(16px, var(--safe-area-inset-left));
max-width: 1200px; max-width: 1200px;
margin: auto; margin: auto;
} }

View File

@ -129,7 +129,7 @@ class PanelDeveloperTools extends LitElement {
z-index: 4; z-index: 4;
background-color: var(--app-header-background-color); background-color: var(--app-header-background-color);
width: var(--mdc-top-app-bar-width, 100%); width: var(--mdc-top-app-bar-width, 100%);
padding-top: env(safe-area-inset-top); padding-top: var(--safe-area-inset-top);
color: var(--app-header-text-color, white); color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none); border-bottom: var(--app-header-border-bottom, none);
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none); -webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
@ -157,9 +157,9 @@ class PanelDeveloperTools extends LitElement {
developer-tools-router { developer-tools-router {
display: block; display: block;
padding-top: calc( padding-top: calc(
var(--header-height) + 48px + env(safe-area-inset-top) var(--header-height) + 48px + var(--safe-area-inset-top)
); );
padding-bottom: calc(env(safe-area-inset-bottom)); padding-bottom: calc(var(--safe-area-inset-bottom));
flex: 1 1 100%; flex: 1 1 100%;
max-width: 100%; max-width: 100%;
} }

View File

@ -596,10 +596,10 @@ class HaPanelDevState extends LitElement {
-moz-user-select: initial; -moz-user-select: initial;
display: block; display: block;
padding: 16px; padding: 16px;
padding: max(16px, env(safe-area-inset-top)) padding: max(16px, var(--safe-area-inset-top))
max(16px, env(safe-area-inset-right)) max(16px, var(--safe-area-inset-right))
max(16px, env(safe-area-inset-bottom)) max(16px, var(--safe-area-inset-bottom))
max(16px, env(safe-area-inset-left)); max(16px, var(--safe-area-inset-left));
} }
ha-textfield { ha-textfield {

View File

@ -800,10 +800,10 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
ha-dialog { ha-dialog {
--mdc-dialog-min-width: calc( --mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-max-width: calc( --mdc-dialog-max-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left) 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
); );
--mdc-dialog-min-height: 100%; --mdc-dialog-min-height: 100%;
--mdc-dialog-max-height: 100%; --mdc-dialog-max-height: 100%;

View File

@ -276,17 +276,17 @@ ${type === "object"
.content { .content {
gap: 16px; gap: 16px;
padding: 16px; padding: 16px;
padding: max(16px, env(safe-area-inset-top)) padding: max(16px, var(--safe-area-inset-top))
max(16px, env(safe-area-inset-right)) max(16px, var(--safe-area-inset-right))
max(16px, env(safe-area-inset-bottom)) max(16px, var(--safe-area-inset-bottom))
max(16px, env(safe-area-inset-left)); max(16px, var(--safe-area-inset-left));
} }
.content.horizontal { .content.horizontal {
--code-mirror-max-height: calc( --code-mirror-max-height: calc(
100vh - var(--header-height) - (var(--ha-line-height-normal) * 3) - 100vh - var(--header-height) - (var(--ha-line-height-normal) * 3) -
(1em * 2) - (max(16px, env(safe-area-inset-top)) * 2) - (1em * 2) - (max(16px, var(--safe-area-inset-top)) * 2) -
(max(16px, env(safe-area-inset-bottom)) * 2) - (max(16px, var(--safe-area-inset-bottom)) * 2) -
(var(--ha-card-border-width, 1px) * 2) - 179px (var(--ha-card-border-width, 1px) * 2) - 179px
); );
} }

View File

@ -251,10 +251,10 @@ export class DeveloperYamlConfig extends LitElement {
.content { .content {
padding: 28px 20px 16px; padding: 28px 20px 16px;
padding: max(28px, calc(12px + env(safe-area-inset-top))) padding: max(28px, calc(12px + var(--safe-area-inset-top)))
max(20px, calc(4px + env(safe-area-inset-right))) max(20px, calc(4px + var(--safe-area-inset-right)))
max(16px, env(safe-area-inset-bottom)) max(16px, var(--safe-area-inset-bottom))
max(20px, calc(4px + env(safe-area-inset-left))); max(20px, calc(4px + var(--safe-area-inset-left)));
max-width: 1040px; max-width: 1040px;
margin: 0 auto; margin: 0 auto;
} }

View File

@ -487,7 +487,7 @@ class PanelEnergy extends LitElement {
position: fixed; position: fixed;
top: 0; top: 0;
width: var(--mdc-top-app-bar-width, 100%); width: var(--mdc-top-app-bar-width, 100%);
padding-top: env(safe-area-inset-top); padding-top: var(--safe-area-inset-top);
z-index: 4; z-index: 4;
transition: box-shadow 200ms linear; transition: box-shadow 200ms linear;
display: flex; display: flex;
@ -528,12 +528,12 @@ class PanelEnergy extends LitElement {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
box-sizing: border-box; box-sizing: border-box;
padding-top: calc(var(--header-height) + env(safe-area-inset-top)); padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-left: env(safe-area-inset-left); padding-left: var(--safe-area-inset-left);
padding-right: env(safe-area-inset-right); padding-right: var(--safe-area-inset-right);
padding-inline-start: env(safe-area-inset-left); padding-inline-start: var(--safe-area-inset-left);
padding-inline-end: env(safe-area-inset-right); padding-inline-end: var(--safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom); padding-bottom: var(--safe-area-inset-bottom);
} }
hui-view { hui-view {
flex: 1 1 100%; flex: 1 1 100%;

View File

@ -626,7 +626,7 @@ class HaPanelHistory extends LitElement {
.content { .content {
padding: 0 16px 16px; padding: 0 16px 16px;
padding-bottom: max(env(safe-area-inset-bottom), 16px); padding-bottom: max(var(--safe-area-inset-bottom), 16px);
} }
:host([virtualize]) { :host([virtualize]) {

View File

@ -184,13 +184,11 @@ export class HuiEnergyDevicesDetailGraphCard
...commonOptions, ...commonOptions,
legend: { legend: {
show: true, show: true,
type: "scroll", type: "custom",
animationDurationUpdate: 400,
selected: this._hiddenStats.reduce((acc, stat) => { selected: this._hiddenStats.reduce((acc, stat) => {
acc[stat] = false; acc[stat] = false;
return acc; return acc;
}, {}), }, {}),
icon: "circle",
}, },
grid: { grid: {
top: 15, top: 15,

View File

@ -465,7 +465,7 @@ export class HuiEnergyUsageGraphCard
} }
data.push({ data.push({
id: compare ? "compare-" + statId : statId, id: `${compare ? "compare-" : ""}${statId}-${type}`,
type: "bar", type: "bar",
cursor: "default", cursor: "default",
name: name:

View File

@ -60,7 +60,7 @@ export class HuiGraphFooterEditor
.configValue=${"entity"} .configValue=${"entity"}
.includeDomains=${includeDomains} .includeDomains=${includeDomains}
.required=${true} .required=${true}
@change=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-entity-picker> ></ha-entity-picker>
<div class="side-by-side"> <div class="side-by-side">
<ha-formfield <ha-formfield

Some files were not shown because too many files have changed in this diff Show More