mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-31 05:06:38 +00:00
Merge branch 'dev' of github.com:home-assistant/frontend into ha-button
This commit is contained in:
commit
422f05dc3b
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
13
package.json
13
package.json
@ -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",
|
||||
|
14
src/common/translations/markdown_support.ts
Normal file
14
src/common/translations/markdown_support.ts
Normal 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
|
||||
>`,
|
||||
});
|
@ -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;
|
||||
|
299
src/components/chart/ha-network-graph.ts
Normal file
299
src/components/chart/ha-network-graph.ts
Normal 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 };
|
||||
}
|
||||
}
|
@ -287,6 +287,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
},
|
||||
} as YAXisOption,
|
||||
legend: {
|
||||
type: "custom",
|
||||
show: this.showNames,
|
||||
},
|
||||
grid: {
|
||||
|
@ -308,6 +308,7 @@ export class StatisticsChart extends LitElement {
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
type: "custom",
|
||||
show: !this.hideLegend,
|
||||
data: this._legendData,
|
||||
},
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
@ -202,6 +202,7 @@ export class HaExpansionPanel extends LitElement {
|
||||
.header,
|
||||
::slotted([slot="header"]) {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -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%;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
@ -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%;
|
||||
|
@ -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 });
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
159
src/dialogs/sidebar/dialog-edit-sidebar.ts
Normal file
159
src/dialogs/sidebar/dialog-edit-sidebar.ts
Normal 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;
|
||||
}
|
||||
}
|
18
src/dialogs/sidebar/show-dialog-edit-sidebar.ts
Normal file
18
src/dialogs/sidebar/show-dialog-edit-sidebar.ts
Normal 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,
|
||||
});
|
||||
};
|
@ -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({
|
||||
|
@ -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
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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%;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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}
|
||||
|
@ -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 {
|
||||
|
@ -1415,7 +1415,6 @@ ${rejected
|
||||
createEntry: async (values) => {
|
||||
const label = await createLabelRegistryEntry(this.hass, values);
|
||||
this._bulkLabel(label.label_id, "add");
|
||||
return label;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -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)}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1110,7 +1110,6 @@ ${rejected
|
||||
createEntry: async (values) => {
|
||||
const label = await createLabelRegistryEntry(this.hass, values);
|
||||
this._bulkLabel(label.label_id, "add");
|
||||
return label;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -1367,7 +1367,6 @@ ${rejected
|
||||
createEntry: async (values) => {
|
||||
const label = await createLabelRegistryEntry(this.hass, values);
|
||||
this._bulkLabel(label.label_id, "add");
|
||||
return label;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -1259,7 +1259,6 @@ ${rejected
|
||||
createEntry: async (values) => {
|
||||
const label = await createLabelRegistryEntry(this.hass, values);
|
||||
this._bulkLabel(label.label_id, "add");
|
||||
return label;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
@ -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"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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%;
|
||||
|
@ -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"
|
||||
|
@ -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() {
|
||||
|
@ -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>;
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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 {
|
||||
|
@ -1158,7 +1158,6 @@ ${rejected
|
||||
createEntry: async (values) => {
|
||||
const label = await createLabelRegistryEntry(this.hass, values);
|
||||
this._bulkLabel(label.label_id, "add");
|
||||
return label;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -1214,7 +1214,6 @@ ${rejected
|
||||
createEntry: async (values) => {
|
||||
const label = await createLabelRegistryEntry(this.hass, values);
|
||||
this._bulkLabel(label.label_id, "add");
|
||||
return label;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -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%;
|
||||
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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%;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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%;
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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%;
|
||||
|
@ -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]) {
|
||||
|
@ -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,
|
||||
|
@ -465,7 +465,7 @@ export class HuiEnergyUsageGraphCard
|
||||
}
|
||||
|
||||
data.push({
|
||||
id: compare ? "compare-" + statId : statId,
|
||||
id: `${compare ? "compare-" : ""}${statId}-${type}`,
|
||||
type: "bar",
|
||||
cursor: "default",
|
||||
name:
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user