Compare commits

..
Author SHA1 Message Date
Petar Petrov adcf554149 Call the Z-Wave adapter a device in the ESPHome setup wizard (#55196) 2026-10-23 21:37:38 +02:00
Paul Bottein 921b73ecbe Use the offset duration mode for sun and calendar offsets (#54703) 2026-10-23 20:50:21 +02:00
Aidan Timsonandcopilot-swe-agent[bot] 6dbf56aae0 Keep the integrations dashboard and integration page search in the URL (#55225)
* Let the query params controller take a fixed page path

* Recreate the integration page per domain and keep params on redirect

* Keep the integrations dashboard search and toggles in the URL

* Keep the integration page search in the URL

* Add search and status params to the integrations My links

* Drop the integrations query params test

* Address review on integrations query params

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-23 20:15:08 +03:00
MichaelandClaude Opus 5.5 784c8f782c Show bridged devices on the Matter network map (#54812)
* Show bridged devices in the Matter network map

A Matter bridge exposes the devices behind it as endpoints of its own
node, so the server topology never lists them and the map showed only the
bridge. Core registers each bridged endpoint as a device whose via_device
is the bridge, so synthesize those devices from the device registry and
hang them off their bridge node with a non-directional "bridged" link.

Bridged devices get their own legend category, a tooltip naming the
bridge, search by manufacturer/model, and click-through to the device page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SdicShTCt2EyiugGZ8GTtZ

* Use a typed key and blue-grey for bridged devices in the Matter map

The bridged link label is a fixed string, so localize it through a typed
visualization key instead of the dynamic network_type lookup, which
mirrors the server's network types. Switch the bridged category to
blue-grey so it no longer reads close to the red offline category.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SdicShTCt2EyiugGZ8GTtZ

* Show Matter bridges as expandable nodes in the network map

* Expand Matter bridges from the node, not the label

* move bridged links after "keep the strongest link"

* skip the "select / hide" hint  when there are no bridged nodes

* search points to the bridge node

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-23 18:08:42 +03:00
Petar Petrov 78d45ccf76 Use the Sendspin mark and check badges in the ESPHome setup wizard (#55239)
* Use the Sendspin mark and check badges in the ESPHome setup wizard

The audio row and banner show the Sendspin mark instead of a music note, and configured serial ports and the connected Z-Wave network show the check badge used for completed rows instead of a Configured chip.

* Color the ESPHome setup audio capability in Sendspin red
2026-10-23 18:00:29 +03:00
Aidan Timson 14952d74ac Allow picking several categories in the category filter (#55222)
* Allow picking several categories in the category filter

* Keep every category in script and scene links

* Keep every category in automations links
2026-10-23 15:04:11 +03:00
39 changed files with 1115 additions and 837 deletions

No files matched your search

@@ -15,6 +15,10 @@ import {
const DEBOUNCE_WAIT = 300;
// A page can be opened with or without a trailing slash
const trimSlash = (path: string) =>
path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
export interface QueryParamsControllerOptions {
/** Whether the host keeps its state in the URL. */
enabled: () => boolean;
@@ -24,6 +28,12 @@ export interface QueryParamsControllerOptions {
* the URL with `searchParams()` and apply it, then `write()` the state back.
*/
navigated: () => void;
/**
* The path of the host's page, for a page that can first be connected on
* a sub-path it sends on to itself, like an add link. Defaults to the path
* the host is first connected on.
*/
path?: string;
}
/**
@@ -57,7 +67,7 @@ export class QueryParamsController<
public hostConnected() {
// A cached page is reconnected on the way to another page, before its
// router has moved on, so it keeps the page it was first connected on
this._path ??= currentPath();
this._path ??= this._options.path ?? currentPath();
this._connected = true;
mainWindow.addEventListener("location-changed", this._navigated);
mainWindow.addEventListener("popstate", this._navigated);
@@ -127,7 +137,9 @@ export class QueryParamsController<
private _isActive(): boolean {
return (
this._connected && this._options.enabled() && currentPath() === this._path
this._connected &&
this._options.enabled() &&
trimSlash(currentPath()) === trimSlash(this._path ?? "")
);
}
@@ -158,7 +170,9 @@ export class QueryParamsController<
const current = mainWindow.location.search;
const queryString = updateQueryString(current, values, this._config);
if (current !== (queryString ? `?${queryString}` : "")) {
replaceCurrentUrl(constructUrlCurrentPath(queryString));
// Keep a fragment like #config_entry=, the demo already routes with it
const hash = __DEMO__ ? "" : mainWindow.location.hash;
replaceCurrentUrl(`${constructUrlCurrentPath(queryString)}${hash}`);
}
}
}
+1 -2
View File
@@ -114,8 +114,7 @@ export const automationsLinkFromUrl = (
"ha-filter-devices": filter(values("device")),
"ha-filter-entities": filter(values("entity")),
"ha-filter-labels": filter(values("label")),
// The category filter picks one category
"ha-filter-categories": filter(values("category").slice(0, 1)),
"ha-filter-categories": filter(values("category")),
"ha-filter-voice-assistants": filter(values("assistants")),
"ha-filter-blueprints": filter(values("blueprint")),
},
@@ -0,0 +1,70 @@
import {
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// `status` adds the ignored and disabled integrations to what is shown.
export const integrationsQueryParamConfig = {
string: ["search"],
list: ["status"],
} as const satisfies QueryParamConfig;
export const integrationQueryParamConfig = {
string: ["search"],
} as const satisfies QueryParamConfig;
export type IntegrationsQueryParams = QueryParamValues<
typeof integrationsQueryParamConfig
>;
export interface IntegrationsState {
search: string;
showIgnored: boolean;
showDisabled: boolean;
}
export const integrationsQueryParams = (
state: IntegrationsState
): IntegrationsQueryParams => ({
search: state.search,
status: [
...(state.showIgnored ? ["ignored"] : []),
...(state.showDisabled ? ["disabled"] : []),
],
});
// What a link says to show, undefined when it has none of the page's params
// so what was picked before stays. A link replaces the search and the toggles.
export const integrationsLinkFromUrl = (
searchParams: SearchParamsSource
): IntegrationsState | undefined => {
if (!hasQueryParams(searchParams, integrationsQueryParamConfig)) {
return undefined;
}
const { search, status } = decodeQueryParams(
searchParams,
integrationsQueryParamConfig
);
return {
search: search ?? "",
showIgnored: !!status?.includes("ignored"),
showDisabled: !!status?.includes("disabled"),
};
};
export const integrationSearchFromUrl = (
searchParams: SearchParamsSource
): string | undefined => {
if (!hasQueryParams(searchParams, integrationQueryParamConfig)) {
return undefined;
}
return (
decodeQueryParams(searchParams, integrationQueryParamConfig).search ?? ""
);
};
+1 -2
View File
@@ -107,8 +107,7 @@ export const scenesLinkFromUrl = (
"ha-filter-devices": filter(values("device")),
"ha-filter-entities": filter(values("entity")),
"ha-filter-labels": filter(values("label")),
// The category filter picks one category
"ha-filter-categories": filter(values("category").slice(0, 1)),
"ha-filter-categories": filter(values("category")),
"ha-filter-voice-assistants": filter(values("assistants")),
},
};
+1 -2
View File
@@ -109,8 +109,7 @@ export const scriptsLinkFromUrl = (
"ha-filter-devices": filter(values("device")),
"ha-filter-entities": filter(values("entity")),
"ha-filter-labels": filter(values("label")),
// The category filter picks one category
"ha-filter-categories": filter(values("category").slice(0, 1)),
"ha-filter-categories": filter(values("category")),
"ha-filter-voice-assistants": filter(values("assistants")),
"ha-filter-blueprints": filter(values("blueprint")),
},
+71 -50
View File
@@ -1,4 +1,3 @@
import type { SelectedDetail } from "@material/mwc-list";
import {
mdiDelete,
mdiDotsVertical,
@@ -12,6 +11,7 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import {
FilterPanelController,
filterPanelStyles,
@@ -35,8 +35,9 @@ import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-list";
import "./ha-list-item";
import "./item/ha-list-item-option";
import "./list/ha-list-selectable";
@customElement("ha-filter-categories")
export class HaFilterCategories extends SubscribeMixin(LitElement) {
@@ -94,47 +95,44 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._categorySelected}
<ha-list-selectable
class="ha-scrollbar"
activatable
multi
aria-label=${this.hass.localize(
"ui.panel.config.category.caption"
)}
@ha-list-item-selected=${this._categoryAdded}
@ha-list-item-deselected=${this._categoryRemoved}
>
${
this._categories.length > 0
? html`<ha-list-item
.selected=${!this.value?.length}
.activated=${!this.value?.length}
>${this.hass.localize(
"ui.panel.config.category.filter.show_all"
)}</ha-list-item
>`
: nothing
}
${this._categories.map(
${repeat(
this._categories,
(category) => category.category_id,
(category) =>
html`<ha-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${category.category_id}
.selected=${this.value?.includes(category.category_id)}
.activated=${this.value?.includes(category.category_id)}
graphic="icon"
hasMeta
.selected=${
this.value?.includes(category.category_id) ?? false
}
>
${
category.icon
? html`<ha-icon
slot="graphic"
slot="start"
.icon=${category.icon}
></ha-icon>`
: html`<ha-svg-icon
.path=${mdiTag}
slot="graphic"
slot="start"
></ha-svg-icon>`
}
${category.name}
<span slot="headline">${category.name}</span>
<ha-dropdown
@click=${stopPropagation}
@keydown=${this._handleMenuKeydown}
@wa-select=${this._handleAction}
slot="meta"
slot="end"
.categoryId=${category.category_id}
>
<ha-icon-button
@@ -161,9 +159,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
)}
</ha-dropdown-item>
</ha-dropdown>
</ha-list-item>`
</ha-list-item-option>`
)}
</ha-list>
</ha-list-selectable>
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
@@ -213,10 +211,10 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
}
try {
await deleteCategoryRegistryEntry(this.hass, this.scope!, id);
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
if (this.value?.includes(id)) {
this._setValue(this.value.filter((categoryId) => categoryId !== id));
}
} catch (err: any) {
alert(`Failed to delete: ${err.message}`);
}
@@ -237,25 +235,39 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
this.expanded = ev.detail.expanded;
}
private async _categorySelected(ev: CustomEvent<SelectedDetail<number>>) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
// The list toggles the focused row on Enter and Space, which would pick the
// category instead of opening its menu.
private _handleMenuKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
}
const index = ev.detail.index - 1;
}
const val = this._categories![index]?.category_id;
if (!val) {
private _categoryAdded(ev: CustomEvent<number>) {
const categoryId = this._categories[ev.detail]?.category_id;
if (!categoryId || this.value?.includes(categoryId)) {
return;
}
this.value = [val];
this._setValue([...(this.value || []), categoryId]);
}
private _categoryRemoved(ev: CustomEvent<number>) {
const categoryId = this._categories[ev.detail]?.category_id;
if (!categoryId) {
return;
}
this._setValue((this.value || []).filter((id) => id !== categoryId));
}
private _setValue(value: string[]) {
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value: this.value,
value: value.length ? value : undefined,
items: undefined,
});
}
@@ -298,13 +310,22 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-list {
/* The list scrolls through its own container, not through the host. */
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: var(--ha-space-1);
--mdc-list-side-padding-left: var(--ha-space-4);
--ha-icon-button-size: 36px;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
/* Keeps a row with its menu button as tall as the other filters. */
ha-list-item-option {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-list-item-option ha-icon-button {
--ha-icon-button-size: 32px;
}
ha-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
+35 -6
View File
@@ -4,7 +4,10 @@ import type {
HassEntityBase,
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { createDurationData } from "../common/datetime/create_duration_data";
import { applyDurationSign } from "../common/datetime/normalize_duration";
import type { WeekdayShort } from "../common/datetime/weekday";
import { computeDomain } from "../common/entity/compute_domain";
import { navigate } from "../common/navigate";
import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params";
@@ -524,6 +527,26 @@ export const pickRowConfig = <T extends object>(
return config as Partial<T>;
};
// Older sun and calendar triggers set a positive offset with an offset type,
// which the backend folds into the sign of the offset.
const OFFSET_TYPE_TRIGGER_DOMAINS = ["sun", "calendar"];
const foldOffsetType = (
options: Record<string, unknown>
): Record<string, unknown> => {
const { offset_type: offsetType, ...folded } = options;
const offset = createDurationData(
folded.offset as string | number | ForDict | undefined
);
if (offset && !Object.values(offset).every(Number.isFinite)) {
return options;
}
if (offsetType === "before" && offset) {
folded.offset = applyDurationSign(offset, true);
}
return folded;
};
export const getRowNote = (note: unknown): string | undefined =>
typeof note === "string" ? note.trim() || undefined : undefined;
@@ -551,12 +574,12 @@ export const migrateAutomationTrigger = (
delete trigger.platform;
}
if ("options" in trigger) {
if (
typeof trigger.options === "object" &&
trigger.options !== null &&
"behavior" in trigger.options
) {
if (
"options" in trigger &&
typeof trigger.options === "object" &&
trigger.options !== null
) {
if ("behavior" in trigger.options) {
// Deprecated behavior values renamed in 2026; the backend raises a repair
// when they are still used, so flag the migration to offer saving.
if (trigger.options.behavior === "any") {
@@ -571,6 +594,12 @@ export const migrateAutomationTrigger = (
}
}
}
if (
"offset_type" in trigger.options &&
OFFSET_TYPE_TRIGGER_DOMAINS.includes(computeDomain(trigger.trigger))
) {
trigger.options = foldOffsetType(trigger.options);
}
}
return trigger;
+24 -25
View File
@@ -1,6 +1,8 @@
import { TZDate } from "@date-fns/tz";
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { createDurationData } from "../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../common/datetime/duration_to_seconds";
import {
formatDurationDigital,
formatDurationLong,
@@ -10,6 +12,7 @@ import {
formatTime,
formatTimeWithSeconds,
} from "../common/datetime/format_time";
import { normalizeDuration } from "../common/datetime/normalize_duration";
import secondsToDuration from "../common/datetime/seconds_to_duration";
import { sortWeekdays } from "../common/datetime/sort_weekdays";
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
@@ -422,16 +425,7 @@ const describeLegacyTrigger = (
// Sun Trigger
if (trigger.trigger === "sun" && trigger.event) {
let duration = "";
if (trigger.offset) {
if (typeof trigger.offset === "number") {
duration = secondsToDuration(trigger.offset)!;
} else if (typeof trigger.offset === "string") {
duration = trigger.offset;
} else {
duration = JSON.stringify(trigger.offset);
}
}
const duration = formatSunOffset(hass, trigger.offset);
return hass.localize(
trigger.event === "sunset"
@@ -841,19 +835,16 @@ const describeLegacyTrigger = (
let offsetChoice = "other";
let offset = "";
if (typeof trigger.offset === "string" && trigger.offset) {
offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
const parts = trigger.offset.startsWith("-")
? trigger.offset.substring(1).split(":")
: trigger.offset.split(":");
const duration = {
hours: parts.length > 0 ? +parts[0] : 0,
minutes: parts.length > 1 ? +parts[1] : 0,
seconds: parts.length > 2 ? +parts[2] : 0,
};
offset = formatDurationLong(hass.locale, duration);
if (offset === "") {
offsetChoice = "other";
const duration = createDurationData(trigger.offset);
if (duration) {
const normalized = normalizeDuration(duration, {
enableDay: true,
enableSecond: true,
enableMillisecond: true,
});
offset = formatDurationLong(hass.locale, normalized.duration);
if (offset) {
offsetChoice = normalized.negative ? "before" : "after";
}
}
@@ -884,9 +875,17 @@ const formatSunOffset = (
if (typeof offset === "string") {
return offset;
}
if (!durationDataToSeconds(offset)) {
return "";
}
try {
const formatted = formatDurationDigital(hass.locale, offset);
return formatted.startsWith("-") ? formatted : `+${formatted}`;
const normalized = normalizeDuration(offset, {
enableDay: false,
enableSecond: true,
enableMillisecond: true,
});
const formatted = formatDurationDigital(hass.locale, normalized.duration);
return `${normalized.negative ? "-" : "+"}${formatted}`;
} catch (_e) {
return JSON.stringify(offset);
}
+7 -9
View File
@@ -1,11 +1,7 @@
import {
mdiAccessPoint,
mdiBluetooth,
mdiMusic,
mdiSwapHorizontal,
} from "@mdi/js";
import { mdiAccessPoint, mdiBluetooth, mdiSwapHorizontal } from "@mdi/js";
import { computeDomain } from "../common/entity/compute_domain";
import type { LocalizeKeys } from "../common/translations/localize";
import { mdiSendspin } from "../resources/sendspin-logo-svg";
import type { ConfigEntry } from "./config_entries";
import type { DataEntryFlowProgress } from "./data_entry_flow";
import type {
@@ -55,17 +51,19 @@ export const CAPABILITY_ORDER: ESPHomeCapabilityId[] = [
"serial",
];
/** Theme colors for each setup capability. */
const SENDSPIN_RED = "#e25c4c";
/** Colors for each setup capability. */
export const ESPHOME_CAPABILITY_ACCENTS: Record<ESPHomeCapabilityId, string> = {
bluetooth: "var(--blue-color)",
audio: "var(--light-green-color)",
audio: SENDSPIN_RED,
connectivity: "var(--cyan-color)",
serial: "var(--deep-purple-color)",
};
export const ESPHOME_CAPABILITY_ICONS: Record<ESPHomeCapabilityId, string> = {
bluetooth: mdiBluetooth,
audio: mdiMusic,
audio: mdiSendspin,
connectivity: mdiAccessPoint,
serial: mdiSwapHorizontal,
};
@@ -398,14 +398,16 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
if (status !== "completed") {
return nothing;
}
return this._checkBadge(
this._i18n!.localize(
"ui.panel.config.devices.esphome.setup_status_completed"
)
);
}
private _checkBadge(label: string) {
return html`
<span
class="status-badge completed"
role="img"
aria-label=${this._i18n!.localize(
"ui.panel.config.devices.esphome.setup_status_completed"
)}
>
<span class="status-badge completed" role="img" aria-label=${label}>
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
</span>
`;
@@ -507,7 +509,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
return html`
<div
class="protect"
style="--capability-accent: ${ESPHOME_CAPABILITY_ACCENTS.audio}"
style="--capability-accent: var(--light-green-color)"
>
<span class="icon-chip">
<ha-svg-icon .path=${mdiShieldLock}></ha-svg-icon>
@@ -624,11 +626,9 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
<span class="port-text">
<span class="port-name">${entry.title}</span>
</span>
<span class="chip active">
${localize(
"ui.panel.config.devices.esphome.setup_zwave_configured"
)}
</span>
${this._checkBadge(
localize("ui.panel.config.devices.esphome.setup_zwave_configured")
)}
</li>
</ul>
`;
@@ -748,13 +748,11 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
</span>
`
: usage.consumers.length
? html`
<span class="chip active">
${localize(
"ui.panel.config.devices.esphome.setup_serial_configured"
)}
</span>
`
? this._checkBadge(
localize(
"ui.panel.config.devices.esphome.setup_serial_configured"
)
)
: html`
<ha-button size="s" @click=${this._setupSerialPort}>
${localize(
@@ -1404,10 +1402,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
background: var(--ha-color-fill-neutral-normal-resting);
color: var(--primary-text-color);
}
.chip.active {
background: var(--success-color);
color: var(--ha-color-on-success-loud);
}
.ma-offer {
display: flex;
flex-direction: column;
@@ -65,7 +65,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
name: "after_offset",
selector: {
duration: {
allow_negative: true,
mode: "offset",
},
},
},
@@ -96,7 +96,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
name: "before_offset",
selector: {
duration: {
allow_negative: true,
mode: "offset",
},
},
},
@@ -980,13 +980,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
// the filters below only expose the selected options (as filter.value);
// applying the filter must be done here
} else if (isFilterUsed(key, filter, "ha-filter-categories")) {
// category filter only allows a single selected option
filteredEntityIds = filteredEntityIds.filter(
(entityId) =>
filter.value![0] ===
this._entityReg.find((reg) => reg.entity_id === entityId)
?.categories.automation
);
filteredEntityIds = filteredEntityIds.filter((entityId) => {
const category = this._entityReg.find(
(reg) => reg.entity_id === entityId
)?.categories.automation;
return (
category !== undefined &&
Array.isArray(filter.value) &&
filter.value.includes(category)
);
});
} else if (isFilterUsed(key, filter, "ha-filter-labels")) {
filteredEntityIds = filteredEntityIds.filter((entityId) =>
this._entityReg
@@ -5,16 +5,22 @@ import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { createDurationData } from "../../../common/datetime/create_duration_data";
import { formatDurationNarrow } from "../../../common/datetime/format_duration";
import { normalizeDuration } from "../../../common/datetime/normalize_duration";
import { hasTemplate } from "../../../common/string/has-template";
import type { ForDict } from "../../../data/automation";
import { internationalizationContext } from "../../../data/context";
import { rowSummaryStyles } from "./styles";
const SUMMARY_UNITS = {
enableDay: true,
enableSecond: true,
enableMillisecond: true,
};
interface HaAutomationRowOptionsConfig {
options?: {
for?: string | number | ForDict;
offset?: string | number | ForDict;
offset_type?: "before" | "after";
};
timeout?: string | number | ForDict;
}
@@ -63,20 +69,18 @@ export class HaAutomationRowOptions extends LitElement {
this._i18n.localize(
"ui.panel.config.automation.editor.row_options.for",
{
duration: forDuration,
duration: forDuration.text,
}
)
);
}
const offsetDuration = this._duration(options.offset);
if (offsetDuration) {
const offsetType =
options.offset_type === "before" ? "before" : "after";
const offset = this._duration(options.offset);
if (offset) {
parts.push(
this._i18n.localize(
`ui.panel.config.automation.editor.row_options.offset_${offsetType}`,
{ offset: offsetDuration }
`ui.panel.config.automation.editor.row_options.offset_${offset.negative ? "before" : "after"}`,
{ offset: offset.text }
)
);
}
@@ -88,7 +92,7 @@ export class HaAutomationRowOptions extends LitElement {
parts.push(
this._i18n.localize(
"ui.panel.config.automation.editor.row_options.timeout",
{ duration: timeoutDuration }
{ duration: timeoutDuration.text }
)
);
}
@@ -98,7 +102,9 @@ export class HaAutomationRowOptions extends LitElement {
}
);
private _duration(value: unknown): string | undefined {
private _duration(
value: unknown
): { negative: boolean; text: string } | undefined {
if (value === undefined || hasTemplate(value)) {
return undefined;
}
@@ -113,7 +119,9 @@ export class HaAutomationRowOptions extends LitElement {
) {
return undefined;
}
return formatDurationNarrow(this._i18n.locale, duration);
const normalized = normalizeDuration(duration, SUMMARY_UNITS);
const text = formatDurationNarrow(this._i18n.locale, normalized.duration);
return text ? { negative: normalized.negative, text } : undefined;
}
static styles = rowSummaryStyles;
@@ -5,10 +5,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import type { CalendarTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
import type { HaDurationData } from "../../../../../components/ha-duration-input";
import "../../../../../components/ha-form/ha-form";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
@@ -47,25 +44,10 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
],
],
},
{ name: "offset", required: true, selector: { duration: {} } },
{
name: "offset_type",
type: "select",
name: "offset",
required: true,
options: [
[
"before",
localize(
"ui.panel.config.automation.editor.triggers.type.calendar.before"
),
],
[
"after",
localize(
"ui.panel.config.automation.editor.triggers.type.calendar.after"
),
],
],
selector: { duration: { enable_day: true, mode: "offset" } },
},
] as const
);
@@ -81,34 +63,10 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
protected render() {
const schema = this._schema(this.hass.localize);
// Convert from string representation to ha form duration representation
const trigger_offset = this.trigger.offset;
// Copy, `createDurationData` returns the input object as-is for dict values.
const duration: HaDurationData = { ...createDurationData(trigger_offset)! };
let offset_type = "after";
if (durationDataToSeconds(duration) < 0) {
// A negative offset negates the whole period, and the sign is shown by
// the separate before/after select instead.
if (duration.days) {
duration.days = Math.abs(duration.days);
}
duration.hours = Math.abs(duration.hours ?? 0);
duration.minutes = Math.abs(duration.minutes ?? 0);
duration.seconds = Math.abs(duration.seconds ?? 0);
if (duration.milliseconds) {
duration.milliseconds = Math.abs(duration.milliseconds);
}
offset_type = "before";
}
const data = {
...this.trigger,
offset: duration,
offset_type: offset_type,
};
return html`
<ha-form
.schema=${schema}
.data=${data}
.data=${this.trigger}
.hass=${this.hass}
.disabled=${this.disabled}
.computeLabel=${this._computeLabelCallback}
@@ -119,18 +77,7 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
// Convert back to duration string representation
const duration = ev.detail.value.offset;
const offsetType = ev.detail.value.offset_type === "before" ? "-" : "";
const h = (duration.days ?? 0) * 24 + (duration.hours ?? 0);
const m = duration.minutes ?? 0;
const s = (duration.seconds ?? 0) + (duration.milliseconds ?? 0) / 1000;
const newTrigger = {
...ev.detail.value,
offset: `${offsetType}${h}:${m}:${s}`,
};
delete newTrigger.offset_type;
fireEvent(this, "value-changed", { value: newTrigger });
fireEvent(this, "value-changed", { value: ev.detail.value });
}
private _computeLabelCallback = (
@@ -143,6 +90,10 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
return this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.calendar.event"
);
case "offset":
return this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.calendar.offset"
);
}
return "";
};
@@ -39,7 +39,7 @@ export class HaSunTrigger extends LitElement implements TriggerElement {
],
],
},
{ name: "offset", selector: { text: {} } },
{ name: "offset", selector: { duration: { mode: "offset" } } },
] as const
);
@@ -18,6 +18,7 @@ import { customElement, property, queryAll, state } from "lit/decorators";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import {
@@ -26,6 +27,10 @@ import {
} from "../../../common/integrations/protocolIntegrationPicked";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
integrationQueryParamConfig,
integrationSearchFromUrl,
} from "../../../common/url/integrations-query-params";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
@@ -176,6 +181,15 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@queryAll("ha-config-entry-row")
private _configEntryRows!: NodeListOf<HaConfigEntryRow>;
private _queryParams = new QueryParamsController(
this,
integrationQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyUrl(),
}
);
private _handleHashChange = () => {
this._searchParms = new URLSearchParams(
__DEMO__ ? window.location.search : window.location.hash.substring(1)
@@ -237,6 +251,10 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this._fetchDiagnostics();
this._fetchEntitySources();
}
if (!this.hasUpdated) {
this._applyUrl();
}
if (
changedProperties.has("configEntries") ||
changedProperties.has("_extraConfigEntries")
@@ -720,11 +738,12 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</div>
${
normalData.length + attentionData.length > 0
normalData.length + attentionData.length > 0 || this._filter
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@blur=${this._flushUrl}
></ha-input-search>`
: nothing
}
@@ -1256,6 +1275,32 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
this._writeUrl(true);
}
private _flushUrl() {
this._queryParams.flush();
}
private _writeUrl(debounced = false) {
this._queryParams.write({ search: this._filter }, debounced);
}
private _applyUrl() {
const searchParams = this._queryParams.searchParams();
if (!searchParams) {
return;
}
const search = integrationSearchFromUrl(searchParams);
if (search !== undefined) {
this._filter = search;
}
this._writeUrl();
}
private async _handleEnableDebugLogging() {
@@ -9,6 +9,7 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { deepActiveElement } from "../../../common/dom/deep-active-element";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
@@ -16,12 +17,13 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import {
integrationsLinkFromUrl,
integrationsQueryParamConfig,
integrationsQueryParams,
} from "../../../common/url/integrations-query-params";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
@@ -169,7 +171,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
window.location.hash.substring(1)
);
@state() private _filter: string = getHistoryState()?.filter || "";
@state() private _filter = "";
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
@@ -185,6 +187,17 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
private _childReadyRegistered = false;
// The page can first be opened on /add, which sends itself on to the page
private _queryParams = new QueryParamsController(
this,
integrationsQueryParamConfig,
{
enabled: () => true,
navigated: () => this._applyUrl(),
path: "/config/integrations/dashboard",
}
);
@consume({ context: childPanelReadyContext, subscribe: true })
private _registerChildPanelReady?: RegisterChildPanelReady;
@@ -398,6 +411,14 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
}
);
protected willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
if (!this.hasUpdated) {
this._applyUrl();
}
}
protected firstUpdated(changed: PropertyValues<this>) {
super.firstUpdated(changed);
this._fetchManifests();
@@ -556,6 +577,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@blur=${this._flushUrl}
.placeholder=${this.hass.localize(
"ui.panel.config.integrations.search"
)}
@@ -577,6 +599,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@blur=${this._flushUrl}
.placeholder=${this.hass.localize(
"ui.panel.config.integrations.search"
)}
@@ -886,6 +909,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
}
private _createFlow() {
// Keep the search typed so far on the entry the dialog's Back returns to
this._queryParams.flush();
showAddIntegrationDialog(this, {
initialFilter: this._filter,
navigateToResult: true,
@@ -897,6 +922,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
switch (action) {
case "show-ignored":
this._showIgnored = !this._showIgnored;
this._writeUrl();
break;
case "show-disabled":
this._toggleShowDisabled();
@@ -906,11 +932,46 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
private _toggleShowDisabled() {
this._showDisabled = !this._showDisabled;
this._writeUrl();
}
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
updateHistoryState({ filter: this._filter });
this._writeUrl(true);
}
private _flushUrl() {
this._queryParams.flush();
}
private _writeUrl(debounced = false) {
this._queryParams.write(
integrationsQueryParams({
search: this._filter,
showIgnored: this._showIgnored,
showDisabled: this._showDisabled,
}),
debounced
);
}
private _applyUrl() {
const searchParams = this._queryParams.searchParams();
if (!searchParams) {
return;
}
const link = integrationsLinkFromUrl(searchParams);
if (link) {
this._filter = link.search;
this._showIgnored = link.showIgnored;
this._showDisabled = link.showDisabled;
}
this._writeUrl();
}
private async _highlightEntry() {
@@ -946,7 +1007,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
const brand = extractSearchParam("brand");
const domain = extractSearchParam("domain");
// Navigating closes open dialogs, so open them once it has finished
await navigate("/config/integrations/dashboard/", { replace: true });
await navigate("/config/integrations/dashboard", { replace: true });
if (brand) {
showAddIntegrationDialog(this, {
@@ -60,6 +60,7 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
},
integration: {
tag: "ha-config-integration-page",
itemId: true,
},
},
};
@@ -201,7 +202,12 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
if (urlParams.has("domain")) {
const domain = urlParams.get("domain");
pageEl.domain = domain;
navigate(`/config/integrations/integration/${domain}`);
urlParams.delete("domain");
const search = urlParams.toString();
navigate(
`/config/integrations/integration/${domain}${search ? `?${search}` : ""}`,
{ replace: true }
);
}
}
}
@@ -1,9 +1,11 @@
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import type {
NetworkData,
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type {
MatterNetworkTopology,
MatterNetworkTopologyNode,
@@ -18,6 +20,8 @@ const CATEGORY_END_DEVICE = 3;
const CATEGORY_WIFI_AP = 4;
const CATEGORY_OFFLINE = 5;
const CATEGORY_UNKNOWN = 6;
const CATEGORY_BRIDGE = 7;
const CATEGORY_BRIDGED = 8;
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
@@ -25,6 +29,41 @@ const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
export const HOME_ASSISTANT_NODE_ID = "ha";
const HOME_ASSISTANT_LABEL = "Home Assistant";
// bridged devices are not Matter nodes, so they are synthesized from the
// device registry under an id that cannot collide with a topology node
const BRIDGED_DEVICE_NODE_PREFIX = "bridged_";
export const getBridgedDeviceId = (nodeId: string): string | undefined =>
nodeId.startsWith(BRIDGED_DEVICE_NODE_PREFIX)
? nodeId.slice(BRIDGED_DEVICE_NODE_PREFIX.length)
: undefined;
// a bridge exposes the devices behind it as endpoints of its own node, so
// the topology never lists them; core registers each as a device whose
// via_device is the bridge
export const getBridgedDevices = (
bridgeDeviceId: string,
devices: HomeAssistant["devices"]
): DeviceRegistryEntry[] =>
Object.values(devices).filter(
(device) => device.via_device_id === bridgeDeviceId && !device.disabled_by
);
// ha-network-graph labels a node beside its symbol only, so the bridged
// device count is drawn into the symbol itself
const bridgeSymbol = (
color: string,
textColor: string,
count?: number
): string => {
const text =
count === undefined
? ""
: `<text x="16" y="16" dy="0.35em" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="${count > 99 ? 10 : 13}" fill="${textColor}">${count}</text>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="16" fill="${color}" fill-opacity="0.35"/><circle cx="16" cy="16" r="12" fill="${color}"/>${text}</svg>`;
return `image://data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
};
// 0 is never returned: a falsy link value re-enables the direction arrow
// in ha-network-graph
export const strengthToScale = (
@@ -80,6 +119,9 @@ export const getTopologyNodeCategory = (
if (node.available === false) {
return CATEGORY_OFFLINE;
}
if (node.is_bridge) {
return CATEGORY_BRIDGE;
}
return node.role && ROUTER_ROLES.has(node.role)
? CATEGORY_ROUTER
: CATEGORY_END_DEVICE;
@@ -128,7 +170,10 @@ const isHub = (category: number): boolean =>
export function createMatterNetworkChartData(
topology: MatterNetworkTopology,
hass: HomeAssistant,
element: Element
element: Element,
// a bridge can expose many devices, so they stay behind their bridge until
// it is expanded
expandedBridges: ReadonlySet<string> = new Set()
): NetworkData {
const style = getComputedStyle(element);
@@ -141,7 +186,13 @@ export function createMatterNetworkChartData(
style.getPropertyValue(networkToColorVar("wifi")),
style.getPropertyValue("--error-color"),
style.getPropertyValue("--disabled-color"),
];
// a bridge is an end device that aggregates others behind it
style.getPropertyValue("--teal-color"),
style.getPropertyValue("--blue-grey-color"),
].map((color) => color.trim());
const textPrimaryColor = style
.getPropertyValue("--text-primary-color")
.trim();
const categories = [
{
name: HOME_ASSISTANT_LABEL,
@@ -180,6 +231,18 @@ export function createMatterNetworkChartData(
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
},
{
name: hass.localize("ui.panel.config.matter.node_type.bridge"),
symbol: bridgeSymbol(categoryColors[CATEGORY_BRIDGE], textPrimaryColor),
itemStyle: { color: categoryColors[CATEGORY_BRIDGE] },
},
{
name: hass.localize(
"ui.panel.config.matter.visualization.bridged_device"
),
symbol: "circle",
itemStyle: { color: categoryColors[CATEGORY_BRIDGED] },
},
];
const threadNetworks = new Set(
@@ -201,6 +264,7 @@ export function createMatterNetworkChartData(
},
];
const nodeCategories = new Map<string, number>();
const bridgedDevices = new Map<string, DeviceRegistryEntry[]>();
topology.nodes.forEach((node) => {
const category = getTopologyNodeCategory(node);
nodeCategories.set(node.id, category);
@@ -225,6 +289,31 @@ export function createMatterNetworkChartData(
if ((multiNetwork || !area) && networkLabel && networkLabel !== name) {
contextParts.push(networkLabel);
}
if (node.is_bridge) {
// without a device the bridged devices are unknown, not zero
const bridged = node.ha_device_id
? getBridgedDevices(node.ha_device_id, hass.devices)
: undefined;
if (bridged && expandedBridges.has(node.id)) {
bridgedDevices.set(node.id, bridged);
}
nodes.push({
id: node.id,
name,
context: contextParts.join(" • ") || undefined,
category,
value: 2,
symbol: bridgeSymbol(
categoryColors[category],
textPrimaryColor,
bridged?.length
),
symbolSize: 30,
itemStyle: { color: categoryColors[category] },
polarDistance: 0.8,
});
return;
}
nodes.push({
id: node.id,
name,
@@ -366,5 +455,39 @@ export function createMatterNetworkChartData(
}
});
bridgedDevices.forEach((devices, bridgeId) => {
devices.forEach((device) => {
const id = `${BRIDGED_DEVICE_NODE_PREFIX}${device.id}`;
const area = getDeviceArea(device, hass.areas, hass.devices);
nodes.push({
id,
name: computeDeviceName(device) || id,
context: area?.name,
category: CATEGORY_BRIDGED,
value: 1,
symbol: "circle",
symbolSize: 15,
itemStyle: { color: categoryColors[CATEGORY_BRIDGED] },
polarDistance: 0.9,
});
// not a radio link, so no strength and no arrow; it stays in the force
// layout so the devices gather around their bridge
links.push({
source: bridgeId,
target: id,
value: 0,
symbol: "none",
lineStyle: {
width: 1,
color: categoryColors[CATEGORY_BRIDGED],
type:
nodeCategories.get(bridgeId) === CATEGORY_OFFLINE
? "dashed"
: "solid",
},
});
});
});
return { nodes, links, categories };
}
@@ -10,6 +10,7 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import type { HASSDomTargetEvent } from "../../../../../common/dom/fire_event";
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
@@ -20,6 +21,7 @@ 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 { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type {
MatterNetworkTopology,
MatterNetworkTopologyConnection,
@@ -34,6 +36,8 @@ import "../../../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import {
createMatterNetworkChartData,
getBridgedDeviceId,
getBridgedDevices,
getTopologyNodeName,
HOME_ASSISTANT_NODE_ID,
} from "./matter-network-data";
@@ -60,6 +64,9 @@ export class MatterNetworkVisualization extends LitElement {
@state() private _searchFilter = "";
// topology node ids of the bridges whose bridged devices are shown
@state() private _expandedBridges: ReadonlySet<string> = new Set();
private _unsub?: Promise<UnsubscribeFunc>;
private _throttledUpdateTopology = throttle(
@@ -159,7 +166,8 @@ export class MatterNetworkVisualization extends LitElement {
this.hass.devices,
this.hass.areas,
this.hass.themes,
this.hass.language
this.hass.language,
this._expandedBridges
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@@ -215,14 +223,21 @@ export class MatterNetworkVisualization extends LitElement {
// 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)
_language: HomeAssistant["language"],
expandedBridges: ReadonlySet<string>
) =>
createMatterNetworkChartData(topology, this.hass, this, expandedBridges)
);
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
return this._topology?.nodes.find((node) => node.id === id);
}
private _getBridgedDevice(id: string): DeviceRegistryEntry | undefined {
const deviceId = getBridgedDeviceId(id);
return deviceId ? this.hass.devices[deviceId] : undefined;
}
private _getConnection(
source: string,
target: string
@@ -235,11 +250,35 @@ export class MatterNetworkVisualization extends LitElement {
}
private _getNodeName(id: string): string {
const bridgedDevice = this._getBridgedDevice(id);
if (bridgedDevice) {
return computeDeviceName(bridgedDevice) || id;
}
const node = this._getTopologyNode(id);
return node ? getTopologyNodeName(node, this.hass) : id;
}
private _getDeviceSearchableAttributes(
device: DeviceRegistryEntry | undefined
): string[] {
const attributes: string[] = [];
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 _getSearchableAttributes = (nodeId: string): string[] => {
const bridgedDevice = this._getBridgedDevice(nodeId);
if (bridgedDevice) {
return this._getDeviceSearchableAttributes(bridgedDevice);
}
const node = this._getTopologyNode(nodeId);
if (!node) {
return [];
@@ -266,15 +305,17 @@ export class MatterNetworkVisualization extends LitElement {
const device = node.ha_device_id
? this.hass.devices[node.ha_device_id]
: undefined;
if (device?.manufacturer) {
attributes.push(device.manufacturer);
attributes.push(...this._getDeviceSearchableAttributes(device));
if (node.is_bridge && node.ha_device_id) {
getBridgedDevices(node.ha_device_id, this.hass.devices).forEach(
(bridged) => {
const name = computeDeviceName(bridged);
if (name) {
attributes.push(name);
}
}
);
}
if (device?.model) {
attributes.push(device.model);
}
device?.connections.forEach((connection) => {
attributes.push(connection[1]);
});
return attributes;
};
@@ -304,6 +345,18 @@ export class MatterNetworkVisualization extends LitElement {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target } = data as { source: string; target: string };
if (this._getBridgedDevice(target)) {
return html`<b
>${this._getNodeName(source)} ↔ ${this._getNodeName(target)}</b
><br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.network"
)}:</b
>
${this.hass.localize(
"ui.panel.config.matter.visualization.bridged"
)}`;
}
const conn = this._getConnection(source, target);
if (!conn) {
return nothing;
@@ -357,6 +410,10 @@ export class MatterNetworkVisualization extends LitElement {
if (id === HOME_ASSISTANT_NODE_ID) {
return html`<b>Home Assistant</b>`;
}
const bridgedDevice = this._getBridgedDevice(id);
if (bridgedDevice) {
return this._bridgedDeviceTooltip(id, bridgedDevice);
}
const node = this._getTopologyNode(id);
if (!node) {
return nothing;
@@ -388,6 +445,16 @@ export class MatterNetworkVisualization extends LitElement {
node.network_name ? html` (${node.network_name})` : nothing
}`
);
if (node.is_bridge && node.ha_device_id) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.bridged_devices"
)}:</b
>
${getBridgedDevices(node.ha_device_id, this.hass.devices).length}`
);
}
if (node.role) {
lines.push(
html`<br /><b
@@ -452,20 +519,101 @@ export class MatterNetworkVisualization extends LitElement {
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
);
}
if (
node.is_bridge &&
node.ha_device_id &&
getBridgedDevices(node.ha_device_id, this.hass.devices).length
) {
lines.push(
html`<br /><i
>${this.hass.localize(
this._expandedBridges.has(id)
? "ui.panel.config.matter.visualization.hide_bridged_devices"
: "ui.panel.config.matter.visualization.show_bridged_devices"
)}</i
>`
);
}
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}`);
}
private _bridgedDeviceTooltip(
id: string,
device: DeviceRegistryEntry
): TemplateResult {
const bridge = device.via_device_id
? this.hass.devices[device.via_device_id]
: undefined;
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const lines: TemplateResult[] = [];
if (bridge) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.bridged_by"
)}:</b
>
${computeDeviceName(bridge) || bridge.id}`
);
}
if (device.manufacturer) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.manufacturer"
)}:</b
>
${device.manufacturer}`
);
}
if (device.model) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.model"
)}:</b
>
${device.model}`
);
}
if (area) {
lines.push(
html`<br /><b
>${this.hass.localize(
"ui.panel.config.matter.visualization.area"
)}:</b
>
${area.name}`
);
}
return html`<b>${this._getNodeName(id)}</b>${lines}`;
}
private _handleChartClick(e: CustomEvent): void {
if (e.detail.dataType !== "node") {
return;
}
const { id } = e.detail.data;
const node = this._getTopologyNode(id);
// the label has a pointer cursor, the draggable symbol a move cursor
if (e.detail.event.target.cursor === "pointer") {
const deviceId = getBridgedDeviceId(id) ?? node?.ha_device_id;
if (deviceId) {
navigate(`/config/devices/device/${deviceId}`);
}
return;
}
if (node?.is_bridge) {
this._toggleBridge(id);
}
}
private _toggleBridge(id: string): void {
const expanded = new Set(this._expandedBridges);
if (!expanded.delete(id)) {
expanded.add(id);
}
this._expandedBridges = expanded;
}
static get styles(): CSSResultGroup {
+11 -7
View File
@@ -830,13 +830,17 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
// the filters below only expose the selected options (as filter.value);
// applying the filter must be done here
} else if (isFilterUsed(key, filter, "ha-filter-categories")) {
// category filter only allows a single selected option
filteredEntityIds = filteredEntityIds.filter(
(entityId) =>
filter.value![0] ===
this._entityReg.find((reg) => reg.entity_id === entityId)
?.categories.scene
);
filteredEntityIds = filteredEntityIds.filter((entityId) => {
const category = this._entityReg.find(
(reg) => reg.entity_id === entityId
)?.categories.scene;
return (
category !== undefined &&
Array.isArray(filter.value) &&
filter.value.includes(category)
);
});
} else if (isFilterUsed(key, filter, "ha-filter-labels")) {
filteredEntityIds = filteredEntityIds.filter((entityId) =>
this._entityReg
+11 -7
View File
@@ -783,13 +783,17 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
// the filters below only expose the selected options (as filter.value);
// applying the filter must be done here
} else if (isFilterUsed(key, filter, "ha-filter-categories")) {
// category filter only allows a single selected option
filteredEntityIds = filteredEntityIds.filter(
(entityId) =>
filter.value![0] ===
this._entityReg.find((reg) => reg.entity_id === entityId)
?.categories.script
);
filteredEntityIds = filteredEntityIds.filter((entityId) => {
const category = this._entityReg.find(
(reg) => reg.entity_id === entityId
)?.categories.script;
return (
category !== undefined &&
Array.isArray(filter.value) &&
filter.value.includes(category)
);
});
} else if (isFilterUsed(key, filter, "ha-filter-labels")) {
filteredEntityIds = filteredEntityIds.filter((entityId) =>
this._entityReg
@@ -1,153 +0,0 @@
import type { ContextType } from "@lit/context";
import { mdiAutoFix, mdiContentPaste, mdiViewGridPlusOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import deepClone from "deep-clone-simple";
import { consume } from "../../../../common/decorators/consume";
import { storage } from "../../../../common/decorators/storage";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-base";
import { internationalizationContext } from "../../../../data/context";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import { generateDefaultSection } from "../../views/default-section";
import { addSection } from "../config-util";
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
import { showSelectSectionStrategyDialog } from "./show-create-section-dialog";
@customElement("hui-dialog-create-section")
export class HuiDialogCreateSection extends DialogMixin<CreateSectionDialogParams>(
LitElement
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@storage({
key: "dashboardSectionClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
private _clipboard?: LovelaceSectionRawConfig;
protected render() {
if (!this.params) {
return nothing;
}
const localize = this._i18n.localize;
return html`
<ha-adaptive-dialog
open
header-title=${localize("ui.panel.lovelace.editor.create_section.header")}
>
<ha-list-base>
<ha-list-item-button @click=${this._createManual}>
<ha-svg-icon
slot="start"
.path=${mdiViewGridPlusOutline}
></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.manual")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.manual_description"
)}
</span>
</ha-list-item-button>
<ha-list-item-button @click=${this._pickStrategy}>
<ha-svg-icon slot="start" .path=${mdiAutoFix}></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.automatic")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.automatic_description"
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
${
this._clipboard
? html`
<ha-list-item-button @click=${this._paste}>
<ha-svg-icon
slot="start"
.path=${mdiContentPaste}
></ha-svg-icon>
<span slot="headline">
${localize("ui.panel.lovelace.editor.create_section.paste")}
</span>
<span slot="supporting-text">
${localize(
"ui.panel.lovelace.editor.create_section.paste_description"
)}
</span>
</ha-list-item-button>
`
: nothing
}
</ha-list-base>
</ha-adaptive-dialog>
`;
}
private _createManual() {
const { lovelace, containerPath } = this.params!;
this.closeDialog();
lovelace.saveConfig(
addSection(
lovelace.config,
containerPath,
generateDefaultSection(this._i18n.localize, true)
)
);
}
private _pickStrategy() {
showSelectSectionStrategyDialog(this, this.params!);
this.closeDialog();
}
private async _paste() {
if (!this._clipboard) {
return;
}
const { lovelace, containerPath } = this.params!;
const section = deepClone(this._clipboard);
this.closeDialog();
try {
await lovelace.saveConfig(
addSection(lovelace.config, containerPath, section)
);
} catch (_err: unknown) {
lovelace.showToast({
message: this._i18n.localize(
"ui.panel.lovelace.editor.section.paste_error"
),
});
}
}
static styles = css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
ha-list-base {
--ha-list-padding: 0 0 var(--ha-space-3);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-create-section": HuiDialogCreateSection;
}
}
@@ -54,7 +54,6 @@ import {
deleteAtPath,
getAtPath,
getViewPath,
insertAtPath,
moveAtPath,
setAtPath,
} from "../lovelace-path";
@@ -140,9 +139,10 @@ export class HuiDialogEditSection
this.lovelace = params.lovelace;
this._config =
params.newSection ??
getAtPath<LovelaceSectionRawConfig>(params.lovelaceConfig, params.path);
this._config = getAtPath<LovelaceSectionRawConfig>(
params.lovelaceConfig,
params.path
);
this._currTab =
this._config && isStrategySection(this._config)
? "tab-strategy"
@@ -152,12 +152,7 @@ export class HuiDialogEditSection
params.lovelaceConfig,
viewPath
);
if (params.newSection) {
this._initDirtyTracking({ type: "deep" }, {});
this._updateDirtyState(params.newSection);
} else {
this._initDirtyTracking({ type: "deep" }, this._config);
}
this._initDirtyTracking({ type: "deep" }, this._config);
this._defaultPreviewMode =
window.innerWidth >= TWO_COLUMNS_MIN_WIDTH ? "desktop" : "mobile";
}
@@ -179,7 +174,6 @@ export class HuiDialogEditSection
this._currTab = TABS[0];
this._strategyError = false;
this._yamlError = false;
this._discardDirtyStateChanges();
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -271,33 +265,24 @@ export class HuiDialogEditSection
@click=${this._enlarge}
>${heading}</span
>
${
this._params.newSection
? nothing
: html`
<ha-dropdown
slot="headerActionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon
slot="icon"
.path=${mdiFileMoveOutline}
></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
)}
</ha-dropdown-item>
</ha-dropdown>
`
}
<ha-dropdown
slot="headerActionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon slot="icon" .path=${mdiFileMoveOutline}></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
)}
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<div class="element-editor ha-scrollbar">
${
@@ -694,13 +679,11 @@ export class HuiDialogEditSection
) {
return;
}
const newConfig = this._params.newSection
? insertAtPath(
this._params.lovelaceConfig,
this._params.path,
this._config
)
: setAtPath(this._params.lovelaceConfig, this._params.path, this._config);
const newConfig = setAtPath(
this._params.lovelaceConfig,
this._params.path,
this._config
);
this._params.saveConfig(newConfig);
this._markDirtyStateClean();
@@ -1,307 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-icon-button-arrow-prev";
import "../../../../components/ha-ripple";
import type { LovelaceStrategySectionConfig } from "../../../../data/lovelace/config/section";
import { CUSTOM_TYPE_PREFIX } from "../../../../data/lovelace_custom_cards";
import { getCustomStrategiesForType } from "../../../../data/lovelace_custom_strategies";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../../types";
import "../../sections/hui-section";
import { getLovelaceStrategy } from "../../strategies/get-strategy";
import type { LovelaceSectionStrategy } from "../../strategies/types";
import { getAtPath } from "../lovelace-path";
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
import { showCreateSectionDialog } from "./show-create-section-dialog";
import { showEditSectionDialog } from "./show-edit-section-dialog";
interface BuiltInSectionStrategy {
type: "common-controls" | "device";
isAvailable?: (hass: HomeAssistant) => boolean;
}
const BUILT_IN_SECTION_STRATEGIES: BuiltInSectionStrategy[] = [
{
type: "common-controls",
isAvailable: (hass) => isComponentLoaded(hass.config, "usage_prediction"),
},
{
type: "device",
},
];
interface StrategyOption {
type: string;
name: string;
description: string;
}
@customElement("hui-dialog-select-section-strategy")
export class HuiDialogSelectSectionStrategy
extends LitElement
implements HassDialog<CreateSectionDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: CreateSectionDialogParams;
@state() private _open = false;
@state() private _previews: Record<string, LovelaceStrategySectionConfig> =
{};
public showDialog(params: CreateSectionDialogParams): void {
this._params = params;
this._open = true;
this._previews = {};
this._loadPreviews(params);
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._previews = {};
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _availableBuiltInTypes() {
return BUILT_IN_SECTION_STRATEGIES.filter(
(strategy) => strategy.isAvailable?.(this.hass) ?? true
).map((strategy) => strategy.type);
}
private _builtInOptions(): StrategyOption[] {
return this._availableBuiltInTypes().map((type) => ({
type,
name: this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${type}.name`
),
description: this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${type}.description`
),
}));
}
private _customOptions(): StrategyOption[] {
return getCustomStrategiesForType("section").map((strategy) => ({
type: CUSTOM_TYPE_PREFIX + strategy.type,
name: strategy.name || strategy.type,
description: strategy.description || "",
}));
}
private async _loadPreviews(params: CreateSectionDialogParams) {
const types = [
...this._availableBuiltInTypes(),
...getCustomStrategiesForType("section").map(
(strategy) => CUSTOM_TYPE_PREFIX + strategy.type
),
];
await Promise.all(
types.map(async (type) => {
try {
const strategy = (await getLovelaceStrategy(
"section",
type
)) as LovelaceSectionStrategy;
const stubConfig = await strategy.getStubConfig?.(this.hass);
if (!stubConfig || this._params !== params) {
return;
}
this._previews = {
...this._previews,
[type]: { strategy: stubConfig },
};
} catch (_err: unknown) {
// The tile is shown without a preview
}
})
);
}
protected render() {
if (!this._params) {
return nothing;
}
const customOptions = this._customOptions();
return html`
<ha-adaptive-dialog
.open=${this._open}
width="large"
@closed=${this._dialogClosed}
header-title=${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.header"
)}
>
<ha-icon-button-arrow-prev
slot="headerNavigationIcon"
@click=${this._back}
></ha-icon-button-arrow-prev>
${
customOptions.length
? html`
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.built_in"
)}
</h3>
`
: nothing
}
<div class="options">
${this._builtInOptions().map((option) => this._renderOption(option))}
</div>
${
customOptions.length
? html`
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.select_section_strategy.custom"
)}
</h3>
<div class="options">
${customOptions.map((option) => this._renderOption(option))}
</div>
`
: nothing
}
</ha-adaptive-dialog>
`;
}
private _renderOption(option: StrategyOption) {
const preview = this._previews[option.type];
return html`
<div
class="option"
role="button"
tabindex="0"
aria-label=${option.name}
data-type=${option.type}
@click=${this._strategyPicked}
@keydown=${this._handleKeyDown}
>
<div class="header">
<h4>${option.name}</h4>
<p>${option.description}</p>
</div>
${
preview
? html`
<div class="preview" inert>
<hui-section
.hass=${this.hass}
.config=${preview}
preview
></hui-section>
</div>
`
: nothing
}
<ha-ripple></ha-ripple>
</div>
`;
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
(ev.currentTarget as HTMLElement).click();
}
}
private _back() {
showCreateSectionDialog(this, this._params!);
this.closeDialog();
}
private _strategyPicked(ev: Event) {
const type = (ev.currentTarget as HTMLElement).dataset.type!;
const { lovelace, containerPath } = this._params!;
const sectionsPath = [...containerPath, "sections"];
const sectionCount =
getAtPath<unknown[]>(lovelace.config, sectionsPath)?.length ?? 0;
showEditSectionDialog(this, {
lovelace,
lovelaceConfig: lovelace.config,
saveConfig: (newConfig) => {
lovelace.saveConfig(newConfig);
},
path: [...sectionsPath, sectionCount],
newSection: this._previews[type] ?? { strategy: { type } },
});
this.closeDialog();
}
static styles = css`
h3 {
margin: 0 0 var(--ha-space-3) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
.options + h3 {
margin-top: var(--ha-space-6);
}
.options {
display: grid;
gap: var(--ha-space-4);
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
}
.option {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
cursor: pointer;
outline: none;
}
.option:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.header {
flex: 1;
padding: var(--ha-space-3) var(--ha-space-4);
}
h4 {
margin: 0 0 var(--ha-space-1) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
p {
margin: 0;
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
.preview {
height: 200px;
overflow: hidden;
padding: var(--ha-space-3);
background: var(--primary-background-color);
pointer-events: none;
mask-image: linear-gradient(to bottom, black 75%, transparent);
}
hui-section {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-select-section-strategy": HuiDialogSelectSectionStrategy;
}
}
@@ -1,30 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
export interface CreateSectionDialogParams {
lovelace: Lovelace;
containerPath: LovelacePath;
}
export const showCreateSectionDialog = (
element: HTMLElement,
params: CreateSectionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-create-section",
dialogImport: () => import("./hui-dialog-create-section"),
dialogParams: params,
});
};
export const showSelectSectionStrategyDialog = (
element: HTMLElement,
params: CreateSectionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-select-section-strategy",
dialogImport: () => import("./hui-dialog-select-section-strategy"),
dialogParams: params,
});
};
@@ -1,5 +1,4 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
@@ -9,7 +8,6 @@ export interface EditSectionDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelacePath;
newSection?: LovelaceSectionRawConfig;
}
const importEditSectionDialog = () => import("./hui-dialog-edit-section");
+1 -5
View File
@@ -35,11 +35,7 @@ export type LovelaceDashboardStrategyGetCreateSuggestions = (
export interface LovelaceViewStrategy extends LovelaceStrategy<LovelaceViewConfig> {}
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {
getStubConfig?(
hass: HomeAssistant
): LovelaceStrategyConfig | Promise<LovelaceStrategyConfig>;
}
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {}
export interface LovelaceStrategyEditor extends LovelaceGenericElementEditor {
setConfig(config: LovelaceStrategyConfig): void;
@@ -40,10 +40,6 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
);
}
public static getStubConfig(): CommonControlsSectionStrategyConfig {
return { type: "common-controls" };
}
static async generate(
config: CommonControlsSectionStrategyConfig,
hass: HomeAssistant
@@ -30,7 +30,6 @@ import "../components/hui-section-edit-mode";
import { addSection } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { getAtPath, moveAtPath } from "../editor/lovelace-path";
import { showCreateSectionDialog } from "../editor/section-editor/show-create-section-dialog";
import type { HuiSection } from "../sections/hui-section";
import {
renderSectionContainer,
@@ -463,10 +462,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
private _createSection(): void {
showCreateSectionDialog(this, {
lovelace: this.lovelace!,
containerPath: this.path!,
});
const newConfig = addSection(
this.lovelace!.config,
this.path!,
generateDefaultSection(this.hass.localize, true)
);
this.lovelace!.saveConfig(newConfig);
}
private _sectionMoved(ev: CustomEvent) {
+13 -2
View File
@@ -292,10 +292,15 @@ export const getMyRedirects = (): Redirects => ({
redirect: "/config/integrations/integration",
params: {
domain: "string",
search: "string?",
},
},
integrations: {
redirect: "/config/integrations",
redirect: "/config/integrations/dashboard",
params: {
search: "string?",
status: "string?",
},
},
config_mqtt: {
component: "mqtt",
@@ -883,7 +888,13 @@ class HaPanelMy extends LitElement {
this._redirect!.redirect === "/config/integrations/integration" &&
params.domain === "hacs"
) {
return `${this._redirect!.redirect}?${createSearchParam({ domain: "marketplace" })}`;
const marketplaceParams = new URLSearchParams(
this._createRedirectParams()
);
marketplaceParams.set("domain", "marketplace");
return `${this._redirect!.redirect}?${marketplaceParams}`;
}
// Special case for supervisor_app/supervisor_addon: use path-based URL
+2
View File
@@ -0,0 +1,2 @@
export const mdiSendspin =
"M9.44 13.751C9.323 13.868 9.133 13.86 9.022 13.737C8.038 12.647 7.5 11.251 7.5 9.772C7.5 8.192 8.115 6.704 9.234 5.587C11.469 3.352 15.061 3.282 17.382 5.376C17.505 5.487 17.513 5.678 17.396 5.795L16.855 6.336C16.748 6.443 16.576 6.45 16.466 6.35C15.2 5.221 13.408 4.862 11.668 5.514C11.659 5.517 11.648 5.522 11.639 5.526C9.863 6.336 8.835 7.976 8.835 9.772C8.835 10.907 9.246 11.98 9.995 12.819C10.095 12.932 10.088 13.102 9.981 13.208L9.44 13.749V13.751ZM10.911 11.836C10.185 10.956 9.96 9.748 10.388 8.572C10.393 8.561 10.398 8.549 10.402 8.538C11.363 6.386 13.899 5.962 15.482 7.266C15.613 7.374 15.621 7.572 15.502 7.69L14.958 8.234C14.856 8.336 14.695 8.344 14.58 8.256C13.832 7.683 12.753 7.739 12.069 8.423C11.709 8.783 11.51 9.263 11.51 9.774C11.51 10.198 11.648 10.602 11.902 10.933C11.99 11.049 11.984 11.21 11.881 11.313L11.338 11.856C11.218 11.976 11.021 11.967 10.913 11.836H10.911ZM3.939 19.252C3.822 19.369 3.631 19.361 3.52 19.238C2.506 18.115 2 16.694 2 15.275C2 13.855 2.506 12.435 3.52 11.312C3.631 11.189 3.822 11.181 3.939 11.298L4.203 11.562L4.48 11.839C4.586 11.945 4.594 12.116 4.494 12.228C3.722 13.094 3.336 14.185 3.336 15.276C3.336 16.367 3.722 17.459 4.494 18.325C4.594 18.437 4.585 18.607 4.48 18.714L4.203 18.99L3.939 19.255V19.252ZM5.834 17.357C5.716 17.475 5.518 17.469 5.412 17.34C4.931 16.76 4.67 16.038 4.67 15.275C4.67 14.512 4.931 13.79 5.412 13.21C5.518 13.08 5.716 13.074 5.834 13.193L6.378 13.737C6.481 13.84 6.488 14.001 6.399 14.117C6.146 14.448 6.007 14.852 6.007 15.276C6.007 15.7 6.146 16.105 6.399 16.436C6.488 16.551 6.481 16.712 6.378 16.816L5.834 17.36V17.357ZM20.48 12.69C20.369 12.813 20.178 12.821 20.061 12.704L19.797 12.439L19.52 12.163C19.414 12.056 19.406 11.886 19.506 11.774C20.278 10.907 20.664 9.816 20.664 8.725C20.664 7.634 20.278 6.543 19.506 5.677C19.406 5.564 19.415 5.394 19.52 5.288L19.797 5.011L20.061 4.747C20.178 4.63 20.369 4.637 20.48 4.76C21.494 5.883 22 7.303 22 8.724C22 10.145 21.494 11.564 20.48 12.687V12.69ZM18.168 10.809L17.623 10.265C17.52 10.161 17.514 10 17.602 9.885C17.856 9.553 17.994 9.149 17.994 8.725C17.994 8.301 17.856 7.897 17.602 7.566C17.514 7.45 17.52 7.289 17.623 7.186L18.168 6.642C18.286 6.523 18.484 6.529 18.59 6.658C19.07 7.239 19.332 7.961 19.332 8.724C19.332 9.487 19.07 10.208 18.59 10.789C18.484 10.918 18.286 10.924 18.168 10.806V10.809ZM10.583 20.144C9.162 20.144 7.743 19.638 6.62 18.624C6.497 18.513 6.489 18.322 6.606 18.205L7.147 17.664C7.253 17.557 7.424 17.55 7.536 17.65C9.334 19.252 12.101 19.192 13.824 17.468C15.548 15.744 15.608 12.977 14.007 11.179C13.906 11.067 13.916 10.897 14.02 10.79L14.561 10.249C14.678 10.132 14.87 10.14 14.981 10.263C17.075 12.584 17.005 16.177 14.77 18.413C13.616 19.566 12.1 20.144 10.585 20.144H10.583ZM10.583 17.472C9.849 17.472 9.117 17.226 8.519 16.734C8.39 16.627 8.383 16.43 8.501 16.311L9.045 15.767C9.147 15.665 9.308 15.658 9.423 15.746C10.171 16.319 11.25 16.264 11.934 15.579C12.618 14.895 12.674 13.816 12.101 13.068C12.013 12.953 12.021 12.792 12.122 12.69L12.666 12.146C12.785 12.027 12.983 12.033 13.089 12.164C14.14 13.438 14.071 15.333 12.879 16.524C12.247 17.156 11.414 17.474 10.583 17.474V17.472Z";
+11 -29
View File
@@ -3482,9 +3482,6 @@
"unknown_error": "An unknown error happened when saving the category",
"confirm_delete": "Delete category?",
"confirm_delete_text": "This will delete the category and unassign everything that is currently assigned to it."
},
"filter": {
"show_all": "Show all"
}
},
"labels": {
@@ -5759,9 +5756,7 @@
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
"start": "Event start",
"end": "Event end",
"offset": "Offset (optional)",
"before": "Before",
"after": "After",
"offset": "Offset",
"description": {
"picker": "Triggers when a calendar event starts or ends.",
"full": "When{offsetChoice, select, \n before { it's {offset} before}\n after { it's {offset} after}\n other {}\n} a calendar event{eventChoice, select, \n start { starts}\n end { ends}\n other { starts or ends}\n}{hasCalendar, select, \n true { in {calendar}}\n other {}\n}"
@@ -5890,7 +5885,7 @@
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
"sunrise": "Sunrise",
"sunset": "Sunset",
"offset": "Offset in seconds or HH:MM:SS (optional)",
"offset": "Offset",
"description": {
"picker": "Triggers when the sun sets or rises.",
"sets": "When the sun sets{hasDuration, select, \n true { offset by {duration}} \n other {}\n }",
@@ -7432,7 +7427,7 @@
"setup_capability_audio_title": "Stream audio via Sendspin",
"setup_capability_audio_short": "Synchronized multi-room audio",
"setup_capability_audio_description": "Set up Sendspin and Music Assistant to turn this device into a synchronized music player. Music Assistant manages your audio library and uses the Sendspin streaming protocol to keep audio in sync across smart speakers in your home. Without this setup, Home Assistant can still play audio on this device, but not in sync with other speakers.",
"setup_capability_connectivity_title": "Connect a Z-Wave adapter",
"setup_capability_connectivity_title": "Connect a Z-Wave device",
"setup_capability_connectivity_short": "Network connectivity for ZWA-2",
"setup_capability_connectivity_description": "Plug a Connect ZWA-2 into the USB port to enable Z-Wave connectivity over Ethernet or Wi-Fi. Get your antenna in an optimal location for better device responsiveness.",
"setup_capability_serial_title": "Control a device over serial",
@@ -7465,7 +7460,7 @@
"setup_status_completed": "Completed",
"setup_zwave": "Set up Z-Wave",
"setup_zwave_configured": "Configured",
"setup_what_are_adapters": "What is this adapter?",
"setup_what_are_adapters": "What is this device?",
"setup_serial_configured": "Configured",
"setup_serial_offline": "Offline",
"setup_serial_port_ttl": "TTL",
@@ -9121,6 +9116,12 @@
"online": "Online",
"unknown_device": "Unknown device",
"unknown_devices": "Unknown devices",
"bridged": "Bridged",
"bridged_device": "Bridged device",
"bridged_devices": "Bridged devices",
"show_bridged_devices": "Select to show the bridged devices",
"hide_bridged_devices": "Select to hide the bridged devices",
"bridged_by": "[%key:ui::panel::config::integrations::config_entry::via%]",
"node": "Node {node_id}",
"node_id": "Node ID",
"network": "Network",
@@ -10431,28 +10432,9 @@
},
"section_strategy": {
"common-controls": {
"name": "Common controls",
"description": "Shows the entities you use most, based on your activity."
},
"device": {
"name": "Device",
"description": "Shows the controls and sensors of a device."
"name": "Common controls"
}
},
"create_section": {
"header": "Create section",
"manual": "Manual section",
"manual_description": "Start empty and add your own cards.",
"automatic": "[%key:ui::panel::lovelace::editor::section::automatic%]",
"automatic_description": "Fills itself with cards and stays up to date.",
"paste": "Paste section",
"paste_description": "Add the section you copied."
},
"select_section_strategy": {
"header": "Choose an automatic section",
"built_in": "Built-in sections",
"custom": "Community sections"
},
"edit_section": {
"header": "Section configuration",
"typed_header": "{type} section configuration",
@@ -65,9 +65,9 @@ describe("automations query params", () => {
).toBe(automationsFilterQueryString({}));
});
it("keeps one category, as the category filter picks one", () => {
it("keeps every category", () => {
expect(
automationsLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
).toEqual(filter(["a"]));
).toEqual(filter(["a", "b"]));
});
});
+2 -2
View File
@@ -61,9 +61,9 @@ describe("scenes query params", () => {
);
});
it("keeps one category, as the category filter picks one", () => {
it("keeps every category", () => {
expect(
scenesLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
).toEqual(filter(["a"]));
).toEqual(filter(["a", "b"]));
});
});
+2 -2
View File
@@ -65,9 +65,9 @@ describe("scripts query params", () => {
);
});
it("keeps one category, as the category filter picks one", () => {
it("keeps every category", () => {
expect(
scriptsLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
).toEqual(filter(["a"]));
).toEqual(filter(["a", "b"]));
});
});
+80
View File
@@ -108,3 +108,83 @@ describe("normalizeAutomationConfig deprecated option reporting", () => {
).not.toThrow();
});
});
describe("migrateAutomationConfig offset type", () => {
const migrateTrigger = (trigger: Record<string, unknown>) =>
(
migrateAutomationConfig({ triggers: [trigger as any] }).triggers as any
)[0];
it.each([
["sun.sunrise", { hours: 1 }, { hours: -1 }],
[
"calendar.event_started",
{ hours: 1, minutes: 30 },
{ hours: -1, minutes: -30 },
],
["sun.sunset", { hours: -1 }, { hours: 1 }],
[
"sun.dawn",
"00:30:00",
{ hours: 0, minutes: -30, seconds: 0, milliseconds: 0 },
],
])(
"folds a `before` offset type into the sign for %s",
(trigger, offset, expected) => {
expect(
migrateTrigger({ trigger, options: { offset, offset_type: "before" } })
).toEqual({ trigger, options: { offset: expected } });
}
);
it("drops an `after` offset type and keeps the offset", () => {
expect(
migrateTrigger({
trigger: "calendar.event_ended",
options: { offset: { hours: 1 }, offset_type: "after" },
})
).toEqual({
trigger: "calendar.event_ended",
options: { offset: { hours: 1 } },
});
});
it("drops the offset type when there is no offset", () => {
expect(
migrateTrigger({
trigger: "sun.sunrise",
options: { offset_type: "before" },
})
).toEqual({ trigger: "sun.sunrise", options: {} });
});
it("leaves an offset it cannot read untouched", () => {
const options = { offset: "{{ offset }}", offset_type: "before" };
expect(
migrateTrigger({ trigger: "sun.sunrise", options: { ...options } })
).toEqual({ trigger: "sun.sunrise", options });
});
it("leaves other integrations untouched", () => {
const options = { offset: { hours: 1 }, offset_type: "before" };
expect(
migrateTrigger({ trigger: "custom.event", options: { ...options } })
).toEqual({ trigger: "custom.event", options });
});
it("does not flag the migration as deprecated", () => {
const report: AutomationMigrationReport = { deprecated: false };
migrateAutomationConfig(
{
triggers: [
{
trigger: "sun.sunrise",
options: { offset: { hours: 1 }, offset_type: "before" },
} as any,
],
},
report
);
expect(report.deprecated).toBe(false);
});
});
@@ -0,0 +1,85 @@
import { IntlMessageFormat } from "intl-messageformat";
import { describe, expect, it } from "vitest";
import type { ForDict } from "../../src/data/automation";
import { describeTrigger } from "../../src/data/automation_i18n";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../src/data/translation";
import en from "../../src/translations/en.json";
import type { HomeAssistant } from "../../src/types";
type TranslationNode = string | { [key: string]: TranslationNode };
const localize = (key: string, values?: Record<string, unknown>) => {
const message = key
.split(".")
.reduce<TranslationNode | undefined>(
(translations, part) =>
typeof translations === "object" ? translations[part] : undefined,
en as TranslationNode
);
return typeof message === "string"
? (new IntlMessageFormat(message, "en").format(values) as string)
: "";
};
const hass = {
localize,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.twenty_four,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
config: { time_zone: "Etc/UTC" },
states: {},
} as unknown as HomeAssistant;
describe("legacy calendar trigger offset description", () => {
it.each([
[
"-01:30:00",
"When it's 1 hour, 30 minutes before a calendar event starts",
],
[
{ hours: 1, minutes: -30 },
"When it's 30 minutes after a calendar event starts",
],
[
{ seconds: 1.5 },
"When it's 1 second, 500 milliseconds after a calendar event starts",
],
[{ hours: 0, minutes: 0, seconds: 0 }, "When a calendar event starts"],
])("describes %j", (offset, expected) => {
expect(
describeTrigger(
{
trigger: "calendar",
event: "start",
entity_id: "",
offset: offset as string | ForDict,
},
hass,
[]
)
).toBe(expected);
});
});
describe("legacy sun trigger offset description", () => {
it.each([
[{ minutes: -30 }, "When the sun sets offset by -30:00"],
[{ hours: 1, minutes: -30 }, "When the sun sets offset by +30:00"],
[{ hours: 0, minutes: 0, seconds: 0 }, "When the sun sets"],
])("describes %j", (offset, expected) => {
expect(
describeTrigger({ trigger: "sun", event: "sunset", offset }, hass, [])
).toBe(expected);
});
});
@@ -7,6 +7,7 @@ import type {
} from "../../../../../src/data/matter";
import {
createMatterNetworkChartData,
getBridgedDeviceId,
getTopologyNodeCategory,
getTopologyNodeName,
networkToColorVar,
@@ -129,6 +130,17 @@ describe("getTopologyNodeCategory", () => {
expect(
getTopologyNodeCategory(node({ id: "unknown_1", kind: "thread_unknown" }))
).toBe(6);
expect(
getTopologyNodeCategory(
node({ id: "8", network_type: "ethernet", is_bridge: true })
)
).toBe(7);
// an offline bridge reads as offline like any other node
expect(
getTopologyNodeCategory(
node({ id: "9", is_bridge: true, available: false })
)
).toBe(5);
});
});
@@ -283,7 +295,7 @@ describe("createMatterNetworkChartData", () => {
element
);
expect(data.categories).toHaveLength(7);
expect(data.categories).toHaveLength(9);
// Home Assistant root + the 3 topology nodes
expect(data.nodes).toHaveLength(4);
@@ -799,4 +811,150 @@ describe("createMatterNetworkChartData", () => {
expect(data.nodes.find((n) => n.id === "1")!.context).toBe("NetA");
expect(data.nodes.find((n) => n.id === "2")!.context).toBe("NetB");
});
it("hangs the devices behind a bridge off the bridge node", () => {
const hass = mockHass(
{
bridge: { id: "bridge", name: "Gateway" },
couch: {
id: "couch",
name: "Couch",
via_device_id: "bridge",
area_id: "living_room",
},
window: {
id: "window",
name: "Window",
name_by_user: "Bedroom window",
via_device_id: "bridge",
},
disabled: {
id: "disabled",
name: "Disabled",
via_device_id: "bridge",
disabled_by: "user",
},
other: { id: "other", name: "Other", via_device_id: "plug" },
plug: { id: "plug", name: "Plug" },
},
{ living_room: { area_id: "living_room", name: "Living room" } }
);
const data = createMatterNetworkChartData(
topology([
node({
id: "3",
node_id: 3,
network_type: "ethernet",
is_bridge: true,
ha_device_id: "bridge",
}),
// only a node flagged as a bridge exposes bridged devices
node({
id: "4",
node_id: 4,
network_type: "wifi",
ha_device_id: "plug",
}),
]),
hass,
element,
new Set(["3"])
);
// the bridge is drawn as an aggregator carrying its bridged device count
const bridge = data.nodes.find((n) => n.id === "3")!;
expect(bridge.category).toBe(7);
expect(bridge.symbol).toMatch(/^image:\/\/data:image\/svg\+xml/);
expect(decodeURIComponent(bridge.symbol!)).toContain(">2</text>");
expect(data.nodes.find((n) => n.id === "4")!.symbol).toBe("circle");
const bridged = data.nodes.filter((n) => n.category === 8);
expect(bridged.map((n) => [n.id, n.name, n.context])).toEqual([
["bridged_couch", "Couch", "Living room"],
["bridged_window", "Bedroom window", undefined],
]);
expect(getBridgedDeviceId("bridged_couch")).toBe("couch");
expect(getBridgedDeviceId("3")).toBeUndefined();
expect(data.categories).toHaveLength(9);
const bridgedLinks = data.links.filter((l) => l.source === "3");
expect(bridgedLinks.map((l) => l.target)).toEqual([
"bridged_couch",
"bridged_window",
]);
// not a directional radio link, and it pulls the device to its bridge
expect(bridgedLinks[0].symbol).toBe("none");
expect(bridgedLinks[0].lineStyle?.type).toBe("solid");
expect(bridgedLinks[0].ignoreForceLayout).toBeFalsy();
});
it("dashes the links to the devices behind an offline bridge", () => {
const data = createMatterNetworkChartData(
topology([
node({
id: "3",
node_id: 3,
network_type: "ethernet",
is_bridge: true,
available: false,
ha_device_id: "bridge",
}),
]),
mockHass({
bridge: { id: "bridge", name: "Gateway" },
couch: { id: "couch", name: "Couch", via_device_id: "bridge" },
}),
element,
new Set(["3"])
);
const link = data.links.find((l) => l.target === "bridged_couch")!;
expect(link.source).toBe("3");
expect(link.lineStyle?.type).toBe("dashed");
});
it("keeps the devices behind a collapsed bridge out of the graph", () => {
const data = createMatterNetworkChartData(
topology([
node({
id: "3",
node_id: 3,
network_type: "ethernet",
is_bridge: true,
ha_device_id: "bridge",
}),
]),
mockHass({
bridge: { id: "bridge", name: "Gateway" },
couch: { id: "couch", name: "Couch", via_device_id: "bridge" },
}),
element
);
expect(data.nodes.filter((n) => n.category === 8)).toHaveLength(0);
expect(data.links.filter((l) => l.source === "3")).toHaveLength(0);
// the count still tells what the bridge would expand to
const bridge = data.nodes.find((n) => n.id === "3")!;
expect(decodeURIComponent(bridge.symbol!)).toContain(">1</text>");
});
it("draws a bridge without a device without a count", () => {
const data = createMatterNetworkChartData(
topology([
node({
id: "3",
node_id: 3,
network_type: "ethernet",
is_bridge: true,
}),
]),
mockHass(),
element
);
const bridge = data.nodes.find((n) => n.id === "3")!;
expect(bridge.category).toBe(7);
expect(decodeURIComponent(bridge.symbol!)).not.toContain("<text");
expect(data.nodes.filter((n) => n.category === 8)).toHaveLength(0);
});
});