Compare commits

..
Author SHA1 Message Date
Petar Petrov 651cb7934a Give the border router node its transport colour too
Both hub categories now derive their colour from the same helper as the
links, so a network is one hue from Home Assistant through its hub to
the devices, and each legend swatch keys the links of that transport.

The border router also gains contrast: the shared Thread purple is
legible on both the light and the dark card background, where the deep
purple it replaces was not.
2026-08-18 17:42:02 +03:00
Petar Petrov 1e1ff92dd2 Give the Wi-Fi access point node its transport colour
The access point wore indigo while everything else on its network was
orange. Deriving the category colour from the same helper the links use
keeps a hub and its links one hue, and gives the Wi-Fi colour a legend
entry it did not have before.
2026-08-18 17:33:49 +03:00
Petar Petrov 3a58565e5e Carry the transport colour onto the Home Assistant edges
An edge from Home Assistant to a hub now takes the colour of the network
behind it, so one transport reads as one colour the whole way back
instead of changing hue at the hub.

Wi-Fi moves from pink to orange: pink sat close to the error red used
for offline nodes, while orange is far from both that red and the Thread
purple.
2026-08-18 17:26:30 +03:00
Petar Petrov c299ea9663 Match the dashboard's line semantics for Matter graph links
Dash an edge whose endpoint is inferred rather than commissioned, or is
offline, which is the same pair of conditions the matter.js dashboard
dashes on. Stop drawing a link whose every direction is dead: the
summary strength is the strongest direction, so none means a stale
neighbour entry, and the dashboard never draws one either.

That also retires the grey dead-link colour, since such links no longer
reach the graph.
2026-08-18 16:45:38 +03:00
Petar Petrov a2d47af076 Float Matter nodes whose route to Home Assistant is unknown
Only border routers and access points get an edge to the Home Assistant
node, because only those are a path we can actually see. A node in a
group with neither was previously anchored to Home Assistant with a
dotted line, which reads as a physical connection that was never
observed.

Removing the anchor makes the component walk that picked a
representative dead code, so it goes too.
2026-08-18 16:39:28 +03:00
Petar Petrov 954d0baa65 Colour Matter graph links by transport and float unknown neighbours
Unknown Thread neighbours are not commissioned on our fabric, so Home
Assistant has no operational path to them. A group made only of unknown
devices no longer draws an anchor to the Home Assistant node and floats
instead of claiming a connection that does not exist.

Link colour now encodes the transport, purple for Thread and pink for
Wi-Fi, with the signal level left on the line width and spelled out in
the tooltip. A dead link keeps the disabled colour, which is the only
thing separating it from a healthy weak one now that both are width 1.
The edge tooltip names the network, since the graph legend describes
nodes only and cannot carry a link entry.
2026-08-18 15:20:53 +03:00
Petar Petrov 9a945f4105 Name Matter access points by SSID and show their radio address
Access points were labelled with a BSSID, and the same address was
repeated as the node context. Prefer the SSID for the label and keep the
radio address as context only when it differs, so the radios of one mesh
stay distinguishable without repeating the label.

Falls back to today's BSSID against servers that do not send an SSID.
2026-08-18 15:01:32 +03:00
Petar Petrov 20be632c96 Distinguish position-unknown Matter graph anchors and name border routers
Anchors to a hubless component only mean the node is reachable, so draw
them dotted instead of reusing the solid line that marks a real path
through a border router or access point.

Also prefer the border router mDNS host name over vendor and model,
which several vendors report identically on every unit.
2026-08-18 14:33:17 +03:00
Petar Petrov 89bc7a40f2 Pass devices to getDeviceArea so bridged nodes inherit their area 2026-08-18 13:47:16 +03:00
Petar Petrov 924cc057b9 Render Matter 'unknown' link strength as a present, neutral link 2026-08-18 10:30:22 +03:00
Petar Petrov 43c9e71a8f Refine Matter graph HA node: theme-aware memoization, drop dead branch 2026-08-18 10:30:22 +03:00
Petar Petrov a98a598c79 Add central Home Assistant node to Matter network graph 2026-08-18 10:30:22 +03:00
Petar Petrov e6ec2540e7 Add Matter network topology visualization page 2026-08-18 10:30:22 +03:00
25 changed files with 2800 additions and 1811 deletions
@@ -1,3 +1,4 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
@@ -8,6 +9,7 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
@@ -153,6 +155,9 @@ interface Sample {
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
@@ -163,6 +168,14 @@ const SAMPLES: Sample[] = [
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
@@ -261,13 +274,23 @@ class DemoHaSelectorReplacedDevice
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
</ha-settings-row>
`
)}
@@ -1,5 +1,4 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { deepEqual } from "../util/deep-equal";
import {
createQueryString,
decodeQueryParams,
@@ -36,15 +35,6 @@ export const historyLogbookTargetFromQueryParams = (
): HassServiceTarget | undefined =>
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
export const historyLogbookTargetsEqual = (
a: HassServiceTarget,
b: HassServiceTarget
): boolean =>
deepEqual(
queryParamsFromServiceTarget(a, historyLogbookTargetParamKeys),
queryParamsFromServiceTarget(b, historyLogbookTargetParamKeys)
);
export const createHistoryLogbookUrl = (
path: string,
target: HassServiceTarget,
@@ -1,4 +1,3 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -23,10 +22,6 @@ import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -43,10 +38,6 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -69,13 +60,6 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -83,7 +67,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -193,19 +177,13 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth") ||
widthChanged
changedProps.has("_yWidth")
) {
this._createOptions();
}
@@ -215,22 +193,14 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -254,52 +224,37 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: insideLabels
? {
show: showNames,
inside: true,
margin: 0,
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
return label;
},
hideOverlap: true,
},
},
grid: {
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
top: 10,
bottom: 30,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
},
tooltip: {
renderMode: "html",
@@ -443,10 +398,6 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,10 +79,6 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw timeline row names above their bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -231,7 +227,6 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -429,12 +424,6 @@ export class StateHistoryCharts extends LitElement {
padding-top: 8px;
}
/* Names inside the plot sit close to the chart above them, so the groups
need more room between them to stay apart. */
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.entry-container:hover {
z-index: 1;
}
@@ -1,79 +0,0 @@
import { mdiCalendar } from "@mdi/js";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import "../chips/ha-assist-chip";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-svg-icon";
import {
haDateRangePickerStyles,
HaDateRangePicker,
} from "./ha-date-range-picker";
/**
* Date range picker as a single pill that also steps through ranges: a
* previous button, the selected range and a next button. Meant for a toolbar,
* next to other chips.
*/
@customElement("ha-date-range-nav")
export class HaDateRangeNav extends HaDateRangePicker {
protected override _renderField() {
return html`
<ha-icon-button-prev
class="step"
.label=${this._i18n.localize("ui.common.previous")}
.disabled=${this.disabled}
@click=${this._handlePrev}
></ha-icon-button-prev>
<ha-assist-chip
id="field"
class="range"
.label=${this._formatRange(" ")}
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="icon" .path=${mdiCalendar}></ha-svg-icon>
</ha-assist-chip>
<ha-icon-button-next
class="step"
.label=${this._i18n.localize("ui.common.next")}
.disabled=${this.disabled}
@click=${this._handleNext}
></ha-icon-button-next>
`;
}
static override styles = [
haDateRangePickerStyles,
css`
/* The three controls read as one pill, with the range chip's borders as
the dividers between them. */
.date-range-inputs {
gap: 0;
border: 1px solid var(--outline-color);
border-radius: var(--ha-assist-chip-container-shape, 10px);
background: var(--ha-assist-chip-container-color, transparent);
overflow: hidden;
width: fit-content;
}
.step {
--ha-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.range {
--md-assist-chip-outline-color: transparent;
--ha-assist-chip-container-shape: 0;
--ha-assist-chip-container-color: transparent;
border-inline: 1px solid var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-date-range-nav": HaDateRangeNav;
}
}
+128 -112
View File
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -10,7 +11,10 @@ import { tinykeys } from "tinykeys";
import { shiftDateRange } from "../../common/datetime/calc_date";
import type { DateRange } from "../../common/datetime/calc_date_range";
import { calcDateRange } from "../../common/datetime/calc_date_range";
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
@@ -38,67 +42,18 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
"now-30d",
];
export const haDateRangePickerStyles = css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right) - var(
--ha-space-8
)
);
overflow: hidden;
}
`;
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n!: ContextType<typeof internationalizationContext>;
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
protected _hassConfig!: HassConfig;
private _hassConfig!: HassConfig;
@property({ attribute: false }) public startDate!: Date;
@@ -188,7 +143,73 @@ export class HaDateRangePicker extends LitElement {
protected render(): TemplateResult {
return html`
<div class="container">
<div class="date-range-inputs">${this._renderField()}</div>
<div class="date-range-inputs">
${
!this.minimal
? html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${
(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this._i18n.locale,
this._hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this._i18n.locale,
this._hassConfig
))
}
.label=${
this._i18n.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`
}
</div>
${
this._pickerWrapperOpen || this._opened
? this._openedNarrow
@@ -227,60 +248,6 @@ export class HaDateRangePicker extends LitElement {
`;
}
/**
* The control that opens the picker. It has to carry `id="field"`, which the
* popover anchors to.
*/
protected _renderField() {
if (this.minimal) {
return html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`;
}
return html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
.label=${
this._i18n.localize("ui.components.date-range-picker.start_date") +
" - " +
this._i18n.localize("ui.components.date-range-picker.end_date")
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`;
}
protected _formatRange(separator: string): string {
const format = (date: Date) =>
formatShortDateTimeWithConditionalYear(
date,
this._i18n.locale,
this._hassConfig
);
return format(this.startDate) + separator + format(this.endDate);
}
private _renderPicker() {
if (!this._opened) {
return nothing;
@@ -336,12 +303,12 @@ export class HaDateRangePicker extends LitElement {
this._opened = false;
};
protected _handleNext(ev: MouseEvent): void {
private _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
protected _handlePrev(ev: MouseEvent): void {
private _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
@@ -369,7 +336,7 @@ export class HaDateRangePicker extends LitElement {
this._pickerWrapperOpen = false;
}
protected _openPicker(ev?: Event) {
private _openPicker(ev?: Event) {
if (this.disabled) {
return;
}
@@ -385,7 +352,7 @@ export class HaDateRangePicker extends LitElement {
});
}
protected _handleKeydown(ev: KeyboardEvent) {
private _handleKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._openPicker(ev);
@@ -402,7 +369,56 @@ export class HaDateRangePicker extends LitElement {
}
}
static styles = [haDateRangePickerStyles];
static styles = [
css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(
--safe-area-inset-right
) - var(--ha-space-8)
);
overflow: hidden;
}
`,
];
}
declare global {
-79
View File
@@ -1,79 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-svg-icon";
/**
* Centered placeholder for a surface that has nothing to show, with an icon, a
* heading, an optional description and optional actions.
*
* @slot - Actions that help the user fill the surface, e.g. a button.
*/
@customElement("ha-empty-state")
export class HaEmptyState extends LitElement {
/** SVG path of the icon shown above the heading. */
@property() public icon?: string;
@property() public heading?: string;
@property() public description?: string;
protected render() {
return html`
<div class="content">
${
this.icon
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
: nothing
}
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
${this.description ? html`<p>${this.description}</p>` : nothing}
<slot></slot>
</div>
`;
}
static styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 100%;
width: 100%;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
max-width: 500px;
padding: var(--ha-space-8) var(--ha-space-4);
text-align: center;
}
ha-svg-icon {
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
color: var(--secondary-text-color);
}
h2 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
p {
margin: 0;
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-empty-state": HaEmptyState;
}
}
-287
View File
@@ -1,287 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
interface DeviceClassItem {
deviceClass: string;
domain: string;
name: string;
}
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-device-classes.caption")}
${
this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
),
(item) => item.deviceClass,
(item) =>
html`<ha-check-list-item
.value=${item.deviceClass}
.selected=${(this.value || []).includes(item.deviceClass)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${item.domain}
.deviceClass=${item.deviceClass}
.state=${item.domain === "binary_sensor" ? "on" : undefined}
></ha-domain-icon>
${item.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined
): DeviceClassItem[] =>
this._deviceClassItems(this._deviceClassDomains(states), localize)
.filter(
(item) =>
!filter ||
item.deviceClass.toLowerCase().includes(filter) ||
item.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _deviceClassDomains = memoizeOne(
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
const domains = new Map<string, string[]>();
Object.values(states).forEach((stateObj) => {
const deviceClass = stateObj.attributes.device_class;
if (!deviceClass) {
return;
}
const domain = computeStateDomain(stateObj);
const known = domains.get(deviceClass);
if (!known) {
domains.set(deviceClass, [domain]);
} else if (!known.includes(domain)) {
known.push(domain);
}
});
return domains;
}
);
private _deviceClassItems = memoizeOne(
(
deviceClassDomains: Map<string, string[]>,
localize: LocalizeFunc
): DeviceClassItem[] =>
[...deviceClassDomains].map(([deviceClass, domains]) => {
for (const domain of domains) {
const name = localize(
`component.${domain}.entity_component.${deviceClass}.name`
);
if (name) {
return { deviceClass, domain, name };
}
}
return { deviceClass, domain: domains[0], name: deviceClass };
}),
([domainsA, localizeA], [domainsB, localizeB]) =>
localizeA === localizeB &&
domainsA.size === domainsB.size &&
[...domainsA].every(
([deviceClass, domains]) =>
domainsB.get(deviceClass)?.join() === domains.join()
)
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const deviceClasses = this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
);
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
const preserved = (this.value || []).filter((d) => !visible.has(d));
const selected = [...ev.detail.index]
.map((i) => deviceClasses[i]?.deviceClass)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
-182
View File
@@ -1,182 +0,0 @@
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-adaptive-dialog";
import "./ha-button";
import "./ha-dialog-footer";
import "./ha-filter-pane-chip";
import "./ha-icon-button";
/**
* Filter pane for a filtered page: a column next to the content on wide
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
* `hass-tabs-subpage-data-table` for pages that are not a data table.
*
* The page keeps ownership of whether the pane is shown, so that it can also
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
* while it is open.
*
* @slot - Filter panels, e.g. `ha-filter-domains`.
*/
@customElement("ha-filter-pane")
export class HaFilterPane extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
/** Header label, defaults to "Filters". */
@property() public label?: string;
/** SVG path of the header chip icon. */
@property() public path = mdiFilterVariant;
/** Number of active filters, shows the clear button when above zero. */
@property({ type: Number }) public count = 0;
/**
* Number of results the current filters resolve to, shown on the narrow
* confirm button. Leave undefined when the page shows everything.
*/
@property({ attribute: false }) public resultCount?: number;
@property({ type: Boolean }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
const label =
this.label ?? this._localize("ui.components.subpage-data-table.filters");
if (this.narrow) {
return html`
<ha-adaptive-dialog
open
flexcontent
.headerTitle=${label}
@closed=${this._close}
>
${this._renderClearButton("headerActionItems")}
<div class="sheet-content">
<slot></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" data-dialog="close">
${
this.resultCount === undefined
? this._localize("ui.common.close")
: this._localize(
"ui.components.subpage-data-table.show_results",
{ number: this.resultCount }
)
}
</ha-button>
</ha-dialog-footer>
</ha-adaptive-dialog>
`;
}
return html`
<div class="header">
<ha-filter-pane-chip
active
.label=${label}
.path=${this.path}
.disabled=${this.disabled}
@click=${this._close}
></ha-filter-pane-chip>
${this._renderClearButton()}
</div>
<div class="content ha-scrollbar">
<slot></slot>
</div>
`;
}
private _renderClearButton(slot?: string) {
if (!this.count) {
return nothing;
}
return html`
<ha-icon-button
slot=${ifDefined(slot)}
.path=${mdiFilterVariantRemove}
.disabled=${this.disabled}
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
@click=${this._clear}
></ha-icon-button>
`;
}
private _close() {
fireEvent(this, "close-filter-pane");
}
private _clear() {
fireEvent(this, "clear-filter");
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
flex: 0 0 var(--ha-filter-pane-width, 320px);
width: var(--ha-filter-pane-width, 320px);
box-sizing: border-box;
overflow: hidden;
border-inline-end: 1px solid var(--divider-color);
}
/* The bottom sheet positions itself, so the pane takes no space. */
:host([narrow]) {
display: contents;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
.content,
.sheet-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-adaptive-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane": HaFilterPane;
}
interface HASSDomEvents {
"close-filter-pane": undefined;
}
}
-268
View File
@@ -1,268 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-device-classes";
import "./ha-filter-domains";
import "./ha-filter-integrations";
import "./ha-target-picker";
/**
* Ways to narrow down the entities a target selection resolves to. Not to be
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
domains?: string[];
deviceClasses?: string[];
integrations?: string[];
}
const TARGET_KEYS = [
"floor_id",
"area_id",
"device_id",
"entity_id",
"label_id",
] as const;
/** Number of picked targets, no matter which type they are. */
export const countTargets = (target: HassServiceTarget): number =>
TARGET_KEYS.reduce(
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
0
);
/** Number of filters that have at least one option selected. */
export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
/**
* Narrows entity IDs down by the selected filters: an entity is kept when it
* matches every filter that has a selection.
*/
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const domains = filters.domains?.length ? filters.domains : undefined;
const deviceClasses = filters.deviceClasses?.length
? filters.deviceClasses
: undefined;
const integrations = filters.integrations?.length
? filters.integrations
: undefined;
if (!domains && !deviceClasses && !integrations) {
return entityIds;
}
return entityIds.filter((entityId) => {
if (domains && !domains.includes(computeDomain(entityId))) {
return false;
}
if (deviceClasses) {
const deviceClass = states[entityId]?.attributes.device_class;
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
return false;
}
}
if (integrations) {
const integration =
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
if (!integration || !integrations.includes(integration)) {
return false;
}
}
return true;
});
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* domain, device class and integration. Meant to be placed in an
* `ha-filter-pane`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
*/
@customElement("ha-sources-picker")
export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value: HassServiceTarget = {};
@property({ attribute: false }) public filters: SourceFilters = {};
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@property({ type: Boolean }) public disabled = false;
// Only one filter panel is expanded at a time, so that the expanded one can
// use the height that is left in the pane.
@state() private _expandedFilter?: keyof SourceFilters;
protected render() {
const noTargets = countTargets(this.value) === 0;
return html`
${
this.description && noTargets
? html`<div class="description">${this.description}</div>`
: nothing
}
<ha-target-picker
class=${classMap({ "no-padding-top": noTargets })}
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.primaryEntitiesOnly=${false}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
></ha-target-picker>
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-domains
.value=${this.filters.domains}
.expanded=${this._expandedFilter === "domains"}
@data-table-filter-changed=${this._domainsChanged}
@expanded-changed=${this._domainsExpanded}
></ha-filter-domains>
<ha-filter-device-classes
.value=${this.filters.deviceClasses}
.expanded=${this._expandedFilter === "deviceClasses"}
@data-table-filter-changed=${this._deviceClassesChanged}
@expanded-changed=${this._deviceClassesExpanded}
></ha-filter-device-classes>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpanded}
></ha-filter-integrations>
</div>
`;
}
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
}
private _targetsChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _domainsChanged(ev: CustomEvent) {
this._filterChanged("domains", ev);
}
private _deviceClassesChanged(ev: CustomEvent) {
this._filterChanged("deviceClasses", ev);
}
private _integrationsChanged(ev: CustomEvent) {
this._filterChanged("integrations", ev);
}
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as DataTableFiltersValue;
fireEvent(this, "source-filters-changed", {
value: {
...this.filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
},
});
}
private _domainsExpanded(ev: CustomEvent) {
this._filterExpanded("domains", ev);
}
private _deviceClassesExpanded(ev: CustomEvent) {
this._filterExpanded("deviceClasses", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
this._filterExpanded("integrations", ev);
}
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
if (ev.detail.expanded) {
this._expandedFilter = key;
} else if (this._expandedFilter === key) {
this._expandedFilter = undefined;
}
}
static styles = css`
/* The sections are laid out by the pane, so that an expanded filter
panel can use the height that is left. */
:host {
display: contents;
}
.description {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
margin: var(--ha-space-4) var(--ha-space-4) 0;
padding: 0 var(--ha-space-6);
border-radius: var(--ha-border-radius-lg);
background-color: var(--ha-color-fill-neutral-quiet-resting);
text-align: center;
color: var(--secondary-text-color);
}
ha-target-picker {
display: block;
flex: none;
padding: var(--ha-space-4);
}
/* The description already spaces the picker from the pane header. */
ha-target-picker.no-padding-top {
padding-top: 0;
}
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over. */
.filters.expanded {
flex: 1 1 auto;
min-height: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-sources-picker": HaSourcesPicker;
}
interface HASSDomEvents {
"source-filters-changed": { value: SourceFilters };
}
}
+297 -1
View File
@@ -28,6 +28,7 @@ import {
type DevicePickerItem,
} from "../data/device/device_picker";
import {
devicesInEffectiveArea,
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
@@ -41,6 +42,9 @@ import { domainToName } from "../data/integration";
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
entityRegMeetsFilter,
getTargetComboBoxItemType,
type TargetItem,
type TargetType,
@@ -63,6 +67,7 @@ import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-value-chip";
const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___";
@@ -81,6 +86,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public helper?: string;
@property({ type: Boolean, reflect: true }) public compact = false;
@property({ attribute: false }) public createDomains?: string[];
@property({ type: Boolean, attribute: "primary-entities-only" })
@@ -110,6 +117,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
@state() private _selectedSection?: TargetTypeFloorless;
@state() private _replaceTarget?: TargetItem;
@@ -245,9 +254,119 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
Fuse.createIndex(keys, states);
protected render() {
if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `;
}
return html` ${this._renderItems()} ${this._renderPicker()} `;
}
private _renderValueChips() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
: [];
const deviceIds = this.value?.device_id
? ensureArray(this.value.device_id)
: [];
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
const floorIds = this.value?.floor_id
? ensureArray(this.value.floor_id)
: [];
const labelIds = this.value?.label_id
? ensureArray(this.value.label_id)
: [];
if (
!entityIds.length &&
!deviceIds.length &&
!areaIds.length &&
!floorIds.length &&
!labelIds.length
) {
return nothing;
}
return html`
<div class="items">
${
floorIds.length
? floorIds.map(
(floor_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="floor"
.itemId=${floor_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
areaIds.length
? areaIds.map(
(area_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="area"
.itemId=${area_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
deviceIds.length
? deviceIds.map(
(device_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
entityIds.length
? entityIds.map(
(entity_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="entity"
.itemId=${entity_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
labelIds.length
? labelIds.map(
(label_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="label"
.itemId=${label_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
</div>
`;
}
private _renderValueGroups() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
@@ -361,7 +480,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
private _renderItems() {
return html` ${this._renderValueGroups()} `;
return html`
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
`;
}
private _renderPicker() {
@@ -536,6 +657,162 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
private _handleExpand(ev: HASSDomEvent<HASSDomEvents["expand-target-item"]>) {
const type = ev.detail.type;
const itemId = ev.detail.id;
const newAreas: string[] = [];
const newDevices: string[] = [];
const newEntities: string[] = [];
if (type === "floor") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.floor_id === itemId &&
!this.value!.area_id?.includes(area.area_id) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newAreas.push(area.area_id);
}
});
} else if (type === "area") {
// Splitting an area yields its effective-area devices, so a child device
// that belongs to a different area is not pulled into this area.
devicesInEffectiveArea(this.hass.devices, itemId).forEach((device) => {
if (
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.area_id === itemId &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
false,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else if (type === "device") {
// Splitting a device into entities includes its child devices' entities,
// since targeting the device would target its children too.
const deviceIds = new Set([
itemId,
...Object.values(this.hass.devices)
.filter((device) => device.parent_device_id === itemId)
.map((device) => device.id),
]);
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.device_id &&
deviceIds.has(entity.device_id) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
false,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else if (type === "label") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.labels.includes(itemId) &&
!this.value!.area_id?.includes(area.area_id) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newAreas.push(area.area_id);
}
});
Object.values(this.hass.devices).forEach((device) => {
if (
device.labels.includes(itemId) &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.labels.includes(itemId) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
true,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else {
return;
}
let value = this.value;
if (newEntities.length) {
value = this._addItems(value, "entity_id", newEntities);
}
if (newDevices.length) {
value = this._addItems(value, "device_id", newDevices);
}
if (newAreas.length) {
value = this._addItems(value, "area_id", newAreas);
}
value = this._removeItem(value, type, itemId);
fireEvent(this, "value-changed", { value });
}
private _handleReplace(
ev: HASSDomEvent<HASSDomEvents["replace-target-item"]>
) {
@@ -575,6 +852,17 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._replaceTargetAnchor = undefined;
}
private _addItems(
value: this["value"],
type: string,
ids: string[]
): this["value"] {
return {
...value,
[type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids,
};
}
private _removeItem(
value: this["value"],
type: TargetType,
@@ -1150,6 +1438,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
width: 100%;
}
.items {
z-index: 2;
display: flex;
flex-wrap: wrap;
padding: var(--ha-space-2) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: var(--ha-border-width-sm) solid var(--divider-color);
@@ -1165,6 +1460,7 @@ declare global {
interface HASSDomEvents {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
@@ -0,0 +1,314 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
mdiUnfoldMoreVertical,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-domain-icon";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon";
import "../ha-icon-button";
import "../ha-state-icon";
import "../ha-tooltip";
@customElement("ha-target-picker-value-chip")
export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public type!: TargetType;
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@state()
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
protected render() {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${classMap({ [this.type]: true, replaced })}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
${
this.type === "entity" || replaced
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize(
"ui.components.target-picker.expand"
)}
.path=${mdiUnfoldMoreVertical}
hide-title
.id="expand-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>`
}
</wa-tag>
`;
}
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
if (type === "floor") {
const floor = this.hass.floors?.[itemId];
return {
name: floor?.name || itemId,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
};
}
if (type === "area") {
const area = this.hass.areas?.[itemId];
return {
name: area?.name || itemId,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
};
}
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
return {
name: device
? computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)
: itemId,
fallbackIconPath: mdiDevices,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(itemId));
const stateObj = this.hass.states[itemId];
return {
name: computeStateName(stateObj) || itemId,
stateObject: stateObj,
};
}
// type label
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
let color = label?.color ? computeCssColor(label.color) : undefined;
if (color?.startsWith("var(")) {
const computedStyles = getComputedStyle(this);
color = computedStyles.getPropertyValue(
color.substring(4, color.length - 1)
);
}
if (color?.startsWith("#")) {
color = hex2rgb(color).join(",");
}
return {
name: label?.name || itemId,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
color,
};
});
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
}
private async _getDeviceDomain(configEntryId: string) {
try {
const data = await getConfigEntry(this.hass, configEntryId);
const domain = data.config_entry.domain;
this._iconImg = brandsUrl(
{
domain: domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
this._setDomainName(domain);
} catch {
// failed to load config entry -> ignore
}
}
private _removeItem(ev: MouseEvent) {
ev.stopPropagation();
fireEvent(this, "remove-target-item", {
type: this.type,
id: this.itemId,
});
}
private _handleExpand(ev: MouseEvent) {
ev.stopPropagation();
fireEvent(this, "expand-target-item", {
type: this.type,
id: this.itemId,
});
}
static styles = css`
:host {
display: inline-block;
max-width: 100%;
}
wa-tag {
background-color: var(--card-background-color);
border-width: var(--ha-border-width-md);
padding-inline-start: 0;
overflow: hidden;
max-width: 100%;
color: var(--primary-text-color);
}
wa-tag.entity {
border-color: var(--ha-color-green-80);
--background-color: var(--ha-color-green-80);
}
wa-tag.device {
border-color: var(--ha-color-primary-80);
--background-color: var(--ha-color-primary-80);
}
wa-tag.area {
border-color: var(--ha-color-orange-80);
--background-color: var(--ha-color-orange-80);
}
wa-tag.label {
border-color: var(--color);
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
background-color: var(--background-color);
border-radius: var(--ha-border-radius-circle);
padding: var(--ha-space-2) var(--ha-space-1);
display: flex;
}
.expand-btn {
--ha-icon-button-size: 16px;
--mdc-icon-size: 14px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-value-chip": HaTargetPickerValueChip;
}
}
+67
View File
@@ -45,6 +45,73 @@ export interface MatterNodeDiagnostics {
export type MatterPingResult = Record<string, boolean>;
export type MatterTopologyNodeKind =
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
export type MatterTopologyStrength =
"strong" | "medium" | "weak" | "none" | "unknown";
export interface MatterTopologyDirectionInfo {
strength: MatterTopologyStrength;
lqi?: number | null;
rssi?: number | null;
}
export interface MatterNetworkTopologyNode {
id: string;
kind: MatterTopologyNodeKind;
network_type: string;
node_id?: number | null;
ha_device_id?: string | null;
role?: string | null;
available?: boolean | null;
is_bridge?: boolean | null;
ext_address?: string | null;
rloc16?: number | null;
ext_pan_id?: string | null;
network_name?: string | null;
ssid?: string | null;
bssid?: string | null;
host_name?: string | null;
vendor_name?: string | null;
model_name?: string | null;
last_seen?: number | null;
}
export interface MatterNetworkTopologyConnection {
source: string;
target: string;
network: string;
strength: MatterTopologyStrength;
source_to_target?: MatterTopologyDirectionInfo | null;
target_to_source?: MatterTopologyDirectionInfo | null;
via_route_table?: boolean | null;
path_cost?: number | null;
}
export interface MatterNetworkTopology {
collected_at: number;
nodes: MatterNetworkTopologyNode[];
connections: MatterNetworkTopologyConnection[];
}
export const fetchMatterNetworkTopology = (
hass: HomeAssistant,
refresh = false
): Promise<MatterNetworkTopology> =>
hass.callWS({
type: "matter/network_topology",
refresh,
});
export const subscribeMatterNetworkTopology = (
hass: HomeAssistant,
callback: (topology: MatterNetworkTopology) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
type: "matter/subscribe_network_topology",
});
export interface MatterCommissioningParameters {
setup_pin_code: number;
setup_manual_code: string;
@@ -545,7 +545,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<div class="description-text">${this._currentAddon.description}</div>
${this._currentAddon.description}.<br />
${this.i18n.localize(
"ui.panel.config.apps.dashboard.visit_app_page",
{
@@ -1658,15 +1658,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--primary-color);
}
.description:dir(rtl) > .description-text {
text-align: right;
direction: ltr;
}
.long-description {
direction: ltr;
}
img.logo {
max-width: 100%;
max-height: 40px;
@@ -33,31 +33,23 @@ interface ProgressSegment {
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
// Quick metadata writes emitted while the backup is initialized, before the
// Home Assistant stage (docker_config only by older Supervisors)
const SETUP_STAGES: CreateBackupStage[] = [
const ADDON_STAGES: CreateBackupStage[] = [
"addons",
"apps",
"addon_repositories",
"app_repositories",
"docker_config",
];
const ADDON_STAGES: CreateBackupStage[] = ["addons", "apps"];
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Emitted after the backup file is finished, when the backend waits for
// cold-backup add-ons to come back up
const AWAIT_RESTART_STAGES: CreateBackupStage[] = [
"await_addon_restarts",
"await_app_restarts",
];
// Ordered groups matching actual backend execution order. The await restart
// stages share the last creation group to keep the progress monotonic.
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Ordered groups matching actual backend execution order
const STAGE_ORDER: CreateBackupStage[][] = [
[...SETUP_STAGES, ...HA_STAGES],
ADDON_STAGES,
[...MEDIA_STAGES, ...AWAIT_RESTART_STAGES],
MEDIA_STAGES,
HA_STAGES,
["upload_to_agents"],
["cleaning_up"],
];
@@ -173,21 +165,21 @@ export class HaBackupOverviewProgress extends LitElement {
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.apps"
),
state: this._getSegmentState(0, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.apps"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(1, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
@@ -209,18 +201,18 @@ export class HaBackupOverviewProgress extends LitElement {
];
}
// Non-HAOS: No app segment, just HA, Media, Upload and Cleaning up
// Non-HAOS: No app segment, just Media, HA, Upload and Cleaning up
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(0, currentGroupIndex),
state: this._getSegmentState(1, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
@@ -5,6 +5,7 @@ import {
mdiPlus,
mdiShape,
mdiTune,
mdiVectorPolyline,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -144,6 +145,10 @@ export class MatterConfigDashboard extends LitElement {
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize("ui.panel.config.matter.panel.my_network_title")}
<ha-button appearance="filled" href="/config/matter/visualization">
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
${this.hass.localize("ui.panel.config.matter.panel.show_map")}
</ha-button>
</div>
<div class="card-content">
<ha-md-list>
@@ -252,6 +257,9 @@ export class MatterConfigDashboard extends LitElement {
}
.nav-card .card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--ha-space-2);
}
@@ -27,6 +27,10 @@ class MatterConfigRouter extends HassRouterPage {
tag: "matter-options-page",
load: () => import("./matter-options-page"),
},
visualization: {
tag: "matter-network-visualization",
load: () => import("./matter-network-visualization"),
},
},
};
@@ -0,0 +1,360 @@
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import type {
NetworkData,
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import type {
MatterNetworkTopology,
MatterNetworkTopologyNode,
MatterTopologyStrength,
} from "../../../../../data/matter";
import type { HomeAssistant } from "../../../../../types";
const CATEGORY_HOME_ASSISTANT = 0;
const CATEGORY_BORDER_ROUTER = 1;
const CATEGORY_ROUTER = 2;
const CATEGORY_END_DEVICE = 3;
const CATEGORY_WIFI_AP = 4;
const CATEGORY_OFFLINE = 5;
const CATEGORY_UNKNOWN = 6;
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
// HA is not a Matter node; the frontend synthesizes it as the graph root.
export const HOME_ASSISTANT_NODE_ID = "ha";
const HOME_ASSISTANT_LABEL = "Home Assistant";
// 0 is never returned: a falsy link value re-enables the direction arrow
// in ha-network-graph
export const strengthToScale = (
strength?: MatterTopologyStrength | null
): number => {
switch (strength) {
case "strong":
return 4;
case "medium":
return 3;
case "weak":
return 2;
// "unknown" (no measurement, link presumed up) sits above "none"/dead so it
// reads as a present link, not a degraded one
case "unknown":
return 2;
default:
return 1;
}
};
// links are colored by transport; signal level stays on the line width.
// Both hues clear 3:1 on the light and the dark card background -- named
// palette colors have no dark variant, so a darker pick would vanish.
export const networkToColorVar = (network?: string | null): string => {
switch (network) {
case "thread":
return "--purple-color";
case "wifi":
return "--orange-color";
// `network` is a plain string on the wire: "ethernet" and anything a newer
// server invents still draw, just neutrally
default:
return "--secondary-text-color";
}
};
const strengthToWidth = (strength?: MatterTopologyStrength | null): number =>
strength === "strong" ? 3 : strength === "medium" ? 2 : 1;
export const getTopologyNodeCategory = (
node: MatterNetworkTopologyNode
): number => {
if (node.kind === "border_router") {
return CATEGORY_BORDER_ROUTER;
}
if (node.kind === "wifi_ap") {
return CATEGORY_WIFI_AP;
}
if (node.kind === "thread_unknown") {
return CATEGORY_UNKNOWN;
}
if (node.available === false) {
return CATEGORY_OFFLINE;
}
return node.role && ROUTER_ROLES.has(node.role)
? CATEGORY_ROUTER
: CATEGORY_END_DEVICE;
};
export const getTopologyNodeName = (
node: MatterNetworkTopologyNode,
hass: HomeAssistant
): string => {
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
if (device) {
return device.name_by_user || device.name || node.id;
}
if (node.kind === "border_router") {
return (
// many vendors report an identical vendor/model pair on every unit
node.host_name ||
[node.vendor_name, node.model_name].filter(Boolean).join(" ") ||
hass.localize("ui.panel.config.matter.visualization.border_router")
);
}
if (node.kind === "wifi_ap") {
return (
// the SSID names the network; network_name still holds the BSSID here
node.ssid ||
node.network_name ||
hass.localize("ui.panel.config.matter.visualization.wifi_ap")
);
}
if (node.kind === "thread_unknown") {
return hass.localize("ui.panel.config.matter.visualization.unknown_device");
}
if (node.node_id != null) {
return hass.localize("ui.panel.config.matter.visualization.node", {
node_id: node.node_id,
});
}
return node.id;
};
const isHub = (category: number): boolean =>
category === CATEGORY_BORDER_ROUTER || category === CATEGORY_WIFI_AP;
export function createMatterNetworkChartData(
topology: MatterNetworkTopology,
hass: HomeAssistant,
element: Element
): NetworkData {
const style = getComputedStyle(element);
// a hub wears its transport's colour, the same hue as the links behind it
const categoryColors = [
style.getPropertyValue("--primary-color"),
style.getPropertyValue(networkToColorVar("thread")),
style.getPropertyValue("--cyan-color"),
style.getPropertyValue("--teal-color"),
style.getPropertyValue(networkToColorVar("wifi")),
style.getPropertyValue("--error-color"),
style.getPropertyValue("--disabled-color"),
];
const categories = [
{
name: HOME_ASSISTANT_LABEL,
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.border_router"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_BORDER_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.router"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_ROUTER] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.end_device"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_END_DEVICE] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.wifi_ap"),
symbol: "roundRect",
itemStyle: { color: categoryColors[CATEGORY_WIFI_AP] },
},
{
name: hass.localize("ui.panel.config.matter.visualization.offline"),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_OFFLINE] },
},
{
name: hass.localize(
"ui.panel.config.matter.visualization.unknown_devices"
),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
},
];
const threadNetworks = new Set(
topology.nodes.map((node) => node.ext_pan_id).filter(Boolean)
);
const multiNetwork = threadNetworks.size > 1;
const nodes: NetworkNode[] = [
{
id: HOME_ASSISTANT_NODE_ID,
name: HOME_ASSISTANT_LABEL,
category: CATEGORY_HOME_ASSISTANT,
value: 4,
symbol: "roundRect",
symbolSize: 45,
polarDistance: 0,
fixed: true,
itemStyle: { color: categoryColors[CATEGORY_HOME_ASSISTANT] },
},
];
const nodeCategories = new Map<string, number>();
topology.nodes.forEach((node) => {
const category = getTopologyNodeCategory(node);
nodeCategories.set(node.id, category);
const device = node.ha_device_id
? hass.devices[node.ha_device_id]
: undefined;
const area = device
? getDeviceArea(device, hass.areas, hass.devices)
: undefined;
const name = getTopologyNodeName(node, hass);
// an AP is named by its SSID, so its own radio address is what tells two
// radios of one mesh apart; everything else is named by its network
const networkLabel =
node.kind === "wifi_ap"
? node.bssid || node.network_name
: node.ssid || node.network_name;
const contextParts: string[] = [];
if (area) {
contextParts.push(area.name);
}
// skip a label that just repeats the name, e.g. an AP with no SSID
if ((multiNetwork || !area) && networkLabel && networkLabel !== name) {
contextParts.push(networkLabel);
}
nodes.push({
id: node.id,
name,
context: contextParts.join(" • ") || undefined,
category,
value: isHub(category) ? 3 : category === CATEGORY_ROUTER ? 2 : 1,
symbol: isHub(category) ? "roundRect" : "circle",
symbolSize: isHub(category) ? 40 : category === CATEGORY_ROUTER ? 30 : 20,
itemStyle: {
color: categoryColors[category],
...(node.role === "leader"
? {
borderColor: style.getPropertyValue("--primary-color"),
borderWidth: 2,
}
: {}),
},
polarDistance: isHub(category)
? 0.1
: category === CATEGORY_ROUTER
? 0.4
: 0.8,
});
});
const links: NetworkLink[] = [];
topology.connections.forEach((conn) => {
if (!nodeCategories.has(conn.source) || !nodeCategories.has(conn.target)) {
return;
}
// the summary strength is the strongest observed direction, so "none" means
// every direction is dead -- a stale neighbour entry the dashboard also
// refuses to draw
if (conn.strength === "none") {
return;
}
let { source, target } = conn;
let forward = conn.source_to_target;
let reverse = conn.target_to_source;
if (!forward && reverse) {
// normalize so the arrow points in the observed direction
[source, target] = [target, source];
forward = reverse;
reverse = undefined;
}
const oneWay = Boolean(forward) && !reverse;
const asymmetric =
forward && reverse && forward.strength !== reverse.strength;
// an edge is lower confidence when an endpoint is inferred rather than
// commissioned, or is offline -- the dashboard dashes on the same two
const lowConfidence = [source, target].some((id) => {
const category = nodeCategories.get(id);
return category === CATEGORY_UNKNOWN || category === CATEGORY_OFFLINE;
});
const width = strengthToWidth(conn.strength);
links.push({
source,
target,
value: strengthToScale(forward?.strength ?? conn.strength),
// route-table edges without per-direction info are not directional
reverseValue: oneWay
? undefined
: strengthToScale(reverse?.strength ?? conn.strength),
symbolSize: oneWay ? width * 2 + 3 : undefined,
lineStyle: {
width,
color: style.getPropertyValue(networkToColorVar(conn.network)),
type:
oneWay || asymmetric || lowConfidence
? "dashed"
: !forward && conn.via_route_table
? "dotted"
: "solid",
},
ignoreForceLayout: !(
isHub(nodeCategories.get(source)!) || isHub(nodeCategories.get(target)!)
),
});
});
// Only a hub gets an edge to HA, and it is a real path. A node whose route we
// cannot see gets nothing: inventing an edge to HA reads as a physical link.
// It keeps the HA node's own color rather than a transport hue.
// `symbol: "none"` is what keeps the arrowhead off these edges -- ha-network-graph
// keys arrow suppression on `reverseValue`, not on `value` -- so it must stay.
const haLink = (target: string, network: string): NetworkLink => ({
source: HOME_ASSISTANT_NODE_ID,
target,
value: 0,
symbol: "none",
lineStyle: {
width: 3,
// the same hue as the radio links behind this hub, so one transport
// reads as one colour all the way back to Home Assistant
color: style.getPropertyValue(networkToColorVar(network)),
type: "solid",
},
});
// HA reaches the mesh through the border routers and Wi-Fi access points
topology.nodes
.filter((node) => node.kind === "border_router" || node.kind === "wifi_ap")
.forEach((node) =>
links.push(haLink(node.id, node.kind === "wifi_ap" ? "wifi" : "thread"))
);
// keep the strongest link of every node in the force layout so
// nodes hang near their best connection instead of floating free
nodes.forEach((node) => {
let bestLink: NetworkLink | undefined;
const hasActiveLink = links.some((link) => {
if (link.source !== node.id && link.target !== node.id) {
return false;
}
if (!link.ignoreForceLayout) {
return true;
}
const linkValue = Math.max(link.value ?? 0, link.reverseValue ?? 0);
if (
linkValue >
Math.max(bestLink?.value ?? -1, bestLink?.reverseValue ?? -1)
) {
bestLink = link;
}
return false;
});
if (!hasActiveLink && bestLink) {
bestLink.ignoreForceLayout = false;
}
});
return { nodes, links, categories };
}
@@ -0,0 +1,508 @@
import { mdiRefresh } from "@mdi/js";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import { throttle } from "../../../../../common/util/throttle";
import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type {
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
MatterTopologyDirectionInfo,
} from "../../../../../data/matter";
import {
fetchMatterNetworkTopology,
subscribeMatterNetworkTopology,
} from "../../../../../data/matter";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import {
createMatterNetworkChartData,
getTopologyNodeName,
HOME_ASSISTANT_NODE_ID,
} from "./matter-network-data";
const UPDATE_THROTTLE_TIME = 5000;
@customElement("matter-network-visualization")
export class MatterNetworkVisualization 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 _topology?: MatterNetworkTopology;
@state() private _notSupported = false;
@state() private _error?: string;
@state() private _refreshing = false;
@state() private _searchFilter = "";
private _unsub?: Promise<UnsubscribeFunc>;
private _throttledUpdateTopology = throttle(
(topology: MatterNetworkTopology) => {
this._topology = topology;
},
UPDATE_THROTTLE_TIME
);
public connectedCallback(): void {
super.connectedCallback();
if (this.hass && !this._unsub) {
this._subscribe();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._throttledUpdateTopology.cancel();
if (this._unsub) {
this._unsub.then((unsub) => unsub()).catch(() => undefined);
this._unsub = undefined;
}
}
private _subscribe(): void {
this._unsub = subscribeMatterNetworkTopology(this.hass, (topology) => {
if (!this._topology) {
this._topology = topology;
} else {
this._throttledUpdateTopology(topology);
}
});
this._unsub.catch((err: { code?: string; message?: string }) => {
this._unsub = undefined;
if (err?.code === "not_supported" || err?.code === "unknown_command") {
this._notSupported = true;
} else {
this._error = err?.message || String(err);
}
});
}
protected render() {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.matter.visualization.header"
)}
back-path="/config/matter/dashboard"
>
${
this.narrow && this._topology?.nodes.length
? html`<div slot="header">${this._renderInputSearch()}</div>`
: nothing
}
${this._renderContent()}
</hass-subpage>
`;
}
private _renderContent() {
if (this._notSupported) {
return html`<div class="center">
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.matter.visualization.not_supported"
)}
</ha-alert>
</div>`;
}
if (this._error) {
return html`<div class="center">
<ha-alert alert-type="error">
${this.hass.localize(
"ui.panel.config.matter.visualization.error_loading",
{ error: this._error }
)}
</ha-alert>
</div>`;
}
if (!this._topology) {
return html`<div class="center"><ha-spinner></ha-spinner></div>`;
}
if (!this._topology.nodes.length) {
return html`<div class="center empty">
${this.hass.localize("ui.panel.config.matter.visualization.empty")}
</div>`;
}
return html`
<ha-network-graph
.hass=${this.hass}
.searchFilter=${this._searchFilter}
.data=${this._formatNetworkData(
this._topology,
this.hass.devices,
this.hass.areas,
this.hass.themes,
this.hass.language
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow ? this._renderInputSearch("search") : nothing}
<ha-icon-button
slot="button"
class="refresh-button"
.disabled=${this._refreshing}
.path=${mdiRefresh}
@click=${this._refreshTopology}
label=${this.hass.localize(
"ui.panel.config.matter.visualization.refresh_topology"
)}
></ha-icon-button>
</ha-network-graph>
`;
}
private _renderInputSearch(slot = "") {
return html`<ha-input-search
appearance="outlined"
slot=${slot}
.value=${this._searchFilter}
@input=${this._handleSearchChange}
></ha-input-search>`;
}
private _handleSearchChange(ev: InputEvent): void {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private async _refreshTopology(): Promise<void> {
if (this._refreshing) {
return;
}
this._refreshing = true;
try {
this._topology = await fetchMatterNetworkTopology(this.hass, true);
} catch (err: unknown) {
this._error = (err as { message?: string })?.message || String(err);
} finally {
this._refreshing = false;
}
}
private _formatNetworkData = memoizeOne(
(
topology: MatterNetworkTopology,
_devices: HomeAssistant["devices"],
_areas: HomeAssistant["areas"],
// node/link colors and labels also depend on the theme and language,
// so both take part in the cache key even though they are read via hass
_themes: HomeAssistant["themes"],
_language: HomeAssistant["language"]
) => createMatterNetworkChartData(topology, this.hass, this)
);
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
return this._topology?.nodes.find((node) => node.id === id);
}
private _getConnection(
source: string,
target: string
): MatterNetworkTopologyConnection | undefined {
return this._topology?.connections.find(
(conn) =>
(conn.source === source && conn.target === target) ||
(conn.source === target && conn.target === source)
);
}
private _getNodeName(id: string): string {
const node = this._getTopologyNode(id);
return node ? getTopologyNodeName(node, this.hass) : id;
}
private _getSearchableAttributes = (nodeId: string): string[] => {
const node = this._getTopologyNode(nodeId);
if (!node) {
return [];
}
const attributes: string[] = [];
if (node.node_id != null) {
attributes.push(String(node.node_id));
}
if (node.network_name) {
attributes.push(node.network_name);
}
if (node.ext_address) {
attributes.push(node.ext_address);
}
if (node.vendor_name) {
attributes.push(node.vendor_name);
}
if (node.model_name) {
attributes.push(node.model_name);
}
if (node.host_name) {
attributes.push(node.host_name);
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
if (device?.manufacturer) {
attributes.push(device.manufacturer);
}
if (device?.model) {
attributes.push(device.model);
}
device?.connections.forEach((connection) => {
attributes.push(connection[1]);
});
return attributes;
};
private _localizeDynamic(prefix: string, value: string): string {
return (
this.hass.localize(
`ui.panel.config.matter.${prefix}.${value}` as LocalizeKeys
) || value
);
}
private _formatDirection(direction: MatterTopologyDirectionInfo): string {
const strength = this._localizeDynamic(
"visualization.strength",
direction.strength
);
if (direction.lqi != null) {
return `${strength} (LQI ${direction.lqi})`;
}
if (direction.rssi != null) {
return `${strength} (RSSI ${direction.rssi} dBm)`;
}
return strength;
}
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target } = data as { source: string; target: string };
const conn = this._getConnection(source, target);
if (!conn) {
return nothing;
}
const lines: TemplateResult[] = [];
// the link color now encodes the transport, and the graph legend can
// only describe nodes, so name it here
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this._localizeDynamic("network_type", conn.network)}`
);
if (conn.source_to_target) {
lines.push(
html`<br />${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}:
${this._formatDirection(conn.source_to_target)}`
);
}
if (conn.target_to_source) {
lines.push(
html`<br />${this._getNodeName(conn.target)}
${this._getNodeName(conn.source)}:
${this._formatDirection(conn.target_to_source)}`
);
}
if (!conn.source_to_target && !conn.target_to_source) {
// no per-direction reading: state the overall strength the width is
// drawn from, so this edge class is not left unexplained
const details = [
this._localizeDynamic("visualization.strength", conn.strength),
];
if (conn.via_route_table) {
details.push(
this.hass.localize(
"ui.panel.config.matter.visualization.via_route_table"
)
);
}
lines.push(html`<br />${details.join(" • ")}`);
}
return html`<b
>${this._getNodeName(conn.source)}
${this._getNodeName(conn.target)}</b
>${lines}`;
}
const { id } = data as { id: string };
if (id === HOME_ASSISTANT_NODE_ID) {
return html`<b>Home Assistant</b>`;
}
const node = this._getTopologyNode(id);
if (!node) {
return nothing;
}
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const lines: TemplateResult[] = [];
if (node.node_id != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.node_id"
)}:</b
>
${node.node_id}`
);
}
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this._localizeDynamic("network_type", node.network_type)}${
node.network_name ? html` (${node.network_name})` : nothing
}`
);
if (node.role) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.role"
)}:</b
>
${this._localizeDynamic("visualization.roles", node.role)}`
);
}
if (node.available != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.status"
)}:</b
>
${this.hass.localize(
node.available
? "ui.panel.config.matter.visualization.online"
: "ui.panel.config.matter.visualization.offline"
)}`
);
}
if (device?.manufacturer || node.vendor_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.manufacturer"
)}:</b
>
${device?.manufacturer || node.vendor_name}`
);
}
if (device?.model || node.model_name) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.model"
)}:</b
>
${device?.model || node.model_name}`
);
}
if (area) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.area"
)}:</b
>
${area.name}`
);
}
if (node.last_seen != null) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.last_seen"
)}:</b
>
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
);
}
return html`<b>${this._getNodeName(id)}</b>${lines}`;
};
private _handleChartClick(e: CustomEvent): void {
if (
e.detail.dataType === "node" &&
e.detail.event.target.cursor === "pointer"
) {
const { id } = e.detail.data;
const node = this._getTopologyNode(id);
if (node?.ha_device_id) {
navigate(`/config/devices/device/${node.ha_device_id}`);
}
}
}
static get styles(): CSSResultGroup {
return [
css`
ha-network-graph {
height: 100%;
}
[slot="header"] {
display: flex;
align-items: center;
}
ha-input-search {
flex: 1;
}
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--ha-space-4);
box-sizing: border-box;
}
ha-alert {
max-width: 500px;
}
.empty {
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"matter-network-visualization": MatterNetworkVisualization;
}
}
+108 -285
View File
@@ -1,9 +1,8 @@
import {
mdiChartBoxOutline,
mdiDotsVertical,
mdiDownload,
mdiFilterRemove,
mdiImagePlus,
mdiTuneVariant,
} from "@mdi/js";
import { differenceInHours } from "date-fns";
import type {
@@ -11,21 +10,17 @@ import type {
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { shallowEqual } from "../../common/util/shallow-equal";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
historyLogbookTargetsEqual,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
@@ -34,25 +29,14 @@ import {
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
import "../../components/chart/state-history-charts";
import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
import "../../components/date-picker/ha-date-range-nav";
import "../../components/ha-button";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import "../../components/ha-icon-button";
import {
applySourceFilters,
countSourceFilters,
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import "../../components/ha-spinner";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { HistoryResult } from "../../data/history";
import {
computeHistory,
@@ -68,8 +52,6 @@ import type { HomeAssistant } from "../../types";
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
import { csvSafeString, csvDownload } from "../../util/csv";
const EMPTY_STATES: HomeAssistant["states"] = {};
@customElement("ha-panel-history")
class HaPanelHistory extends LitElement {
@property({ attribute: false }) hass!: HomeAssistant;
@@ -96,19 +78,6 @@ class HaPanelHistory extends LitElement {
@state() private _isLoading = false;
@state() private _filters: SourceFilters = {};
@storage({
key: "historySourceFilters",
state: false,
subscribe: false,
})
private _storedFilters?: SourceFilters;
@state() private _showSources?: boolean;
@state() private _entitySources?: EntitySources;
@state() private _stateHistory?: HistoryResult;
private _mungedStateHistory?: HistoryResult;
@@ -123,8 +92,6 @@ class HaPanelHistory extends LitElement {
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _fetchedEntityIds?: string[];
private _interval?: number;
public constructor() {
@@ -152,24 +119,7 @@ class HaPanelHistory extends LitElement {
}
protected render() {
const entityIds = this._getEntityIds();
const targetCount = countTargets(this._targetPickerValue);
const filterCount = countSourceFilters(this._filters);
const sourceCount = targetCount + filterCount;
const hasTargets = targetCount > 0;
// A target whose entities are all filtered out fetches nothing.
const loading =
this._isLoading || (entityIds.length > 0 && !this._mungedStateHistory);
const hasResults =
!!this._mungedStateHistory &&
(this._mungedStateHistory.line.length > 0 ||
this._mungedStateHistory.timeline.length > 0);
const sourcesLabel = sourceCount
? this.hass.localize("ui.panel.history.sources_count", {
count: entityIds.length,
})
: this.hass.localize("ui.panel.history.sources");
const entitiesSelected = this._getEntityIds().length > 0;
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
@@ -178,6 +128,13 @@ class HaPanelHistory extends LitElement {
<h1 class="page-title" slot="title">
${this.hass.localize("panel.history")}
</h1>
<ha-icon-button
slot="actionItems"
@click=${this._removeAll}
.disabled=${this._isLoading || !entitiesSelected}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.panel.history.remove_all")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
@@ -196,112 +153,51 @@ class HaPanelHistory extends LitElement {
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<div class="main">
${
this._sourcesShown()
? html`<ha-filter-pane
.narrow=${this.narrow}
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${sourceCount}
.resultCount=${entityIds.length}
.disabled=${this._isLoading}
@close-filter-pane=${this._closeSources}
@clear-filter=${this._clearSources}
>
<ha-sources-picker
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.disabled=${this._isLoading}
.description=${this.hass.localize(
"ui.panel.history.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
</ha-filter-pane>`
: nothing
}
<div class="content-column">
<div class="toolbar">
${
this._sourcesShown() && !this.narrow
? nothing
: html`<ha-filter-pane-chip
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${filterCount}
.active=${sourceCount > 0}
.disabled=${this._isLoading}
@click=${this._toggleSources}
></ha-filter-pane-chip>`
}
<ha-date-range-nav
.disabled=${this._isLoading}
.startDate=${this._startDate}
.endDate=${this._endDate}
extended-presets
time-picker
@value-changed=${this._dateRangeChanged}
></ha-date-range-nav>
</div>
<div class="results ha-scrollbar">
${
loading
? html`<div class="progress-wrapper">
<ha-spinner></ha-spinner>
</div>`
: !hasTargets || !hasResults
? this._renderEmptyState(hasTargets)
: html`
<state-history-charts
.hass=${this.hass}
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
`
}
</div>
</div>
<div class="flex content ha-scrollbar">
<div class="filters">
<ha-date-range-picker
?disabled=${this._isLoading}
.startDate=${this._startDate}
.endDate=${this._endDate}
extended-presets
time-picker
@value-changed=${this._dateRangeChanged}
></ha-date-range-picker>
<ha-target-picker
.hass=${this.hass}
.value=${this._targetPickerValue}
.disabled=${this._isLoading}
add-on-top
@value-changed=${this._targetsChanged}
compact
></ha-target-picker>
</div>
${
this._isLoading
? html`<div class="progress-wrapper">
<ha-spinner></ha-spinner>
</div>`
: !entitiesSelected
? html`<div class="start-search">
${this.hass.localize("ui.panel.history.start_search")}
</div>`
: html`
<state-history-charts
.hass=${this.hass}
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
sync-charts
>
</state-history-charts>
`
}
</div>
</ha-top-app-bar-fixed>
`;
}
private _renderEmptyState(hasTargets: boolean) {
return html`
<ha-empty-state
.icon=${mdiChartBoxOutline}
.heading=${this.hass.localize(
hasTargets
? "ui.panel.history.no_results_title"
: "ui.panel.history.start_search_title"
)}
.description=${this.hass.localize(
hasTargets
? "ui.panel.history.no_results"
: "ui.panel.history.start_search"
)}
>
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize(
hasTargets
? "ui.panel.history.change_sources"
: "ui.panel.history.add_targets"
)}
</ha-button>
</ha-empty-state>
`;
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -330,22 +226,12 @@ class HaPanelHistory extends LitElement {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const urlTarget = historyLogbookTargetFromQueryParams(queryParams);
const initialValue = urlTarget ?? this._storedTargetPickerValue;
const initialValue =
historyLogbookTargetFromQueryParams(queryParams) ??
this._storedTargetPickerValue;
if (initialValue) {
this._targetPickerValue = initialValue;
}
// A target linked from another page must not be narrowed by stored filters.
if (
this._storedFilters &&
(!urlTarget ||
historyLogbookTargetsEqual(
urlTarget,
this._storedTargetPickerValue ?? {}
))
) {
this._filters = this._storedFilters;
}
if (queryParams.start_date) {
this._startDate = queryParams.start_date;
}
@@ -356,9 +242,6 @@ class HaPanelHistory extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
this._showBack = true;
@@ -372,39 +255,18 @@ class HaPanelHistory extends LitElement {
if (
changedProps.has("_startDate") ||
changedProps.has("_endDate") ||
!shallowEqual(this._getEntityIds(), this._fetchedEntityIds)
changedProps.has("_targetPickerValue") ||
(!this._stateHistory &&
(changedProps.has("_deviceEntityLookup") ||
changedProps.has("_areaEntityLookup") ||
changedProps.has("_areaDeviceLookup")))
) {
this._getHistory();
this._getStats();
}
}
private _sourcesShown(): boolean {
return this._showSources ?? !this.narrow;
}
private _toggleSources() {
this._showSources = !this._sourcesShown();
}
private _openSources() {
this._showSources = true;
}
private _closeSources() {
this._showSources = false;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
) {
this._filters = ev.detail.value;
this._storedFilters = this._filters;
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
private _removeAll() {
this._targetPickerValue = {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
@@ -412,7 +274,6 @@ class HaPanelHistory extends LitElement {
private async _getStats() {
const statisticIds = this._getEntityIds();
this._fetchedEntityIds = statisticIds;
if (statisticIds.length === 0) {
this._statisticsHistory = undefined;
@@ -449,13 +310,9 @@ class HaPanelHistory extends LitElement {
private async _getHistory() {
const entityIds = this._getEntityIds();
this._fetchedEntityIds = entityIds;
if (entityIds.length === 0) {
// The running subscription would keep pushing the previous entities.
this._unsubscribeHistory();
this._stateHistory = undefined;
this._isLoading = false;
return;
}
@@ -485,7 +342,6 @@ class HaPanelHistory extends LitElement {
);
this._subscribed.catch(() => {
this._isLoading = false;
this._stateHistory = { line: [], timeline: [] };
this._unsubscribeHistory();
});
if (this._endDate > now) {
@@ -521,44 +377,24 @@ class HaPanelHistory extends LitElement {
}
private _getEntityIds(): string[] {
return this.__filterEntityIds(
this.__resolveTargetEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
return this.__getEntityIds(
this._targetPickerValue,
this.hass.entities,
this._entitySources
this.hass.devices,
this.hass.areas
);
}
// Same rules as the target picker, so that the chip and the picker agree.
private __resolveTargetEntityIds = memoizeOne(
private __getEntityIds = memoizeOne(
(
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
): string[] => {
const picked = new Set(ensureArray(targetPickerValue.entity_id));
return resolveEntityIDs(
this.hass,
targetPickerValue,
entities,
devices,
areas
).filter(
(entityId) => picked.has(entityId) || !entities[entityId]?.hidden
);
}
): string[] =>
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
);
private __filterEntityIds = memoizeOne(applySourceFilters);
private _dateRangeChanged(ev) {
this._startDate = ev.detail.value.startDate;
this._endDate = ev.detail.value.endDate;
@@ -737,14 +573,7 @@ class HaPanelHistory extends LitElement {
line-height: inherit;
}
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
}
.content {
display: flex;
flex-direction: column;
height: calc(
100vh - var(--header-height, 0px) - var(
--safe-area-inset-top,
@@ -752,55 +581,13 @@ class HaPanelHistory extends LitElement {
) - var(--safe-area-inset-bottom, 0px)
);
box-sizing: border-box;
overflow: hidden;
overflow-x: hidden;
padding: 0 16px 16px;
}
.main {
display: flex;
flex: 1;
min-height: 0;
}
.content-column {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
overflow-x: auto;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar {
display: none;
}
.toolbar > * {
flex-shrink: 0;
}
.results {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
:host([virtualize]) {
height: 100%;
--ha-generic-picker-max-width: 400px;
}
.progress-wrapper {
@@ -810,6 +597,42 @@ class HaPanelHistory extends LitElement {
flex-direction: column;
padding: 16px;
}
.filters {
display: flex;
align-items: flex-start;
margin-top: 16px;
}
ha-date-range-picker {
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
max-width: 100%;
direction: var(--direction);
}
ha-target-picker {
flex: 1;
max-width: 100%;
min-width: 0;
}
@media all and (max-width: 1025px) {
.filters {
flex-direction: column;
}
ha-date-range-picker {
width: 100%;
margin-bottom: 8px;
}
}
.start-search {
padding-top: 16px;
text-align: center;
color: var(--secondary-text-color);
}
`,
];
}
+1 -37
View File
@@ -2,7 +2,7 @@ import type { VisibilityChangedEvent } from "@lit-labs/virtualizer";
import memoizeOne from "memoize-one";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators";
import { customElement, eventOptions, property } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { restoreScroll } from "../../common/decorators/restore-scroll";
@@ -60,8 +60,6 @@ class HaLogbookRenderer extends LitElement {
// @ts-ignore
@restoreScroll(".container") private _savedScrollPos?: number;
@state() private _firstVisibleIndex = 0;
protected willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && this.virtualize) ||
@@ -82,11 +80,9 @@ class HaLogbookRenderer extends LitElement {
return (
changedProps.has("entries") ||
changedProps.has("traceContexts") ||
changedProps.has("noDetail") ||
changedProps.has("userIdToName") ||
changedProps.has("systemUserIds") ||
changedProps.has("_firstVisibleIndex" as never) ||
languageChanged
);
}
@@ -100,24 +96,12 @@ class HaLogbookRenderer extends LitElement {
`;
}
// Rows positioned by the virtualizer cannot carry a sticky date header.
const floatingEntry = this.virtualize
? this.entries[this._firstVisibleIndex]
: undefined;
return html`
<div
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
@logbook-entry-selected=${this._handleEntrySelected}
>
${
floatingEntry
? html`<h4 class="date floating-date">
${this._formatDateHeader(new Date(floatingEntry.when * 1000))}
</h4>`
: nothing
}
${
this.virtualize
? html`<lit-virtualizer
@@ -192,11 +176,6 @@ class HaLogbookRenderer extends LitElement {
});
}
private _dayOf(index: number): number | undefined {
const entry = this.entries[index];
return entry ? new Date(entry.when * 1000).setHours(0, 0, 0, 0) : undefined;
}
private _formatDateHeader(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -221,10 +200,6 @@ class HaLogbookRenderer extends LitElement {
@eventOptions({ passive: true })
private _visibilityChanged(e: VisibilityChangedEvent) {
const first = Math.max(0, e.first);
if (this._dayOf(first) !== this._dayOf(this._firstVisibleIndex)) {
this._firstVisibleIndex = first;
}
fireEvent(this, "hass-logbook-live", {
enable: e.first === 0,
});
@@ -251,23 +226,12 @@ class HaLogbookRenderer extends LitElement {
font-weight: var(--ha-font-weight-medium);
}
.floating-date {
position: absolute;
top: 0;
inset-inline: 0;
z-index: 2;
margin: 0;
padding-bottom: var(--ha-space-2);
background-color: var(--card-background-color);
}
.no-entries {
text-align: center;
color: var(--secondary-text-color);
}
.container {
position: relative;
max-height: var(--logbook-max-height);
}
+13 -21
View File
@@ -29,17 +29,15 @@ const idsChanged = (oldIds?: string[], newIds?: string[]) => {
if (oldIds === undefined && newIds === undefined) {
return false;
}
if (!oldIds || !newIds || oldIds.length !== newIds.length) {
return true;
}
const newIdSet = new Set(newIds);
return oldIds.some((val) => !newIdSet.has(val));
return (
!oldIds ||
!newIds ||
oldIds.length !== newIds.length ||
oldIds.some((val) => !newIds.includes(val)) ||
newIds.some((val) => !oldIds.includes(val))
);
};
/**
* @slot empty - Shown instead of the default text when there is no activity,
* for surfaces that can offer a way out (e.g. changing the filters).
*/
@customElement("ha-logbook")
export class HaLogbook extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -128,11 +126,9 @@ export class HaLogbook extends LitElement {
}
if (this._logbookEntries.length === 0) {
return html`<slot name="empty">
<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>
</slot>`;
return html`<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>`;
}
return html`
@@ -247,21 +243,17 @@ export class HaLogbook extends LitElement {
if (this._unsubLogbook) {
this._unsubLogbook.then((unsub) => unsub());
this._unsubLogbook = undefined;
this._logbookEntries = loading ? undefined : [];
this._pendingStreamMessages = [];
}
this._logbookEntries = loading ? undefined : [];
}
public connectedCallback() {
super.connectedCallback();
this._attachReadyListener();
if (this.hasUpdated) {
if (this._filterAlwaysEmptyResults) {
this._unsubscribe(false);
} else {
// Ensure clean state before subscribing
this._subscribeLogbookPeriod(this._calculateLogbookPeriod());
}
// Ensure clean state before subscribing
this._subscribeLogbookPeriod(this._calculateLogbookPeriod());
}
}
+93 -299
View File
@@ -3,51 +3,34 @@ import {
mdiDownload,
mdiFilterRemove,
mdiRefresh,
mdiTextBoxOutline,
mdiTuneVariant,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fromUnixTime } from "date-fns";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
historyLogbookTargetsEqual,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
import { deepEqual } from "../../common/util/deep-equal";
import { shallowEqual } from "../../common/util/shallow-equal";
import "../../components/date-picker/ha-date-range-nav";
import "../../components/ha-button";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import "../../components/ha-icon-button";
import {
applySourceFilters,
countSourceFilters,
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import { filterLogbookCompatibleEntities } from "../../data/logbook";
import { resolveEntityIDs } from "../../data/selector";
import { haStyle } from "../../resources/styles";
@@ -56,8 +39,6 @@ import "./ha-logbook";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { csvDownload, csvSafeString } from "../../util/csv";
const EMPTY_STATES: HomeAssistant["states"] = {};
interface LogbookState {
time: { range: [Date, Date] };
targetPickerValue: HassServiceTarget;
@@ -76,12 +57,6 @@ export class HaPanelLogbook extends LitElement {
@state()
private _showBack?: boolean;
@state() private _filters: SourceFilters = {};
@state() private _showSources?: boolean;
@state() private _entitySources?: EntitySources;
@state() private _targetPickerValue: HassServiceTarget = {};
// Remembers the last user-picked selection as a fallback for visits without
@@ -94,34 +69,25 @@ export class HaPanelLogbook extends LitElement {
})
private _storedTargetPickerValue?: HassServiceTarget;
@storage({
key: "logbookSourceFilters",
state: false,
subscribe: false,
})
private _storedFilters?: SourceFilters;
public constructor() {
super();
this._time = this._defaultState.time;
}
protected render() {
const entityIds = this._getEntityIds();
const filterCount = countSourceFilters(this._filters);
const sourceCount = countTargets(this._targetPickerValue) + filterCount;
const sourcesLabel = sourceCount
? this.hass.localize("ui.panel.logbook.sources_count", {
count: entityIds?.length ?? 0,
})
: this.hass.localize("ui.panel.logbook.sources");
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!!this._showBack}
>
<div slot="title">${this.hass.localize("panel.logbook")}</div>
<ha-icon-button
slot="actionItems"
@click=${this._resetLogbook}
.disabled=${this._isDefaultState()}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.common.reset")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
@@ -139,131 +105,40 @@ export class HaPanelLogbook extends LitElement {
${this.hass.localize("ui.panel.logbook.download_data")}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="reset" .disabled=${this._isDefaultState()}>
${this.hass.localize("ui.common.reset")}
<ha-svg-icon slot="icon" .path=${mdiFilterRemove}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<div class="main">
${
this._sourcesShown()
? html`<ha-filter-pane
.narrow=${this.narrow}
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${sourceCount}
.resultCount=${entityIds?.length}
@close-filter-pane=${this._closeSources}
@clear-filter=${this._clearSources}
>
<ha-sources-picker
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.entityFilter=${this._filterFunc}
.description=${this.hass.localize(
"ui.panel.logbook.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
</ha-filter-pane>`
: nothing
}
<div class="content-column">
<div class="toolbar">
${
this._sourcesShown() && !this.narrow
? nothing
: html`<ha-filter-pane-chip
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${filterCount}
.active=${sourceCount > 0}
@click=${this._toggleSources}
></ha-filter-pane-chip>`
}
<ha-date-range-nav
.startDate=${this._time.range[0]}
.endDate=${this._time.range[1]}
@value-changed=${this._dateRangeChanged}
time-picker
></ha-date-range-nav>
</div>
<div class="filters">
<ha-date-range-picker
.startDate=${this._time.range[0]}
.endDate=${this._time.range[1]}
@value-changed=${this._dateRangeChanged}
time-picker
></ha-date-range-picker>
<ha-logbook
.hass=${this.hass}
.time=${this._time}
.entityIds=${entityIds}
.narrow=${this.narrow}
show-cause
virtualize
>
${
sourceCount > 0
? html`<ha-empty-state
slot="empty"
.icon=${mdiTextBoxOutline}
.heading=${this.hass.localize(
"ui.panel.logbook.no_results_title"
)}
.description=${this.hass.localize(
"ui.panel.logbook.no_results"
)}
>
<ha-button
appearance="plain"
@click=${this._openSources}
>
${this.hass.localize(
"ui.panel.logbook.change_sources"
)}
</ha-button>
</ha-empty-state>`
: nothing
}
</ha-logbook>
</div>
<ha-target-picker
.hass=${this.hass}
.entityFilter=${this._filterFunc}
.value=${this._targetPickerValue}
add-on-top
@value-changed=${this._targetsChanged}
compact
></ha-target-picker>
</div>
<ha-logbook
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._getEntityIds()}
.narrow=${this.narrow}
show-cause
virtualize
></ha-logbook>
</div>
</ha-top-app-bar-fixed>
`;
}
private _sourcesShown(): boolean {
return this._showSources ?? !this.narrow;
}
private _toggleSources() {
this._showSources = !this._sourcesShown();
}
private _openSources() {
this._showSources = true;
}
private _closeSources() {
this._showSources = false;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
) {
this._filters = ev.detail.value;
this._storedFilters = this._filters;
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
this._targetPickerValue = {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
}
private _filterFunc: HaEntityPickerEntityFilterFunc = (entity) =>
filterLogbookCompatibleEntities(entity);
@@ -280,9 +155,6 @@ export class HaPanelLogbook extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
@@ -307,38 +179,20 @@ export class HaPanelLogbook extends LitElement {
this._applyURLParams();
};
/** The entities to show activity for, or undefined for all of them. */
private _getEntityIds(): string[] | undefined {
const hasTargets = countTargets(this._targetPickerValue) > 0;
const targetEntities = hasTargets
? this.__filterTargetEntityIds(
this.__resolveTargetEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
),
this._targetPickerValue.entity_id,
this.hass.entities,
this.hass.states
)
: undefined;
if (!countSourceFilters(this._filters)) {
return targetEntities;
}
return this.__filterEntityIds(
targetEntities ?? this.__logbookEntityIds(this.hass.states),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
const entities = this.__getEntityIds(
this._targetPickerValue,
this.hass.entities,
this._entitySources
this.hass.devices,
this.hass.areas
);
if (entities.length === 0) {
return undefined;
}
return entities;
}
private __resolveTargetEntityIds = memoizeOne(
private __getEntityIds = memoizeOne(
(
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
@@ -348,53 +202,6 @@ export class HaPanelLogbook extends LitElement {
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
);
// Same rules as the target picker, so that the chip and the picker agree.
private __filterTargetEntityIds = memoizeOne(
(
entityIds: string[],
pickedEntityIds: string | string[] | undefined,
entities: HomeAssistant["entities"],
states: HomeAssistant["states"]
): string[] => {
const picked = new Set(ensureArray(pickedEntityIds));
return this._stableEntityIds(
entityIds.filter((entityId) => {
if (picked.has(entityId)) {
return true;
}
const stateObj = states[entityId];
return (
!entities[entityId]?.hidden &&
stateObj &&
filterLogbookCompatibleEntities(stateObj)
);
})
);
}
);
private __logbookEntityIds = memoizeOne(
(states: HomeAssistant["states"]): string[] =>
this._stableEntityIds(
Object.values(states)
.filter((stateObj) => filterLogbookCompatibleEntities(stateObj))
.map((stateObj) => stateObj.entity_id)
)
);
private __filterEntityIds = memoizeOne(applySourceFilters);
private _lastEntityIds?: string[];
// A list keyed on the states must keep its identity or ha-logbook resubscribes.
private _stableEntityIds(entityIds: string[]): string[] {
if (this._lastEntityIds && shallowEqual(this._lastEntityIds, entityIds)) {
return this._lastEntityIds;
}
this._lastEntityIds = entityIds;
return entityIds;
}
private _applyURLParams() {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
@@ -406,19 +213,6 @@ export class HaPanelLogbook extends LitElement {
this._targetPickerValue = this._storedTargetPickerValue;
}
// A target linked from another page must not be narrowed by the filters.
if (
targetPickerValue &&
!historyLogbookTargetsEqual(
targetPickerValue,
this._storedTargetPickerValue ?? {}
)
) {
this._filters = {};
} else if (!this.hasUpdated && this._storedFilters) {
this._filters = this._storedFilters;
}
if (queryParams.start_date || queryParams.end_date) {
const startDate = queryParams.start_date ?? this._time.range[0];
const endDate = queryParams.end_date ?? this._time.range[1];
@@ -479,12 +273,9 @@ export class HaPanelLogbook extends LitElement {
}
private _isDefaultState(): boolean {
return (
!countSourceFilters(this._filters) &&
deepEqual(
{ time: this._time, targetPickerValue: this._targetPickerValue },
this._defaultState
)
return deepEqual(
{ time: this._time, targetPickerValue: this._targetPickerValue },
this._defaultState
);
}
@@ -493,8 +284,6 @@ export class HaPanelLogbook extends LitElement {
this._time = defaultState.time;
this._targetPickerValue = defaultState.targetPickerValue;
this._storedTargetPickerValue = undefined;
this._filters = {};
this._storedFilters = undefined;
navigate("/logbook", { replace: true });
}
@@ -511,9 +300,6 @@ export class HaPanelLogbook extends LitElement {
case "refresh":
this._refreshLogbook();
break;
case "reset":
this._resetLogbook();
break;
}
}
@@ -577,7 +363,6 @@ export class HaPanelLogbook extends LitElement {
haStyle,
css`
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
}
@@ -590,50 +375,59 @@ export class HaPanelLogbook extends LitElement {
0px
) - var(--safe-area-inset-bottom, 0px)
);
box-sizing: border-box;
overflow: hidden;
}
.main {
display: flex;
flex: 1;
min-height: 0;
}
.content-column {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
overflow-x: auto;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar {
display: none;
}
.toolbar > * {
flex-shrink: 0;
overflow-x: hidden;
padding: 0 0 16px;
}
ha-logbook {
flex: 1;
min-height: 0;
}
ha-date-range-picker {
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
max-width: 100%;
direction: var(--direction);
}
@media all and (max-width: 870px) {
ha-date-range-picker {
width: 100%;
}
.filters {
flex-direction: column;
}
}
:host([narrow]) ha-date-range-picker {
margin-right: 0;
margin-inline-end: 0;
margin-inline-start: initial;
direction: var(--direction);
margin-bottom: 8px;
}
.content {
overflow-x: hidden;
}
.filters {
display: flex;
padding: 16px 16px 0;
}
:host([narrow]) .filters {
flex-wrap: wrap;
}
ha-target-picker {
flex: 1;
max-width: 100%;
min-width: 0;
}
`,
];
}
+51 -18
View File
@@ -790,6 +790,11 @@
}
},
"target-picker": {
"expand": "Expand",
"expand_floor_id": "Split this floor into separate areas",
"expand_area_id": "Split this area into separate devices and entities",
"expand_device_id": "Split this device into separate entities",
"expand_label_id": "Split this label into separate areas, devices and entities",
"add_target": "Add target",
"remove": "Remove",
"remove_floor_id": "Remove floor",
@@ -802,6 +807,7 @@
"device_not_found": "Device not found",
"entity_not_found": "Entity not found",
"label_not_found": "Label not found",
"replaced_device": "Replaced device",
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"replace_device": "Replace",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
@@ -835,9 +841,6 @@
},
"style": "Time format style"
},
"filter-device-classes": {
"caption": "Device class"
},
"subpage-data-table": {
"filters": "Filters",
"show_results": "Show {number} results",
@@ -8419,6 +8422,7 @@
"status_online": "Online",
"status_offline": "Offline",
"my_network_title": "My network",
"show_map": "[%key:ui::panel::config::bluetooth::show_map%]",
"devices": "{count, plural,\n one {# device}\n other {# devices}\n}",
"device_count": "{count, plural,\n one {# device}\n other {# devices}\n}",
"entity_count": "{count, plural,\n one {# entity}\n other {# entities}\n}",
@@ -8467,6 +8471,48 @@
}
}
},
"visualization": {
"header": "Network visualization",
"refresh_topology": "Refresh topology",
"border_router": "Border router",
"router": "Router",
"end_device": "End device",
"wifi_ap": "Wi-Fi access point",
"offline": "Offline",
"online": "Online",
"unknown_device": "Unknown device",
"unknown_devices": "Unknown devices",
"node": "Node {node_id}",
"node_id": "Node ID",
"network": "Network",
"role": "Role",
"status": "Status",
"manufacturer": "Manufacturer",
"model": "Model",
"area": "Area",
"last_seen": "Last seen",
"via_route_table": "Learned from routing table",
"empty": "No network topology data is available yet.",
"not_supported": "The connected Matter server does not support network topology. Update your Matter server to use this feature.",
"error_loading": "Failed to load the network topology: {error}",
"strength": {
"strong": "Strong",
"medium": "Medium",
"weak": "Weak",
"none": "None",
"unknown": "Unknown"
},
"roles": {
"leader": "Leader",
"router": "Router",
"reed": "Router-eligible end device",
"end_device": "End device",
"sleepy_end_device": "Sleepy end device",
"unassigned": "Unassigned",
"station": "Station",
"ap": "Access point"
}
},
"network_type": {
"thread": "Thread",
"wifi": "Wi-Fi",
@@ -11717,16 +11763,9 @@
}
},
"history": {
"sources": "Sources",
"sources_count": "Sources: {count}",
"add_targets": "Add targets",
"change_sources": "Change sources",
"no_targets": "No targets selected",
"start_search_title": "Nothing to show yet",
"start_search": "Select areas, devices, entities or labels to see their history",
"no_results_title": "No results found",
"no_results": "No history for the current selection. Try changing the targets, filters, or time range.",
"start_search": "Select areas, devices, entities or labels above",
"add_all": "Add all entities",
"remove_all": "Remove all selections",
"download_data": "Download data",
"download_data_error": "Unable to download data",
"add_card": "Add current view as card",
@@ -11734,12 +11773,6 @@
"error_no_data": "You need to select some data sources first."
},
"logbook": {
"sources": "[%key:ui::panel::history::sources%]",
"sources_count": "[%key:ui::panel::history::sources_count%]",
"change_sources": "[%key:ui::panel::history::change_sources%]",
"no_targets": "Showing all activity. Add targets to narrow it down.",
"no_results_title": "[%key:ui::panel::history::no_results_title%]",
"no_results": "No activity for the current selection. Try changing the targets, filters, or time range.",
"download_data": "[%key:ui::panel::history::download_data%]",
"download_data_error": "[%key:ui::panel::history::download_data_error%]",
"error_no_data": "No activity for the selected target in the selected time range."
@@ -0,0 +1,769 @@
import { describe, expect, it } from "vitest";
import type {
MatterNetworkTopology,
MatterNetworkTopologyConnection,
MatterNetworkTopologyNode,
} from "../../../../../src/data/matter";
import {
createMatterNetworkChartData,
getTopologyNodeCategory,
getTopologyNodeName,
networkToColorVar,
strengthToScale,
} from "../../../../../src/panels/config/integrations/integration-panels/matter/matter-network-data";
import type { HomeAssistant } from "../../../../../src/types";
const mockHass = (
devices: Record<string, Partial<HomeAssistant["devices"][string]>> = {},
areas: Record<string, Partial<HomeAssistant["areas"][string]>> = {}
): HomeAssistant =>
({
localize: (key: string) => key.split(".").pop(),
devices,
areas,
}) as unknown as HomeAssistant;
const node = (
overrides: Partial<MatterNetworkTopologyNode> & { id: string }
): MatterNetworkTopologyNode => ({
kind: "matter",
network_type: "thread",
...overrides,
});
const connection = (
overrides: Partial<MatterNetworkTopologyConnection> & {
source: string;
target: string;
}
): MatterNetworkTopologyConnection => ({
network: "thread",
strength: "strong",
...overrides,
});
const topology = (
nodes: MatterNetworkTopologyNode[],
connections: MatterNetworkTopologyConnection[] = []
): MatterNetworkTopology => ({
collected_at: 1767888000000,
nodes,
connections,
});
const element = document.createElement("div");
document.body.appendChild(element);
// jsdom resolves custom properties set inline, so color lookups can be
// asserted on real values; use a fresh element so nothing leaks into the
// tests that expect the bare element's empty strings
const themedElement = (): HTMLElement => {
const el = document.createElement("div");
el.style.setProperty("--primary-color", "#009ac7");
el.style.setProperty("--purple-color", "#926bc7");
el.style.setProperty("--orange-color", "#ff9800");
el.style.setProperty("--disabled-color", "#bdbdbd");
document.body.appendChild(el);
return el;
};
describe("strengthToScale", () => {
it("never returns a falsy value so the graph arrow stays suppressed", () => {
expect(strengthToScale("strong")).toBe(4);
expect(strengthToScale("medium")).toBe(3);
expect(strengthToScale("weak")).toBe(2);
expect(strengthToScale("none")).toBe(1);
expect(strengthToScale(undefined)).toBe(1);
expect(strengthToScale(null)).toBe(1);
});
it("renders an unmeasured link as present, above a dead one", () => {
// "unknown" (no measurement) must not collapse to the "none"/dead bucket
expect(strengthToScale("unknown")).toBe(2);
expect(strengthToScale("unknown")).toBeGreaterThan(strengthToScale("none"));
});
});
describe("networkToColorVar", () => {
it("separates the two transports and degrades gracefully", () => {
expect(networkToColorVar("thread")).toBe("--purple-color");
expect(networkToColorVar("wifi")).toBe("--orange-color");
expect(networkToColorVar("thread")).not.toBe(networkToColorVar("wifi"));
// the wire type is a plain string, not a union
expect(networkToColorVar("ethernet")).toBe("--secondary-text-color");
expect(networkToColorVar(undefined)).toBe("--secondary-text-color");
});
});
describe("getTopologyNodeCategory", () => {
it("maps kinds and roles to categories", () => {
// category 0 is reserved for the synthesized Home Assistant root node
expect(
getTopologyNodeCategory(node({ id: "br", kind: "border_router" }))
).toBe(1);
expect(getTopologyNodeCategory(node({ id: "1", role: "leader" }))).toBe(2);
expect(getTopologyNodeCategory(node({ id: "2", role: "router" }))).toBe(2);
expect(getTopologyNodeCategory(node({ id: "3", role: "reed" }))).toBe(2);
expect(getTopologyNodeCategory(node({ id: "4", role: "end_device" }))).toBe(
3
);
expect(
getTopologyNodeCategory(node({ id: "5", role: "sleepy_end_device" }))
).toBe(3);
expect(
getTopologyNodeCategory(
node({ id: "6", network_type: "wifi", role: "station" })
)
).toBe(3);
expect(
getTopologyNodeCategory(
node({ id: "ap_112233445566", kind: "wifi_ap", network_type: "wifi" })
)
).toBe(4);
expect(
getTopologyNodeCategory(
node({ id: "7", role: "router", available: false })
)
).toBe(5);
expect(
getTopologyNodeCategory(node({ id: "unknown_1", kind: "thread_unknown" }))
).toBe(6);
});
});
describe("getTopologyNodeName", () => {
it("prefers the HA device name", () => {
const hass = mockHass({
dev1: { name_by_user: "Living room plug", name: "Plug" },
});
expect(
getTopologyNodeName(
node({ id: "1", node_id: 1, ha_device_id: "dev1" }),
hass
)
).toBe("Living room plug");
});
it("falls back to wire metadata for external nodes", () => {
const hass = mockHass();
expect(
getTopologyNodeName(
node({ id: "br_1", kind: "border_router", vendor_name: "Apple" }),
hass
)
).toBe("Apple");
expect(
getTopologyNodeName(
node({
id: "ap_112233445566",
kind: "wifi_ap",
network_type: "wifi",
network_name: "MyWiFi",
}),
hass
)
).toBe("MyWiFi");
expect(
getTopologyNodeName(
node({ id: "unknown_1", kind: "thread_unknown" }),
hass
)
).toBe("unknown_device");
});
it("prefers the border router hostname over its generic vendor and model", () => {
// some vendors report the same vendor/model on every unit they ship, so
// vendor+model alone labels every border router identically
expect(
getTopologyNodeName(
node({
id: "br_1",
kind: "border_router",
host_name: "Cuisine",
vendor_name: "Apple",
model_name: "BorderRouter",
}),
mockHass()
)
).toBe("Cuisine");
});
it("keeps the HA device name ahead of the border router hostname", () => {
expect(
getTopologyNodeName(
node({
id: "br_1",
kind: "border_router",
ha_device_id: "dev1",
host_name: "Cuisine",
}),
mockHass({ dev1: { name: "Kitchen hub" } })
)
).toBe("Kitchen hub");
});
it("falls through to vendor and model when host_name is null", () => {
// core's serializer always emits the key, so null must behave as absent
expect(
getTopologyNodeName(
node({
id: "br_1",
kind: "border_router",
host_name: null,
vendor_name: "Apple",
model_name: "BorderRouter",
}),
mockHass()
)
).toBe("Apple BorderRouter");
});
it("names a Wi-Fi access point by its SSID, not its radio address", () => {
expect(
getTopologyNodeName(
node({
id: "ap_1",
kind: "wifi_ap",
network_type: "wifi",
ssid: "we@home",
network_name: "50:91:00:D9:62:00",
}),
mockHass()
)
).toBe("we@home");
});
it("falls back to the BSSID when the server sends no SSID", () => {
expect(
getTopologyNodeName(
node({
id: "ap_1",
kind: "wifi_ap",
network_type: "wifi",
ssid: null,
network_name: "50:91:00:D9:62:00",
}),
mockHass()
)
).toBe("50:91:00:D9:62:00");
});
});
describe("createMatterNetworkChartData", () => {
it("maps a thread mesh with a border router", () => {
const hass = mockHass(
{ dev1: { name: "Leader plug", area_id: "living" } },
{ living: { name: "Living room" } }
);
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, ha_device_id: "dev1", role: "leader" }),
node({ id: "2", node_id: 2, role: "end_device", available: true }),
node({ id: "br_1", kind: "border_router", vendor_name: "Apple" }),
],
[
connection({
source: "1",
target: "2",
strength: "medium",
source_to_target: { strength: "medium", lqi: 2 },
target_to_source: { strength: "medium", lqi: 2 },
}),
connection({
source: "1",
target: "br_1",
source_to_target: { strength: "strong", lqi: 3 },
target_to_source: { strength: "strong", lqi: 3 },
}),
]
),
hass,
element
);
expect(data.categories).toHaveLength(7);
// Home Assistant root + the 3 topology nodes
expect(data.nodes).toHaveLength(4);
const ha = data.nodes[0];
expect(ha.id).toBe("ha");
expect(ha.category).toBe(0);
expect(ha.fixed).toBe(true);
expect(ha.polarDistance).toBe(0);
const leader = data.nodes.find((n) => n.id === "1")!;
expect(leader.name).toBe("Leader plug");
expect(leader.context).toBe("Living room");
expect(leader.category).toBe(2);
expect(leader.itemStyle?.borderWidth).toBe(2);
const endDevice = data.nodes.find((n) => n.id === "2")!;
expect(endDevice.category).toBe(3);
expect(endDevice.itemStyle?.borderWidth).toBeUndefined();
const borderRouter = data.nodes.find((n) => n.id === "br_1")!;
expect(borderRouter.category).toBe(1);
expect(borderRouter.symbol).toBe("roundRect");
const meshLink = data.links.find(
(l) => l.source === "1" && l.target === "2"
)!;
expect(meshLink.value).toBe(3);
expect(meshLink.reverseValue).toBe(3);
expect(meshLink.lineStyle?.type).toBe("solid");
// HA anchors to the border router (the mesh's infrastructure), not to
// the individual routers hanging off it
const haLink = data.links.find((l) => l.source === "ha")!;
expect(haLink.target).toBe("br_1");
expect(haLink.symbol).toBe("none");
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(1);
});
it("marks asymmetric links dashed and keeps one-way arrows", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "2", node_id: 2, role: "router" }),
node({ id: "3", node_id: 3, role: "router" }),
],
[
connection({
source: "1",
target: "2",
source_to_target: { strength: "strong", lqi: 3 },
target_to_source: { strength: "weak", lqi: 1 },
}),
// only observed from node 3's side: 3 → 2
connection({
source: "2",
target: "3",
strength: "medium",
target_to_source: { strength: "medium", lqi: 2 },
}),
]
),
mockHass(),
element
);
const asymmetric = data.links.find(
(l) => l.source === "1" && l.target === "2"
)!;
expect(asymmetric.lineStyle?.type).toBe("dashed");
expect(asymmetric.value).toBe(4);
expect(asymmetric.reverseValue).toBe(2);
// one-way link is flipped so the arrow points the observed direction
const oneWay = data.links.find(
(l) => l.source === "3" && l.target === "2"
)!;
expect(oneWay.reverseValue).toBeUndefined();
expect(oneWay.symbolSize).toBeGreaterThan(0);
expect(oneWay.lineStyle?.type).toBe("dashed");
});
it("suppresses direction arrows on route-table edges", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "2", node_id: 2, role: "router" }),
],
[
connection({
source: "1",
target: "2",
// no measurement is "unknown"; "none" means observed dead
strength: "unknown",
via_route_table: true,
path_cost: 1,
}),
]
),
mockHass(),
element
);
const link = data.links[0];
expect(link.value).toBe(2);
expect(link.reverseValue).toBe(2);
expect(link.lineStyle?.type).toBe("dotted");
});
it("keeps every node attached to the force layout", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "2", node_id: 2, role: "router" }),
node({ id: "3", node_id: 3, role: "end_device" }),
node({ id: "br_1", kind: "border_router" }),
],
[
connection({
source: "1",
target: "br_1",
source_to_target: { strength: "strong", lqi: 3 },
target_to_source: { strength: "strong", lqi: 3 },
}),
connection({
source: "1",
target: "2",
strength: "weak",
source_to_target: { strength: "weak", lqi: 1 },
target_to_source: { strength: "weak", lqi: 1 },
}),
connection({
source: "2",
target: "3",
strength: "weak",
source_to_target: { strength: "weak", lqi: 1 },
target_to_source: { strength: "weak", lqi: 1 },
}),
]
),
mockHass(),
element
);
// hub link stays active, and every node has at least one active link
const hubLink = data.links.find((l) => l.target === "br_1")!;
expect(hubLink.ignoreForceLayout).toBe(false);
data.nodes.forEach((n) => {
const nodeLinks = data.links.filter(
(l) => l.source === n.id || l.target === n.id
);
expect(
nodeLinks.some((l) => !l.ignoreForceLayout),
`node ${n.id} has no active link`
).toBe(true);
});
});
it("tolerates minimal nodes and skips connections to unknown nodes", () => {
const data = createMatterNetworkChartData(
topology(
[node({ id: "1" })],
[connection({ source: "1", target: "missing" })]
),
mockHass(),
element
);
// Home Assistant root + the single topology node
expect(data.nodes).toHaveLength(2);
// the bogus connection is skipped, and a node with no visible route to
// Home Assistant gets no invented edge either
expect(data.links).toHaveLength(0);
});
it("floats routers whose route to Home Assistant is not visible", () => {
const data = createMatterNetworkChartData(
topology([
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "2", node_id: 2, role: "router" }),
]),
mockHass(),
element
);
// with no border router or access point in the graph there is no known
// path, and a drawn edge would read as a physical link
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(0);
expect(data.nodes.map((n) => n.id).sort()).toEqual(["1", "2", "ha"]);
});
it("shows an access point's radio address beside its SSID, and never twice", () => {
const named = createMatterNetworkChartData(
topology([
node({
id: "ap_1",
kind: "wifi_ap",
network_type: "wifi",
ssid: "we@home",
bssid: "50:91:00:D9:62:00",
network_name: "50:91:00:D9:62:00",
}),
]),
mockHass(),
element
);
const ap = named.nodes.find((n) => n.id === "ap_1")!;
// the radio address is what distinguishes two radios of one mesh
expect(ap.name).toBe("we@home");
expect(ap.context).toBe("50:91:00:D9:62:00");
const unnamed = createMatterNetworkChartData(
topology([
node({
id: "ap_1",
kind: "wifi_ap",
network_type: "wifi",
network_name: "50:91:00:D9:62:00",
}),
]),
mockHass(),
element
);
const bare = unnamed.nodes.find((n) => n.id === "ap_1")!;
// without an SSID the name is already the address, so no context repeat
expect(bare.name).toBe("50:91:00:D9:62:00");
expect(bare.context).toBeUndefined();
});
it("leaves a component of only unknown neighbours unanchored", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "unknown_1", kind: "thread_unknown" }),
node({ id: "unknown_2", kind: "thread_unknown" }),
],
[connection({ source: "unknown_1", target: "unknown_2" })]
),
mockHass(),
element
);
// neither the unknown pair nor the hubless router gets an edge to HA
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(0);
// the unknown pair keeps its own mesh edge
expect(data.links.filter((l) => l.source === "unknown_1")).toHaveLength(1);
});
it("anchors an unknown neighbour through its known peer, not through HA", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "unknown_1", kind: "thread_unknown" }),
],
[connection({ source: "1", target: "unknown_1" })]
),
mockHass(),
element
);
// floating must not mean dropping the node out of the graph
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(0);
expect(data.nodes.find((n) => n.id === "unknown_1")).toBeDefined();
expect(data.links.filter((l) => l.source === "1")).toHaveLength(1);
});
it("draws a lone unknown neighbour with no links at all", () => {
const data = createMatterNetworkChartData(
topology([node({ id: "unknown_1", kind: "thread_unknown" })]),
mockHass(),
element
);
expect(data.links).toHaveLength(0);
// polarDistance is what places an unlinked node in ha-network-graph;
// at 0 it would stack on the origin instead
const unknown = data.nodes.find((n) => n.id === "unknown_1")!;
expect(unknown.polarDistance).toBe(0.8);
});
it("colors links by transport, not by signal level", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "br_1", kind: "border_router" }),
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "ap_1", kind: "wifi_ap", network_type: "wifi" }),
node({ id: "7", node_id: 7, network_type: "wifi", role: "station" }),
],
[
connection({ source: "1", target: "br_1", strength: "weak" }),
connection({
source: "7",
target: "ap_1",
network: "wifi",
strength: "strong",
}),
]
),
mockHass(),
themedElement()
);
// a weak thread link and a strong wi-fi link differ by transport in the
// color channel and by level in the width channel
const threadLink = data.links.find((l) => l.source === "1")!;
const wifiLink = data.links.find((l) => l.source === "7")!;
expect(threadLink.lineStyle?.color).toBe("#926bc7");
expect(threadLink.lineStyle?.width).toBe(1);
expect(wifiLink.lineStyle?.color).toBe("#ff9800");
expect(wifiLink.lineStyle?.width).toBe(3);
// each hub node wears the same hue as the links behind it
expect(data.nodes.find((n) => n.id === "ap_1")!.itemStyle?.color).toBe(
"#ff9800"
);
expect(data.nodes.find((n) => n.id === "br_1")!.itemStyle?.color).toBe(
"#926bc7"
);
// an HA edge carries the hue of the transport behind that hub, so one
// network reads as one color the whole way back
const threadSpine = data.links.find(
(l) => l.source === "ha" && l.target === "br_1"
)!;
const wifiSpine = data.links.find(
(l) => l.source === "ha" && l.target === "ap_1"
)!;
expect(threadSpine.lineStyle?.color).toBe("#926bc7");
expect(wifiSpine.lineStyle?.color).toBe("#ff9800");
});
it("does not draw a link whose every direction is dead", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "br_1", kind: "border_router" }),
node({ id: "1", node_id: 1, role: "router" }),
],
[connection({ source: "1", target: "br_1", strength: "none" })]
),
mockHass(),
themedElement()
);
// the summary strength is the strongest direction, so "none" means every
// direction is dead -- a stale entry, not a weak link
expect(data.links.find((l) => l.source === "1")).toBeUndefined();
// the border router keeps its own edge to Home Assistant
expect(data.links.filter((l) => l.source === "ha")).toHaveLength(1);
});
it("dashes an edge to an inferred or offline endpoint", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "unknown_1", kind: "thread_unknown" }),
node({ id: "6", node_id: 6, role: "end_device", available: false }),
],
[
connection({
source: "1",
target: "unknown_1",
source_to_target: { strength: "medium", lqi: 2 },
target_to_source: { strength: "medium", lqi: 2 },
}),
connection({
source: "1",
target: "6",
source_to_target: { strength: "medium", lqi: 2 },
target_to_source: { strength: "medium", lqi: 2 },
}),
]
),
mockHass(),
element
);
// symmetric and two-way, so only the endpoint lowers the confidence
const inferred = data.links.find((l) => l.target === "unknown_1")!;
const offline = data.links.find((l) => l.target === "6")!;
expect(inferred.lineStyle?.type).toBe("dashed");
expect(offline.lineStyle?.type).toBe("dashed");
});
it("links Home Assistant to hubs only, and never to a hubless node", () => {
const data = createMatterNetworkChartData(
topology(
[
node({ id: "br_1", kind: "border_router", host_name: "Cuisine" }),
node({ id: "1", node_id: 1, role: "router" }),
node({ id: "9", node_id: 9, role: "router" }),
],
[connection({ source: "1", target: "br_1" })]
),
mockHass(),
element
);
// HA -> border router is a real path
const hubLink = data.links.find(
(l) => l.source === "ha" && l.target === "br_1"
)!;
expect(hubLink.lineStyle?.type).toBe("solid");
// symbol, not the falsy value, is what keeps the arrowhead off
expect(hubLink.symbol).toBe("none");
expect(hubLink.reverseValue).toBeUndefined();
// node 9 has no route we can see, so it is left floating
expect(
data.links.find((l) => l.source === "ha" && l.target === "9")
).toBeUndefined();
// the wire host_name reaches the rendered label, not just the helper
expect(data.nodes.find((n) => n.id === "br_1")!.name).toBe("Cuisine");
});
it("routes HA through the Wi-Fi access point, not the stations", () => {
const data = createMatterNetworkChartData(
topology(
[
node({
id: "ap_112233445566",
kind: "wifi_ap",
network_type: "wifi",
}),
node({ id: "7", node_id: 7, network_type: "wifi", role: "station" }),
node({ id: "8", node_id: 8, network_type: "wifi", role: "station" }),
],
[
connection({
source: "7",
target: "ap_112233445566",
network: "wifi",
source_to_target: { strength: "strong", rssi: -55 },
}),
connection({
source: "8",
target: "ap_112233445566",
network: "wifi",
source_to_target: { strength: "medium", rssi: -70 },
}),
]
),
mockHass(),
element
);
const haTargets = data.links
.filter((l) => l.source === "ha")
.map((l) => l.target);
expect(haTargets).toEqual(["ap_112233445566"]);
});
it("adds the network name to the context when there are multiple networks", () => {
const data = createMatterNetworkChartData(
topology([
node({
id: "1",
node_id: 1,
ext_pan_id: "AAA",
network_name: "NetA",
}),
node({
id: "2",
node_id: 2,
ext_pan_id: "BBB",
network_name: "NetB",
}),
]),
mockHass(),
element
);
expect(data.nodes.find((n) => n.id === "1")!.context).toBe("NetA");
expect(data.nodes.find((n) => n.id === "2")!.context).toBe("NetB");
});
});