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 {
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;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
@ -76,7 +76,7 @@
padding-top: 48px;
}
.ohf-logo {
margin: max(env(safe-area-inset-bottom), 48px) 0;
margin: max(var(--safe-area-inset-bottom), 48px) 0;
display: flex;
flex-direction: column;
align-items: center;

View File

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

View File

@ -610,7 +610,7 @@ export class DialogHassioNetwork
display: flex;
justify-content: space-between;
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);
}
.warning {

View File

@ -32,9 +32,9 @@
"@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.0",
"@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.10",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.7",
"@codemirror/view": "6.36.8",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11",
@ -137,7 +137,6 @@
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.3",
"vis-data": "7.1.9",
"vis-network": "9.1.9",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
@ -160,8 +159,8 @@
"@octokit/plugin-retry": "7.2.1",
"@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "1.1.2",
"@rspack/cli": "1.3.9",
"@rspack/core": "1.3.9",
"@rspack/cli": "1.3.10",
"@rspack/core": "1.3.10",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11",
@ -185,7 +184,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.26.0",
"eslint": "9.27.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.5",
"eslint-import-resolver-webpack": "0.13.10",
@ -219,7 +218,7 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.8.3",
"typescript-eslint": "8.32.0",
"typescript-eslint": "8.32.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.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 })
public expandLegend?: boolean;
@property({ attribute: false }) public extraComponents?: any[];
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@state()
@consume({ context: themesContext, subscribe: true })
@ -106,48 +107,49 @@ export class HaChartBase extends LitElement {
})
);
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if (
!this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
if (!this.options?.dataZoom) {
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if (
!this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = true;
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) => {
if (
this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
const handleKeyUp = (ev: KeyboardEvent) => {
if (
this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: false,
});
}
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: false,
});
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
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() {
@ -191,16 +193,19 @@ export class HaChartBase extends LitElement {
<div class="chart"></div>
</div>
${this._renderLegend()}
${this._isZoomed
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<div class="chart-controls">
${this._isZoomed
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
</div>
`;
}
@ -210,7 +215,7 @@ export class HaChartBase extends LitElement {
return nothing;
}
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
if (!legend.show) {
if (!legend.show || legend.type !== "custom") {
return nothing;
}
const datasets = ensureArray(this.data);
@ -315,7 +320,9 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => {
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) {
this.chart.getZr().on("click", (e: ECElementEvent) => {
if (!e.zrByTouch) {
@ -410,6 +417,12 @@ export class HaChartBase extends LitElement {
} as XAXisOption;
});
}
let legend = this.options?.legend;
if (legend) {
legend = ensureArray(legend).map((l) =>
l.type === "custom" ? { show: false } : l
);
}
const options = {
animation: !this._reducedMotion,
darkMode: this._themes.darkMode ?? false,
@ -424,7 +437,7 @@ export class HaChartBase extends LitElement {
iconStyle: { opacity: 0 },
},
...this.options,
legend: { show: false },
legend,
xAxis,
};
@ -725,16 +738,26 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.zoom-reset {
.chart-controls {
position: absolute;
top: 16px;
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);
border-radius: 4px;
--mdc-icon-button-size: 32px;
color: var(--primary-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 {
max-height: 60%;
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,
legend: {
type: "custom",
show: this.showNames,
},
grid: {

View File

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

View File

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

View File

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

View File

@ -403,7 +403,8 @@ export class HaEntityPicker extends LitElement {
}
public async open() {
this._picker?.open();
await this.updateComplete;
await this._picker?.open();
}
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 type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
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 { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { domainToName } from "../../data/integration";
import {
getStatisticIds,
getStatisticLabel,
type StatisticsMetaData,
} 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-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button";
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-statistic-combo-box";
import type { HaStatisticComboBox } from "./ha-statistic-combo-box";
import "./state-badge";
interface StatisticItem {
primary: string;
secondary?: string;
iconPath?: string;
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
const MISSING_ID = "___missing-entity___";
type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticComboBoxItem extends PickerComboBoxItem {
statistic_id?: string;
stateObj?: HassEntity;
type?: StatisticItemType;
}
@customElement("ha-statistic-picker")
@ -70,6 +70,9 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[];
@property({ attribute: false }) public helpMissingEntityUrl =
"/more-info/statistics/";
/**
* Show only statistics natively stored with these units of measurements.
* @type {Array}
@ -114,11 +117,7 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@query("#anchor") private _anchor?: HaMdListItem;
@query("#input") private _input?: HaStatisticComboBox;
@state() private _opened = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public willUpdate(changedProps: PropertyValues) {
if (
@ -133,6 +132,165 @@ export class HaStatisticPicker extends LitElement {
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(
(statisticId: string, statisticIds: StatisticsMetaData[]) => {
if (!statisticIds) {
@ -144,26 +302,11 @@ export class HaStatisticPicker extends LitElement {
}
);
private _renderContent() {
const statisticId = this.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>
`;
}
private _valueRenderer: PickerValueRenderer = (value) => {
const statisticId = value;
const item = this._computeItem(statisticId);
const showClearIcon =
!this.required && !this.disabled && !this.hideClearIcon;
return html`
${item.stateObj
? html`
@ -173,29 +316,19 @@ export class HaStatisticPicker extends LitElement {
slot="start"
></state-badge>
`
: item.iconPath
? html`<ha-svg-icon
slot="start"
.path=${item.iconPath}
></ha-svg-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}
${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];
if (stateObj) {
@ -211,11 +344,24 @@ export class HaStatisticPicker extends LitElement {
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
id: statisticId,
statistic_id: statisticId,
primary,
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";
if (type === "external") {
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(this.hass, statisticId, statistic);
const domain = statisticId.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
return {
id: statisticId,
statistic_id: statisticId,
primary: label,
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 {
primary: statisticId,
iconPath: mdiShape,
id: statisticId,
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`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container">
${!this._opened
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-combo-box-item
.disabled=${this.disabled}
id="anchor"
type="button"
compact
@click=${this._showPicker}
>
${this._renderContent()}
</ha-combo-box-item>
<ha-svg-icon
style="margin: 0 4px"
slot="start"
.path=${item.icon_path}
></ha-svg-icon>
`
: html`
<ha-statistic-combo-box
id="input"
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomEntity=${this.allowCustomEntity}
.label=${this.hass.localize("ui.common.search")}
.value=${this.value}
.includeStatisticsUnitOfMeasurement=${this
.includeStatisticsUnitOfMeasurement}
.includeUnitClass=${this.includeUnitClass}
.includeDeviceClass=${this.includeDeviceClass}
.statisticTypes=${this.statisticTypes}
.statisticIds=${this.statisticIds}
.excludeStatistics=${this.excludeStatistics}
hide-clear-icon
@opened-changed=${this._debounceOpenedChanged}
@input=${stopPropagation}
></ha-statistic-combo-box>
`}
${this._renderHelper()}
</div>
: item.stateObj
? html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
<span slot="headline">${item.primary} </span>
${item.secondary || item.type
? html`<span slot="supporting-text"
>${item.secondary} - ${item.type}</span
>`
: nothing}
${item.statistic_id && showEntityId
? html`<span slot="supporting-text" class="code">
${item.statistic_id}
</span>`
: nothing}
</ha-combo-box-item>
`;
};
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() {
return this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
private _clear(e) {
e.stopPropagation();
this.value = undefined;
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "change");
}
private async _showPicker() {
if (this.disabled) {
if (value === MISSING_ID) {
window.open(
documentationUrl(this.hass, this.helpMissingEntityUrl),
"_blank"
);
return;
}
this._opened = true;
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete;
this._input?.focus();
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;
}
`,
];
await this._picker?.open();
}
}

View File

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

View File

@ -5,8 +5,11 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../types";
import {
type PipelineRunEvent,
runAssistPipeline,
type AssistPipeline,
type ConversationChatLogAssistantDelta,
type ConversationChatLogToolResultDelta,
} from "../data/assist_pipeline";
import { supportsFeature } from "../common/entity/supports-feature";
import { ConversationEntityFeature } from "../data/conversation";
@ -90,7 +93,7 @@ export class HaAssistChat extends LitElement {
super.disconnectedCallback();
this._audioRecorder?.close();
this._audioRecorder = undefined;
this._audio?.pause();
this._unloadAudio();
this._conversation = [];
this._conversationId = null;
}
@ -109,25 +112,24 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html`
${controlHA
? nothing
: html`
<ha-alert>
${this.hass.localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
`}
<div class="messages">
<div class="messages-container" id="scroll-container">
${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html`
<div class="messages" id="scroll-container">
${controlHA
? nothing
: html`
<ha-alert>
${this.hass.localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
`}
<div class="spacer"></div>
${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html`
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
`
)}
</div>
)}
</div>
<div class="input" slot="primaryAction">
<ha-textfield
@ -273,8 +275,8 @@ export class HaAssistChat extends LitElement {
}
private async _startListening() {
this._unloadAudio();
this._processing = true;
this._audio?.pause();
if (!this._audioRecorder) {
this._audioRecorder = new AudioRecorder((audio) => {
if (this._audioBuffer) {
@ -293,27 +295,36 @@ export class HaAssistChat extends LitElement {
await this._audioRecorder.start();
this._addMessage(userMessage);
this.requestUpdate("_audioRecorder");
let continueConversation = false;
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
const hassMessageProcesser = this._createAddHassMessageProcessor();
try {
const unsub = await runAssistPipeline(
this.hass,
(event) => {
(event: PipelineRunEvent) => {
if (event.type === "run-start") {
this._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
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.
for (const buffer of this._audioBuffer) {
this._sendAudioChunk(buffer);
@ -322,91 +333,26 @@ export class HaAssistChat extends LitElement {
}
// 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._stopListening();
userMessage.text = event.data.stt_output.text;
this.requestUpdate("_conversation");
// To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(hassMessage);
}
if (event.type === "intent-progress") {
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") {
// Add the response message placeholder to the chat when we know the STT is done
hassMessageProcesser.addMessage();
} else if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
} else if (event.type === "run-end") {
this._stt_binary_handler_id = undefined;
unsub();
}
if (event.type === "error") {
} else if (event.type === "error") {
this._unloadAudio();
this._stt_binary_handler_id = undefined;
if (userMessage.text === "…") {
userMessage.text = event.data.message;
userMessage.error = true;
} else {
hassMessage.text = event.data.message;
hassMessage.error = true;
hassMessageProcesser.setError(event.data.message);
}
this._stopListening();
this.requestUpdate("_conversation");
@ -464,90 +410,33 @@ export class HaAssistChat extends LitElement {
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 = () => {
this._audio?.removeAttribute("src");
if (!this._audio) {
return;
}
this._audio.pause();
this._audio.removeAttribute("src");
this._audio = undefined;
};
private async _processText(text: string) {
this._unloadAudio();
this._processing = true;
this._audio?.pause();
this._addMessage({ who: "user", text });
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
this._addMessage(hassMessage);
const hassMessageProcesser = this._createAddHassMessageProcessor();
hassMessageProcesser.addMessage();
try {
const unsub = await runAssistPipeline(
this.hass,
(event) => {
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// 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.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
}
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();
}
if (event.type === "error") {
hassMessage.text = event.data.message;
hassMessage.error = true;
this.requestUpdate("_conversation");
hassMessageProcesser.setError(event.data.message);
unsub();
}
},
@ -560,20 +449,126 @@ export class HaAssistChat extends LitElement {
}
);
} catch {
hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error");
hassMessage.error = true;
this.requestUpdate("_conversation");
hassMessageProcesser.setError(
this.hass.localize("ui.dialogs.voice_command.error")
);
} finally {
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`
:host {
flex: 1;
display: flex;
flex-direction: column;
}
ha-alert {
margin-bottom: 8px;
}
ha-textfield {
display: block;
}
@ -581,17 +576,14 @@ export class HaAssistChat extends LitElement {
flex: 1;
display: block;
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;
max-height: 100%;
display: flex;
flex-direction: column;
padding: 0 12px 16px;
}
.spacer {
flex: 1;
}
.message {
white-space: pre-line;
@ -601,6 +593,9 @@ export class HaAssistChat extends LitElement {
padding: 8px;
border-radius: 15px;
}
.message:last-child {
margin-bottom: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.message {
@ -619,7 +614,7 @@ export class HaAssistChat extends LitElement {
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
float: var(--float-end);
align-self: flex-end;
text-align: right;
border-bottom-right-radius: 0px;
background-color: var(--chat-background-color-user, var(--primary-color));
@ -631,7 +626,7 @@ export class HaAssistChat extends LitElement {
margin-right: 24px;
margin-inline-end: 24px;
margin-inline-start: initial;
float: var(--float-start);
align-self: flex-start;
border-bottom-left-radius: 0px;
background-color: var(
--chat-background-color-hass,

View File

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

View File

@ -90,7 +90,7 @@ export class HaDialog extends DialogBase {
}
.mdc-dialog__actions {
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) {
flex: var(--secondary-action-button-flex, unset);
@ -117,7 +117,7 @@ export class HaDialog extends DialogBase {
:host([hideactions]) .mdc-dialog .mdc-dialog__content {
padding-bottom: max(
var(--dialog-content-padding, 24px),
env(safe-area-inset-bottom)
var(--safe-area-inset-bottom)
);
}
.mdc-dialog .mdc-dialog__surface {

View File

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

View File

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

View File

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

View File

@ -25,6 +25,7 @@ export interface DisplayItem {
value: string;
label: string;
description?: string;
disableSorting?: boolean;
}
export interface DisplayValue {
@ -50,6 +51,9 @@ export class HaItemDisplayEditor extends LitElement {
@property({ type: Boolean, attribute: "show-navigation-button" })
public showNavigationButton = false;
@property({ type: Boolean, attribute: "dont-sort-visible" })
public dontSortVisible = false;
@property({ attribute: false })
public value: DisplayValue = {
order: [],
@ -122,9 +126,15 @@ export class HaItemDisplayEditor extends LitElement {
private _visibleItems = memoizeOne(
(items: DisplayItem[], hidden: string[], order: string[]) => {
const compare = orderCompare(order);
return items
.filter((item) => !hidden.includes(item.value))
.sort((a, b) => compare(a.value, b.value));
const visibleItems = items.filter((item) => !hidden.includes(item.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: DisplayItem, _idx) => {
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`
<ha-md-list-item
type=${ifDefined(
@ -172,14 +189,14 @@ export class HaItemDisplayEditor extends LitElement {
.value=${value}
class=${classMap({
hidden: !isVisible,
draggable: isVisible,
draggable: isVisible && !disableSorting,
})}
>
<span slot="headline">${label}</span>
${description
? html`<span slot="supporting-text">${description}</span>`
: nothing}
${isVisible
${isVisible && !disableSorting
? html`
<ha-svg-icon
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 { PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
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 {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
@ -19,30 +17,19 @@ import {
createLabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
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-icon-button";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
type ScorableLabelItem = ScorableTextItem & LabelRegistryEntry;
const ADD_NEW_ID = "___ADD_NEW___";
const NO_LABELS_ID = "___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>
`;
const NO_LABELS = "___NO_LABELS___";
@customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) {
@ -101,24 +88,13 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _labels?: LabelRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
await this._picker?.open();
}
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(
(
labels: LabelRegistryEntry[],
areas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
labels: LabelRegistryEntry[] | undefined,
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
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 inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
@ -274,7 +291,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
if (areaIds) {
areaIds.forEach((areaId) => {
const area = areas[areaId];
const area = haAreas[areaId];
area.labels.forEach((label) => usedLabels.add(label));
});
}
@ -291,192 +308,144 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
);
}
if (!outputLabels.length) {
outputLabels = [
{
label_id: NO_LABELS_ID,
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
color: null,
description: null,
created_at: 0,
modified_at: 0,
},
];
}
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: label.label_id,
primary: label.name,
icon: label.icon || undefined,
icon_path: label.icon ? undefined : mdiLabel,
sorting_label: label.name,
search_labels: [label.name, label.label_id, label.description].filter(
(v): v is string => Boolean(v)
),
}));
return noAdd
? 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,
},
];
return items;
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._labels) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const items = this._getLabels(
this._labels!,
this.hass.areas,
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
).map((label) => ({
...label,
strings: [label.label_id, label.name],
}));
private _getItems = () =>
this._getLabels(
this._labels,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeLabels
);
this.comboBox.items = items;
this.comboBox.filteredItems = items;
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
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 {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
return html`
<ha-combo-box
<ha-generic-picker
.hass=${this.hass}
.helper=${this.helper}
item-value-path="label_id"
item-id-path="label_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.label")
: this.label}
.placeholder=${this.placeholder
? 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}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.label-picker.no_match"
)}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-combo-box>
</ha-generic-picker>
`;
}
private _filterChanged(ev: CustomEvent): void {
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>) {
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === NO_LABELS_ID) {
newValue = "";
this.comboBox.setInputValue("");
const value = ev.detail.value;
if (value === NO_LABELS) {
return;
}
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
if (!value) {
this._setValue(undefined);
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, {
entry: undefined,
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
const labels = [...this._labels!, label];
this.comboBox.filteredItems = this._getLabels(
labels,
this.hass.areas!,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(label.label_id);
return label;
},
});
showLabelDetailDialog(this, {
suggestedName: suggestedName,
createEntry: async (values) => {
try {
const label = await createLabelRegistryEntry(this.hass, values);
this._setValue(label.label_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.label-picker.failed_create_label"
),
text: err.message,
});
}
},
});
return;
}
this._suggestion = undefined;
this.comboBox.setInputValue("");
this._setValue(value);
}
private _setValue(value?: string) {

View File

@ -122,6 +122,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
this.hass.locale.language
);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${labels?.length
? html`<ha-chip-set>
${repeat(
@ -157,9 +158,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.add_label")
: this.label}
.placeholder=${this.placeholder}
.excludeLabels=${this.value}
@value-changed=${this._labelChanged}
@ -182,12 +180,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
showLabelDetailDialog(this, {
entry: label,
updateEntry: async (values) => {
const updated = await updateLabelRegistryEntry(
this.hass,
label.label_id,
values
);
return updated;
await updateLabelRegistryEntry(this.hass, label.label_id, values);
},
});
}
@ -219,6 +212,10 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
--ha-input-chip-selected-container-opacity: 0.5;
--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) {
:host(:not([type="alert"])) {
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(
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%;
max-height: 100%;

View File

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

View File

@ -1,11 +1,9 @@
import "@material/mwc-button/mwc-button";
import {
mdiBell,
mdiCalendar,
mdiCellphoneCog,
mdiChartBox,
mdiClipboardList,
mdiClose,
mdiCog,
mdiFormatListBulletedType,
mdiHammer,
@ -13,12 +11,11 @@ import {
mdiMenu,
mdiMenuOpen,
mdiPlayBoxMultiple,
mdiPlus,
mdiTooltipAccount,
mdiViewDashboard,
} from "@mdi/js";
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 {
customElement,
@ -29,7 +26,6 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { storage } from "../common/decorators/storage";
import { fireEvent } from "../common/dom/fire_event";
import { toggleAttribute } from "../common/dom/toggle_attribute";
import { stringCompare } from "../common/string/compare";
@ -40,6 +36,7 @@ import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs";
import type { UpdateEntity } from "../data/update";
import { updateCanInstall } from "../data/update";
import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles";
@ -49,8 +46,6 @@ import "./ha-icon-button";
import "./ha-md-list";
import "./ha-md-list-item";
import type { HaMdListItem } from "./ha-md-list-item";
import "./ha-menu-button";
import "./ha-sortable";
import "./ha-svg-icon";
import "./user/ha-user-badge";
@ -67,7 +62,7 @@ const SORT_VALUE_URL_PATHS = {
config: 11,
};
const PANEL_ICONS = {
export const PANEL_ICONS = {
calendar: mdiCalendar,
"developer-tools": mdiHammer,
energy: mdiLightningBolt,
@ -140,7 +135,7 @@ const defaultPanelSorter = (
return stringCompare(a.title!, b.title!, language);
};
const computePanels = memoizeOne(
export const computePanels = memoizeOne(
(
panels: HomeAssistant["panels"],
defaultPanel: HomeAssistant["defaultPanel"],
@ -192,8 +187,11 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@property({ attribute: "always-expand", type: Boolean })
public alwaysExpand = false;
@property({ attribute: "edit-mode", type: Boolean })
public editMode = false;
@property({ attribute: false })
public panelOrder!: string[];
@property({ attribute: false })
public hiddenPanels!: string[];
@state() private _notifications?: PersistentNotification[];
@ -207,26 +205,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private _recentKeydownActiveUntil = 0;
private _editStyleLoaded = false;
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;
public hassSubscribe(): UnsubscribeFunc[] {
@ -270,13 +250,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
changedProps.has("expanded") ||
changedProps.has("narrow") ||
changedProps.has("alwaysExpand") ||
changedProps.has("editMode") ||
changedProps.has("_externalConfig") ||
changedProps.has("_updatesCount") ||
changedProps.has("_issuesCount") ||
changedProps.has("_notifications") ||
changedProps.has("_hiddenPanels") ||
changedProps.has("_panelOrder")
changedProps.has("hiddenPanels") ||
changedProps.has("panelOrder")
) {
return true;
}
@ -322,9 +301,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
if (changedProps.has("alwaysExpand")) {
toggleAttribute(this, "expanded", this.alwaysExpand);
}
if (changedProps.has("editMode") && this.editMode) {
this._editModeActivated();
}
if (!changedProps.has("hass")) {
return;
}
@ -374,8 +350,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
class="menu"
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: !this.editMode,
disabled: this.editMode,
hasHold: true,
})}
>
${!this.narrow
@ -389,11 +364,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
></ha-icon-button>
`
: ""}
${this.editMode
? html`<mwc-button outlined @click=${this._closeEditMode}>
${this.hass.localize("ui.sidebar.done")}
</mwc-button>`
: html`<div class="title">Home Assistant</div>`}
<div class="title">Home Assistant</div>
</div>`;
}
@ -401,14 +372,13 @@ class HaSidebar extends SubscribeMixin(LitElement) {
const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels,
this.hass.defaultPanel,
this._panelOrder,
this._hiddenPanels,
this.panelOrder,
this.hiddenPanels,
this.hass.locale
);
// prettier-ignore
return html`
<ha-sortable .disabled=${!this.editMode} draggable-selector=".draggable" @item-moved=${this._panelMoved}>
<ha-md-list
class="ha-scrollbar"
@focusin=${this._listboxFocusIn}
@ -416,22 +386,15 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown}
>
${this.editMode
? this._renderPanelsEdit(beforeSpacer, selectedPanel)
: this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderExternalConfiguration()}
</ha-md-list>
</ha-sortable>
`;
}
private _renderPanels(
panels: PanelInfo[],
selectedPanel: string,
sortable = false
) {
private _renderPanels(panels: PanelInfo[], selectedPanel: string) {
return panels.map((panel) =>
this._renderPanel(
panel.url_path,
@ -444,36 +407,26 @@ class HaSidebar extends SubscribeMixin(LitElement) {
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]
: undefined,
selectedPanel,
sortable
selectedPanel
)
);
}
private _renderPanelsEdit(beforeSpacer: PanelInfo[], selectedPanel: string) {
return html`
${this._renderPanels(beforeSpacer, selectedPanel, true)}
${this._renderSpacer()}${this._renderHiddenPanels()}
`;
}
private _renderPanel(
urlPath: string,
title: string | null,
icon: string | null | undefined,
iconPath: string | null | undefined,
selectedPanel: string,
sortable = false
selectedPanel: string
) {
return urlPath === "config"
? this._renderConfiguration(title, selectedPanel)
: html`
<ha-md-list-item
.href=${this.editMode ? undefined : `/${urlPath}`}
.href=${`/${urlPath}`}
type="link"
class=${classMap({
selected: selectedPanel === urlPath,
draggable: this.editMode && sortable,
})}
@mouseenter=${this._itemMouseEnter}
@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-icon slot="start" .icon=${icon}></ha-icon>`}
<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>
`;
}
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() {
return html`<div class="divider"></div>`;
}
@ -677,48 +559,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return;
}
fireEvent(this, "hass-edit-sidebar", { editMode: true });
showEditSidebarDialog(this, {
saveCallback: this._saveSidebar,
});
}
private async _editModeActivated() {
await this._loadEditStyle();
}
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 _saveSidebar = (order: string[], hidden: string[]) => {
fireEvent(this, "hass-edit-sidebar", {
order,
hidden,
});
};
private _itemMouseEnter(ev: MouseEvent) {
// 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);
align-items: center;
padding-left: calc(4px + env(safe-area-inset-left));
padding-inline-start: calc(4px + env(safe-area-inset-left));
padding-left: calc(4px + var(--safe-area-inset-left));
padding-inline-start: calc(4px + var(--safe-area-inset-left));
padding-inline-end: initial;
}
:host([expanded]) .menu {
width: calc(256px + env(safe-area-inset-left));
width: calc(256px + var(--safe-area-inset-left));
}
.menu ha-icon-button {
color: var(--sidebar-icon-color);
@ -875,12 +726,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
:host([expanded]) .title {
display: initial;
}
:host([expanded]) .menu mwc-button {
margin: 0 8px;
}
.menu mwc-button {
width: 100%;
}
.hidden-panel {
display: none;
}
@ -890,11 +735,11 @@ class HaSidebar extends SubscribeMixin(LitElement) {
box-sizing: border-box;
height: calc(100% - var(--header-height) - 132px);
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;
background: none;
margin-left: env(safe-area-inset-left);
margin-left: var(--safe-area-inset-left);
}
ha-md-list-item {
@ -914,7 +759,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
:host([expanded]) ha-md-list-item {
width: 248px;
width: calc(248px - env(safe-area-inset-left));
width: calc(248px - var(--safe-area-inset-left));
}
ha-md-list-item.selected {
@ -949,7 +794,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
ha-md-list-item .item-text {
font-family: var(--ha-font-family-body);
display: none;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);

View File

@ -419,7 +419,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
id="input"
.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"
)}
.deviceFilter=${this.deviceFilter}
@ -438,7 +441,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
id="input"
.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"
)}
no-add

View File

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

View File

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

View File

@ -214,6 +214,7 @@ class BrowseMediaTTS extends LitElement {
item.media_content_id = `${
item.media_content_id.split("?")[0]
}?${query.toString()}`;
item.media_content_type = "audio/mp3";
item.can_play = true;
item.title = message;
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 { css, html, LitElement } from "lit";
import { property } from "lit/decorators";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { User } from "../../data/user";
import { fetchUsers } from "../../data/user";
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-list-item";
interface UserComboBoxItem extends PickerComboBoxItem {
user?: User;
}
@customElement("ha-user-picker")
class HaUserPicker extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public placeholder?: string;
@property({ attribute: false }) public noUserLabel?: string;
@property() public value = "";
@ -24,78 +33,124 @@ class HaUserPicker extends LitElement {
@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) {
return [];
}
return users
.filter((user) => !user.system_generated)
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass!.locale.language)
);
.map<UserComboBoxItem>((user) => ({
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 {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.user-picker.user");
return html`
<ha-select
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.disabled=${this.disabled}
.value=${this.value}
@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>
`
.notFoundLabel=${this.hass.localize(
"ui.components.user-picker.no_match"
)}
</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) {
super.firstUpdated(changedProps);
if (this.users === undefined) {
fetchUsers(this.hass!).then((users) => {
this.users = users;
});
}
private _valueChanged(ev) {
const value = ev.detail.value;
this.value = value;
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 {
interface HTMLElementTagNameMap {
"ha-user-picker": HaUserPicker;

View File

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

View File

@ -1,6 +1,5 @@
import type { HomeAssistant } from "../types";
import type { ConversationResult } from "./conversation";
import type { ResolvedMediaSource } from "./media_source";
import type { SpeechMetadata } from "./stt";
export interface AssistPipeline {
@ -53,10 +52,16 @@ interface PipelineRunStartEvent extends PipelineEventBase {
data: {
pipeline: string;
language: string;
conversation_id: string;
runner_data: {
stt_binary_handler_id: number | null;
timeout: number;
};
tts_output?: {
token: string;
url: string;
mime_type: string;
};
};
}
interface PipelineRunEndEvent extends PipelineEventBase {
@ -109,7 +114,7 @@ interface PipelineIntentStartEvent extends PipelineEventBase {
};
}
interface ConversationChatLogAssistantDelta {
export interface ConversationChatLogAssistantDelta {
role: "assistant";
content: string;
tool_calls: {
@ -119,7 +124,7 @@ interface ConversationChatLogAssistantDelta {
}[];
}
interface ConversationChatLogToolResultDelta {
export interface ConversationChatLogToolResultDelta {
role: "tool_result";
agent_id: string;
tool_call_id: string;
@ -156,7 +161,12 @@ interface PipelineTTSStartEvent extends PipelineEventBase {
interface PipelineTTSEndEvent extends PipelineEventBase {
type: "tts-end";
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;
bottom: 0;
margin: 0 -24px 0 -24px;
margin-bottom: calc(-1 * max(env(safe-area-inset-bottom), 24px));
padding-bottom: env(safe-area-inset-bottom);
margin-bottom: calc(-1 * max(var(--safe-area-inset-bottom), 24px));
padding-bottom: var(--safe-area-inset-bottom);
box-sizing: border-box;
display: flex;
flex-direction: column;

View File

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

View File

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

View File

@ -28,6 +28,7 @@ import {
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_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 { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
@ -41,6 +42,7 @@ import "../../components/ha-md-list-item";
import "../../components/ha-spinner";
import "../../components/ha-textfield";
import "../../components/ha-tip";
import { getConfigEntries } from "../../data/config_entries";
import { fetchHassioAddonsInfo } from "../../data/hassio/addon";
import { domainToName } from "../../data/integration";
import { getPanelNameTranslationKey } from "../../data/panel";
@ -50,6 +52,7 @@ import { HaFuse } from "../../resources/fuse";
import { haStyleDialog, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar";
@ -75,6 +78,8 @@ interface EntityItem extends QuickBarItem {
interface DeviceItem extends QuickBarItem {
deviceId: string;
domain?: string;
translatedDomain?: string;
area?: string;
}
@ -297,7 +302,8 @@ export class QuickBar extends LitElement {
this._commandItems =
this._commandItems || (await this._generateCommandItems());
} else if (this._mode === QuickBarMode.Device) {
this._deviceItems = this._deviceItems || this._generateDeviceItems();
this._deviceItems =
this._deviceItems || (await this._generateDeviceItems());
} else {
this._entityItems =
this._entityItems || (await this._generateEntityItems());
@ -344,10 +350,28 @@ export class QuickBar extends LitElement {
tabindex="0"
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>
${item.area
? html` <span slot="supporting-text">${item.area}</span> `
: nothing}
${item.translatedDomain
? html`<div slot="trailing-supporting-text" class="domain">
${item.translatedDomain}
</div>`
: nothing}
</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)
.filter((device) => !device.disabled_by)
.map((device) => {
const area = device.area_id
? this.hass.areas[device.area_id]
: undefined;
const deviceName = computeDeviceNameDisplay(device, this.hass);
const { area } = getDeviceContext(device, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const deviceItem = {
primaryText: computeDeviceNameDisplay(device, this.hass),
primaryText: deviceName,
deviceId: device.id,
area: area?.name,
area: areaName,
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 {
...deviceItem,
strings: [deviceItem.primaryText],
domain,
translatedDomain,
strings: [deviceName, areaName, domain, domainToName].filter(
Boolean
) as string[],
};
})
.sort((a, b) =>
@ -1036,6 +1081,11 @@ export class QuickBar extends LitElement {
white-space: nowrap;
}
ha-md-list-item img {
width: 32px;
height: 32px;
}
ha-tip {
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 { mainWindow } from "../common/dom/get_main_window";
import { navigate } from "../common/navigate";
import { showAutomationEditor } from "../data/automation";
import type { HomeAssistantMain } from "../layouts/home-assistant-main";
import type {
@ -50,7 +51,7 @@ export const addExternalBarCodeListener = (
};
};
const handleExternalMessage = (
export const handleExternalMessage = (
hassMainEl: HomeAssistantMain,
msg: EMIncomingMessageCommands
): boolean => {
@ -64,6 +65,14 @@ const handleExternalMessage = (
success: true,
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") {
fireEvent(hassMainEl, "hass-show-notifications");
bus.fireMessage({

View File

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

View File

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

View File

@ -44,7 +44,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-top {
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;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
@ -52,7 +52,7 @@
padding-top: 48px;
}
.ohf-logo {
margin: max(env(safe-area-inset-bottom), 48px) 0;
margin: max(var(--safe-area-inset-bottom), 48px) 0;
display: flex;
flex-direction: column;
align-items: center;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1415,7 +1415,6 @@ ${rejected
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
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"
)}
<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}
.disabled=${this.disabled}
.value=${this._wrapUsersInArray(context?.user_id)}

View File

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

View File

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

View File

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

View File

@ -223,7 +223,7 @@ class HaConfigSystemNavigation extends LitElement {
haStyle,
css`
: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 {
@ -235,7 +235,7 @@ class HaConfigSystemNavigation extends LitElement {
ha-card {
overflow: hidden;
margin-bottom: 24px;
margin-bottom: max(24px, env(safe-area-inset-bottom));
margin-bottom: max(24px, var(--safe-area-inset-bottom));
}
ha-card a {

View File

@ -387,10 +387,10 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
haStyle,
css`
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 {
margin-bottom: max(24px, env(safe-area-inset-bottom));
margin-bottom: max(24px, var(--safe-area-inset-bottom));
}
ha-config-section {
margin: auto;
@ -425,7 +425,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
}
ha-tip {
margin-bottom: max(env(safe-area-inset-bottom), 8px);
margin-bottom: max(var(--safe-area-inset-bottom), 8px);
}
.new {

View File

@ -240,10 +240,10 @@ class DialogMQTTDeviceDebugInfo extends LitElement {
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--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(
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) => {
const label = await createLabelRegistryEntry(this.hass, values);
this._bulkLabel(label.label_id, "add");
return label;
},
});
};

View File

@ -252,7 +252,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
display: flex;
padding: 8px 16px 8px 24px;
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);
border-top: 1px solid var(--divider-color);
position: sticky;

View File

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

View File

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

View File

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

View File

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

View File

@ -27,6 +27,18 @@ import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
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")
export class BluetoothAdvertisementMonitorPanel extends LitElement {
@ -220,6 +232,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
@collapsed-changed=${this._handleCollapseChanged}
filter=${this.address || ""}
clickable
.tabs=${bluetoothAdvertisementMonitorTabs}
></hass-tabs-subpage-data-table>
`;
}

View File

@ -31,6 +31,10 @@ class BluetoothConfigDashboardRouter extends HassRouterPage {
tag: "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
>
<a href="/config/bluetooth/visualization"
><ha-button>
${this.hass.localize(
"ui.panel.config.bluetooth.visualization"
)}
</ha-button></a
>
</div>
</ha-card>
<ha-card
@ -208,6 +215,10 @@ export class BluetoothConfigDashboard extends LitElement {
ha-card {
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 {
--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(
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 {
mdiAlertCircle,
mdiCheckCircle,
mdiFolderMultipleOutline,
mdiLan,
mdiNetwork,
mdiPlus,
mdiPencil,
mdiCheckCircle,
mdiAlertCircle,
mdiPlus,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-card";
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/buttons/ha-progress-button";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-alert";
import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import type {
ZHAConfiguration,
ZHANetworkSettings,
ZHANetworkBackupAndMetadata,
ZHANetworkSettings,
} from "../../../../../data/zha";
import {
fetchZHAConfiguration,
updateZHAConfiguration,
fetchZHANetworkSettings,
createZHANetworkBackup,
fetchDevices,
fetchZHAConfiguration,
fetchZHANetworkSettings,
updateZHAConfiguration,
} from "../../../../../data/zha";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
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";
@ -108,6 +108,7 @@ class ZHAConfigDashboard extends LitElement {
.route=${this.route}
.tabs=${zhaTabs}
back-path="/config/integrations"
has-fab
>
<ha-card class="content network-status">
${this._error

View File

@ -1,27 +1,26 @@
import "@material/mwc-button";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { Edge, EdgeOptions, Node } from "vis-network/peer/esm/vis-network";
import { Network } from "vis-network/peer/esm/vis-network";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/search-input";
import "../../../../../components/device/ha-device-picker";
import "../../../../../components/ha-button-menu";
import "../../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../../components/ha-checkbox";
import "../../../../../components/ha-formfield";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
import { customElement, property, state } from "lit/decorators";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import { mdiRefresh } from "@mdi/js";
import "../../../../../components/chart/ha-network-graph";
import type {
NetworkData,
NetworkNode,
NetworkLink,
} from "../../../../../components/chart/ha-network-graph";
import type { ZHADevice } from "../../../../../data/zha";
import { fetchDevices, refreshTopology } from "../../../../../data/zha";
import "../../../../../layouts/hass-tabs-subpage";
import type {
ValueChangedEvent,
HomeAssistant,
Route,
} from "../../../../../types";
import type { HomeAssistant, Route } from "../../../../../types";
import { formatAsPaddedHex } from "./functions";
import { zhaTabs } from "./zha-config-dashboard";
import { colorVariables } from "../../../../../resources/theme/color.globals";
import { navigate } from "../../../../../common/navigate";
@customElement("zha-network-visualization-page")
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: false })
public zoomedDeviceIdFromURL?: string;
@state()
private _networkData: NetworkData = {
nodes: [],
links: [],
categories: [],
};
@state()
private zoomedDeviceId?: string;
@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;
private _devices: ZHADevice[] = [];
protected firstUpdated(changedProperties: PropertyValues): void {
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) {
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() {
@ -140,363 +58,311 @@ export class ZHANetworkVisualizationPage extends LitElement {
.narrow=${this.narrow}
.isWide=${this.isWide}
.route=${this.route}
.header=${this.hass.localize(
"ui.panel.config.zha.visualization.header"
)}
header=${this.hass.localize("ui.panel.config.zha.visualization.header")}
>
${this.narrow
? html`
<div slot="header">
<search-input
.hass=${this.hass}
class="header"
@value-changed=${this._handleSearchChange}
.filter=${this._filter}
.label=${this.hass.localize(
"ui.panel.config.zha.visualization.highlight_label"
)}
>
</search-input>
</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"
<ha-network-graph
.hass=${this.hass}
.data=${this._networkData}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
<ha-icon-button
slot="button"
class="refresh-button"
.path=${mdiRefresh}
@click=${this._refreshTopology}
label=${this.hass.localize(
"ui.panel.config.zha.visualization.refresh_topology"
)}
.deviceFilter=${this._filterDevices}
@value-changed=${this._onZoomToDevice}
></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>
></ha-icon-button>
</ha-network-graph>
</hass-tabs-subpage>
`;
}
private async _fetchData() {
const devices = await fetchDevices(this.hass!);
this._devices = new Map(
devices.map((device: ZHADevice) => [device.ieee, device])
);
this._devicesByDeviceId = new Map(
devices.map((device: ZHADevice) => [device.device_reg_id, device])
);
this._updateDevices(devices);
this._devices = await fetchDevices(this.hass!);
this._networkData = this._createChartData(this._devices);
}
private _updateDevices(devices: ZHADevice[]) {
this._nodes = [];
const edges: Edge[] = [];
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data, name } = params as CallbackDataParams;
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) => {
this._nodes.push({
id: device.ieee,
label: this._buildLabel(device),
shape: this._getShape(device),
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;
}
});
const reverseValue = this._networkData.links.find(
(link) => link.source === source && link.target === target
)?.reverseValue;
if (reverseValue) {
return `${tooltipText}<br>${targetName}${sourceName} <b>LQI:</b> ${reverseValue}`;
}
});
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,
};
return tooltipText;
}
if (lqi > 128) {
return {
color: { color: "#e6b402", highlight: "#e6b402" },
width: 9,
length: length,
physics: false,
};
const device = this._devices.find((d) => d.ieee === (data as any).id);
if (!device) {
return name;
}
return {
color: { color: "#bfbfbf", highlight: "#bfbfbf" },
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("_", " ")}`;
let label = `<b>IEEE: </b>${device.ieee}`;
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b>${device.device_type.replace("_", " ")}`;
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) {
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 {
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) {
label += `\n<b>Area ID: </b>${device.area_id}`;
}
return label;
}
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" },
});
const area = this.hass.areas[device.area_id];
if (area) {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.area")}: </b>${area.name}`;
}
}
}
private _zoomOut() {
this._network!.fit({
nodes: [],
animation: { duration: 500, easingFunction: "easeOutQuad" },
});
}
return label;
};
private async _refreshTopology(): Promise<void> {
await refreshTopology(this.hass);
await this._fetchData();
}
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if (!this.hass) {
return false;
}
for (const parts of device.identifiers) {
for (const part of parts) {
if (part === "zha") {
return true;
}
private _handleChartClick(e: CustomEvent): void {
if (
e.detail.dataType === "node" &&
e.detail.event.target.cursor === "pointer"
) {
const { id } = e.detail.data;
const device = this._devices.find((d) => d.ieee === id);
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 {
return [
css`
.header {
border-bottom: 1px solid var(--divider-color);
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));
ha-network-graph {
height: 100%;
}
`,
];
}
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 {

View File

@ -1109,10 +1109,10 @@ class DialogZWaveJSAddNode extends SubscribeMixin(LitElement) {
@media all and (max-width: 500px), all and (max-height: 500px) {
ha-dialog {
--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(
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-max-height: 100%;

View File

@ -43,6 +43,7 @@ import {
subscribeZwaveControllerStatistics,
subscribeZwaveNVMBackup,
} from "../../../../../data/zwave_js";
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-tabs-subpage";
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 { showZWaveJSRemoveNodeDialog } from "./show-dialog-zwave_js-remove-node";
import { configTabs } from "./zwave_js-config-router";
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
@customElement("zwave_js-config-dashboard")
class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
@ -142,6 +142,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
.route=${this.route}
.tabs=${configTabs}
has-fab
>
<ha-icon-button
slot="toolbar-icon"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1214,7 +1214,6 @@ ${rejected
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
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) {
ha-dialog {
--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(
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-max-height: 100%;

View File

@ -130,7 +130,7 @@ class DialogHomeZoneDetail extends LitElement {
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--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) {
ha-dialog {
--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
: "/config"}
.tabs=${configSections.areas}
has-fab
>
${this.narrow
? html`
@ -581,9 +582,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
min-height: 100%;
box-sizing: border-box;
}
ha-card {
margin-bottom: 100px;
}
ha-tooltip {
display: block;
}

View File

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

View File

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

View File

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

View File

@ -129,7 +129,7 @@ class PanelDeveloperTools extends LitElement {
z-index: 4;
background-color: var(--app-header-background-color);
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);
border-bottom: var(--app-header-border-bottom, none);
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
@ -157,9 +157,9 @@ class PanelDeveloperTools extends LitElement {
developer-tools-router {
display: block;
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%;
max-width: 100%;
}

View File

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

View File

@ -800,10 +800,10 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
ha-dialog {
--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(
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-max-height: 100%;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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