Compare commits

...

12 Commits

Author SHA1 Message Date
Bram Kragten 43ff58010a Bumped version to 20260624.1 2026-06-25 16:16:23 +02:00
Copilot c391d571d7 Localize "(default)" label in Edit sidebar dialog (#52868)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 16:15:51 +02:00
Petar Petrov 18e15f8a99 Group Sankey flow siblings under their parent to fix segment crossovers (#52867) 2026-06-25 16:15:50 +02:00
Paul Bottein dfdd55b649 Show dash for unavailable number entity in slider row (#52866) 2026-06-25 16:15:49 +02:00
Paul Bottein bed98776c3 Fix logbook padding and margin (#52864) 2026-06-25 16:15:48 +02:00
Paul Bottein ad37f1bb58 Show action labels instead of timestamps in the logbook (#52861) 2026-06-25 16:15:47 +02:00
Franck Nijhof 2a00b0d0ec Use choose selector for legacy trigger fields (#52859)
* Use choose selector for legacy trigger fields

Replace the duration-only selector on the `for` field in the state,
numeric_state, and template triggers with a choose selector that
offers both duration and template options.

Replace the hand-rolled lower_limit/upper_limit select toggle for
above/below in the numeric_state trigger with a choose selector
that switches between a fixed number and an entity reference.

Add translation entries for the choose selector toggle button labels.

* Shorten the numeric state value toggle label

Use "Value of an entity" instead of "Numeric value of another entity" for
the numeric state trigger toggle, so it stays compact.
2026-06-25 16:15:46 +02:00
Paul Bottein 20efc35da3 Keep self-closing slashes when minifying svg`` templates (#52857)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:45 +02:00
Michael Hansen ac71b4c400 Add demo voice assistants and exposed entities (#52855) 2026-06-25 16:15:44 +02:00
Franck Nijhof b85422e652 Use the Jinja block comment for toggle-comment in templates (#52854)
The jinja2 editor mode is rendered on a YAML base, so Ctrl+/ inserted a "#"
line comment, which does nothing useful in a template. Give the jinja2
language a Jinja block comment token so toggle-comment wraps with {# #},
while the plain YAML mode keeps its # comment.
2026-06-25 16:15:43 +02:00
Paulus Schoutsen 4ff69aab8f Show supported frequencies column in radio frequency devices list (#52851)
Add a "Frequencies" column to the radio frequency devices (proxy) list so
users can see which frequency bands each transmitter supports. The supported
frequency ranges are formatted into a human-readable, locale-aware string
(picking Hz/kHz/MHz/GHz automatically) with a helper in the data layer.


Claude-Session: https://claude.ai/code/session_01SYyMTtBdrt7EBrVEt869Uw

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:42 +02:00
Bram Kragten ebb15d1118 Show warning when priming will not work for condition (#52709)
* Show warning when priming will not work for condition

* rename

* change to warning icon with tooltip

* review

* Update duration_to_seconds.test.ts
2026-06-25 16:15:41 +02:00
30 changed files with 1530 additions and 381 deletions
@@ -24,11 +24,16 @@ const getMinifier = () => {
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
// css`` templates and inline <style> is handled by minify-literals' lightningcss
// default.
//
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
// trailing slash would break the markup. It is harmless for HTML.
const htmlOptions = {
caseSensitive: true,
collapseWhitespace: true,
conservativeCollapse: true,
decodeEntities: true,
keepClosingSlash: true,
removeComments: true,
removeRedundantAttributes: true,
};
+1
View File
@@ -53,6 +53,7 @@ const CONFIG_PANEL_COMMANDS = [
"config/scene/config",
"search/related",
"tag/list",
"assist_pipeline/",
];
@customElement("ha-demo")
+36 -4
View File
@@ -1,11 +1,43 @@
import type { AssistPipeline } from "../../../src/data/assist_pipeline";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const pipelines: AssistPipeline[] = [
{
id: "01home_assistant_cloud",
name: "Home Assistant Cloud",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: "cloud",
stt_language: "en-US",
tts_engine: "cloud",
tts_language: "en-US",
tts_voice: "JennyNeural",
wake_word_entity: null,
wake_word_id: null,
},
{
id: "01local",
name: "Local",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: "stt.faster_whisper",
stt_language: "en",
tts_engine: "tts.piper",
tts_language: "en",
tts_voice: null,
wake_word_entity: null,
wake_word_id: null,
},
];
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
// Stub for assist pipeline list — returns a cloud and a local pipeline so the
// voice assistants config panel shows configured assistants.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
pipelines,
preferred_pipeline: "01home_assistant_cloud",
}));
// Stub for assist pipeline run — immediately sends run-end event so
+2
View File
@@ -1,5 +1,6 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { mockApplicationCredentials } from "./application_credentials";
import { mockAssist } from "./assist";
import { mockAutomation } from "./automation";
import { mockBackup } from "./backup";
import { mockBlueprint } from "./blueprint";
@@ -37,4 +38,5 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockScene(hass);
mockSearch(hass);
mockTags(hass);
mockAssist(hass);
};
+9 -4
View File
@@ -2,22 +2,27 @@ import type { ExposeEntitySettings } from "../../../src/data/expose";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const exposedEntities: Record<string, ExposeEntitySettings> = {
"light.bed_light": {
"light.floor_lamp": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
},
"light.ceiling_lights": {
"light.living_room_spotlights": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": false,
},
"switch.decorative_lights": {
"light.bar_lamp": {
conversation: true,
"cloud.alexa": false,
"cloud.google_assistant": true,
},
"climate.ecobee": {
"light.kitchen_spotlights": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
},
"light.outdoor_light": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.0"
version = "20260624.1"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+9 -2
View File
@@ -111,7 +111,7 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
]);
/** Domains that use a timestamp for state. */
export const TIMESTAMP_STATE_DOMAINS = new Set([
const TIMESTAMP_STATE_DOMAINS_LIST = [
"ai_task",
"button",
"conversation",
@@ -127,7 +127,14 @@ export const TIMESTAMP_STATE_DOMAINS = new Set([
"tts",
"wake_word",
"datetime",
]);
] as const;
export type TimestampStateDomain =
(typeof TIMESTAMP_STATE_DOMAINS_LIST)[number];
export const TIMESTAMP_STATE_DOMAINS = new Set<string>(
TIMESTAMP_STATE_DOMAINS_LIST
);
/** Temperature units. */
export const UNIT_C = "°C";
@@ -1,4 +1,13 @@
import type { HaDurationData } from "../../components/ha-duration-input";
export default function durationToSeconds(duration: string): number {
const parts = duration.split(":").map(Number);
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
export const durationDataToSeconds = (duration: HaDurationData): number =>
(duration.days || 0) * 86400 +
(duration.hours || 0) * 3600 +
(duration.minutes || 0) * 60 +
(duration.seconds || 0) +
(duration.milliseconds || 0) / 1000;
+42 -4
View File
@@ -1,5 +1,6 @@
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
import type { TimestampStateDomain } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import type { LocalizeFunc } from "../common/translations/localize";
@@ -239,16 +240,53 @@ export const parseTriggerSource = (source: string): ParsedTriggerSource => {
return {};
};
// Short label shown instead of the bare timestamp for each timestamp-state
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value), so a
// new timestamp domain won't compile until it gets a label here.
type LogbookActionMessage =
| "pressed"
| "activated"
| "scanned"
| "detected_event_no_type"
| "updated"
| "sent"
| "detected"
| "transcribed"
| "spoke"
| "responded"
| "ran"
| "command_sent";
const STATE_ACTION_MESSAGES: Record<
Exclude<TimestampStateDomain, "datetime">,
LogbookActionMessage
> = {
button: "pressed",
input_button: "pressed",
scene: "activated",
tag: "scanned",
event: "detected_event_no_type",
image: "updated",
notify: "sent",
wake_word: "detected",
stt: "transcribed",
tts: "spoke",
conversation: "responded",
ai_task: "ran",
infrared: "command_sent",
radio_frequency: "command_sent",
};
export const localizeStateMessage = (
hass: HomeAssistant,
state: string,
stateObj: HassEntity,
domain: string
): string => {
// Events expose a timestamp as their state, which has no meaningful display
// value, so keep a dedicated phrase.
if (domain === "event") {
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
const actionKey: LogbookActionMessage | undefined =
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
if (actionKey) {
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.${actionKey}`);
}
// Every other domain reuses the backend state translation, so the logbook
// speaks the same vocabulary as the rest of the UI.
+40
View File
@@ -1,3 +1,5 @@
import { formatNumber } from "../common/number/format_number";
import type { FrontendLocaleData } from "./translation";
import type { HomeAssistant } from "../types";
export const DOMAIN = "radio_frequency";
@@ -20,3 +22,41 @@ export const fetchRadioFrequencyTransmitters = (
hass.callWS({
type: "radio_frequency/list",
});
const FREQUENCY_UNITS: [number, string][] = [
[1e9, "GHz"],
[1e6, "MHz"],
[1e3, "kHz"],
[1, "Hz"],
];
// Format a frequency in hertz using the largest unit that keeps the value >= 1.
export const formatFrequency = (
hz: number,
locale: FrontendLocaleData
): string => {
const [divisor, unit] = FREQUENCY_UNITS.find(
([threshold]) => Math.abs(hz) >= threshold
) ?? [1, "Hz"];
return `${formatNumber(hz / divisor, locale, {
maximumFractionDigits: 3,
})} ${unit}`;
};
// Format a single [min, max] range; collapses to a single value when min === max.
export const formatFrequencyRange = (
range: readonly [number, number],
locale: FrontendLocaleData
): string => {
const [min, max] = range;
return min === max
? formatFrequency(min, locale)
: `${formatFrequency(min, locale)} ${formatFrequency(max, locale)}`;
};
// Format a list of frequency ranges into a human-readable, comma-separated string.
export const formatFrequencyRanges = (
ranges: readonly (readonly [number, number])[],
locale: FrontendLocaleData
): string =>
ranges.map((range) => formatFrequencyRange(range, locale)).join(", ");
+15
View File
@@ -153,6 +153,21 @@ export const getRecorderInfo = (conn: Connection) =>
type: "recorder/info",
});
export type EntityRecordingDisabler = "user";
export interface RecorderEntityOptions {
recording_disabled_by: EntityRecordingDisabler | null;
}
export const getRecorderEntityOptions = (
hass: Pick<HomeAssistant, "callWS">,
entity_id: string
) =>
hass.callWS<RecorderEntityOptions>({
type: "recorder/entity_options/get",
entity_id,
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
statistic_type?: "mean" | "sum"
+1 -2
View File
@@ -1044,8 +1044,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
ha-more-info-history-and-logbook {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
var(--ha-space-6);
padding: var(--ha-space-2) 0 var(--ha-space-6) 0;
display: block;
}
@@ -278,6 +278,7 @@ export class MoreInfoHistory extends LitElement {
justify-content: space-between;
align-items: center;
margin-bottom: var(--ha-space-2);
padding-inline: var(--ha-space-6);
}
.header > a,
a:visited {
@@ -290,6 +291,12 @@ export class MoreInfoHistory extends LitElement {
h2 {
margin: 0;
}
ha-alert,
state-history-charts,
statistics-chart {
display: block;
padding-inline: var(--ha-space-6);
}
`,
];
}
@@ -70,6 +70,7 @@ export class MoreInfoLogbook extends LitElement {
css`
ha-logbook {
--logbook-max-height: 250px;
--logbook-horizontal-padding: var(--ha-space-6);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-logbook {
@@ -82,6 +83,7 @@ export class MoreInfoLogbook extends LitElement {
justify-content: space-between;
align-items: center;
margin-bottom: var(--ha-space-2);
padding-inline: var(--ha-space-6);
}
.header > a,
a:visited {
+1 -1
View File
@@ -159,7 +159,7 @@ class DialogEditSidebar extends DirtyStateProviderMixin<SidebarState>()(
value: panel.url_path,
label:
(getPanelTitle(this.hass, panel) || panel.url_path) +
`${defaultPanel === panel.url_path ? " (default)" : ""}`,
`${defaultPanel === panel.url_path ? ` (${this.hass.localize("ui.sidebar.default")})` : ""}`,
icon: getPanelIcon(panel),
iconPath: getPanelIconPath(panel),
disableHiding: panel.url_path === defaultPanel,
@@ -1,13 +1,21 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import { mdiAlertOutline, mdiHelpCircleOutline } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformCondition } from "../../../../../data/automation";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-tooltip";
import type {
ForDict,
PlatformCondition,
} from "../../../../../data/automation";
import {
getConditionDomain,
getConditionObjectId,
@@ -15,11 +23,21 @@ import {
} from "../../../../../data/condition";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import { getRecorderEntityOptions } from "../../../../../data/recorder";
import type { TargetSelector } from "../../../../../data/selector";
import { getTargetEntityCount } from "../../../../../data/target";
import {
extractFromTarget,
getTargetEntityCount,
} from "../../../../../data/target";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
// when a condition has a `for:` duration, the recorder is only queried this far
// back to prime it at setup, so longer durations can't be fully satisfied from
// history after a restart or reload.
const MAX_HISTORY_PRIMING_LOOKBACK_HOURS = 6;
const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
field.selector &&
!field.required &&
@@ -41,6 +59,11 @@ export class HaPlatformCondition extends LitElement {
@state() private _resolvedTargetEntityCount?: number;
@state() private _targetHasUnrecordedEntity = false;
// Incremented on each recording check so stale async responses are ignored.
private _recordingCheckId = 0;
public static get defaultConfig(): PlatformCondition {
return { condition: "" };
}
@@ -51,6 +74,26 @@ export class HaPlatformCondition extends LitElement {
this.hass.loadBackendTranslation("conditions");
this.hass.loadBackendTranslation("selector");
}
// The `for:` priming info depends on both the condition (target + duration)
// and the description (whether the condition targets entities at all), which
// can arrive in separate updates.
if (
changedProperties.has("condition") ||
changedProperties.has("description")
) {
const previousCondition = changedProperties.get("condition") as
| undefined
| this["condition"];
if (
changedProperties.has("description") ||
previousCondition?.target !== this.condition?.target ||
previousCondition?.options?.for !== this.condition?.options?.for
) {
this._updateDurationPrimingInfo();
}
}
if (!changedProperties.has("condition")) {
return;
}
@@ -263,7 +306,7 @@ export class HaPlatformCondition extends LitElement {
@click=${showOptional ? this._toggleCheckbox : undefined}
>${this.hass.localize(
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
) || fieldName}</span
) || fieldName}${this._renderForPrimingInfo(fieldName)}</span
>
${description
? html`<span
@@ -472,6 +515,118 @@ export class HaPlatformCondition extends LitElement {
}
}
// Shows a small info icon beside the `for` duration field's label, with a
// tooltip explaining when history priming can't fully cover the duration.
private _renderForPrimingInfo(fieldName: string) {
if (fieldName !== "for") {
return nothing;
}
const text = this._durationPrimingInfoText();
if (!text) {
return nothing;
}
return html`<ha-svg-icon
id="for-priming-info"
tabindex="0"
class="priming-info-icon"
.path=${mdiAlertOutline}
@click=${stopPropagation}
></ha-svg-icon>
<ha-tooltip for="for-priming-info">${text}</ha-tooltip>`;
}
private _durationPrimingInfoText(): string | undefined {
const forValue = this.condition.options?.for;
// Priming only happens for entity conditions that have a `for:` duration.
if (
forValue === undefined ||
forValue === "" ||
!this.description?.target
) {
return undefined;
}
if (this._targetHasUnrecordedEntity) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.entity_not_recorded"
);
}
if (this._durationExceedsLookback(forValue)) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.history_capped",
{ hours: MAX_HISTORY_PRIMING_LOOKBACK_HOURS }
);
}
return undefined;
}
private _durationExceedsLookback(forValue: unknown): boolean {
const duration = createDurationData(
forValue as string | number | ForDict | undefined
);
if (!duration) {
return false;
}
return (
durationDataToSeconds(duration) >
MAX_HISTORY_PRIMING_LOOKBACK_HOURS * 3600
);
}
private async _updateDurationPrimingInfo(): Promise<void> {
const forValue = this.condition.options?.for;
const target = this.condition.target;
// Recording status only matters for an entity condition that has both a
// target and a `for:` duration.
const checkId = ++this._recordingCheckId;
if (
forValue === undefined ||
forValue === "" ||
!this.description?.target ||
!target ||
!this.hass.config.components.includes("recorder")
) {
this._targetHasUnrecordedEntity = false;
return;
}
try {
const { referenced_entities } = await extractFromTarget(
this.hass.callWS,
target
);
// Ignore if a newer check superseded this one.
if (checkId !== this._recordingCheckId) {
return;
}
if (!referenced_entities.length) {
this._targetHasUnrecordedEntity = false;
return;
}
const recordingDisabled = await Promise.all(
referenced_entities.map((entityId) =>
getRecorderEntityOptions(this.hass, entityId)
.then((options) => options.recording_disabled_by !== null)
// Unknown entity or command unavailable on older cores: don't warn.
.catch(() => false)
)
);
if (checkId !== this._recordingCheckId) {
return;
}
this._targetHasUnrecordedEntity = recordingDisabled.some(Boolean);
} catch (_err) {
// Target resolution failed; fall back to no warning rather than guessing.
if (checkId === this._recordingCheckId) {
this._targetHasUnrecordedEntity = false;
}
}
}
static styles = css`
:host {
display: block;
@@ -527,6 +682,15 @@ export class HaPlatformCondition extends LitElement {
.clickable {
cursor: pointer;
}
.priming-info-icon {
--mdc-icon-size: 16px;
width: 16px;
height: 16px;
color: var(--warning-color);
margin-inline-start: var(--ha-space-1);
vertical-align: middle;
cursor: help;
}
`;
}
@@ -1,17 +1,189 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
const SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
context: { filter_entity: "entity_id" },
selector: {
attribute: {
hide_attributes: [
"access_token",
"auto_update",
"available_modes",
"away_mode",
"changed_by",
"code_arm_required",
"code_format",
"color_mode",
"color_modes",
"current_activity",
"device_class",
"editable",
"effect_list",
"effect",
"entity_id",
"entity_picture",
"event_type",
"event_types",
"fan_mode",
"fan_modes",
"fan_speed_list",
"forecast",
"friendly_name",
"frontend_stream_type",
"has_date",
"has_time",
"hs_color",
"hvac_mode",
"hvac_modes",
"icon",
"id",
"latest_version",
"max_color_temp_kelvin",
"max_mireds",
"max_temp",
"media_album_name",
"media_artist",
"media_content_type",
"media_position_updated_at",
"media_title",
"min_color_temp_kelvin",
"min_mireds",
"min_temp",
"mode",
"next_dawn",
"next_dusk",
"next_midnight",
"next_noon",
"next_rising",
"next_setting",
"operation_list",
"operation_mode",
"options",
"percentage_step",
"precipitation_unit",
"preset_mode",
"preset_modes",
"pressure_unit",
"release_notes",
"release_summary",
"release_url",
"restored",
"rgb_color",
"rgbw_color",
"shuffle",
"skipped_version",
"sound_mode_list",
"sound_mode",
"source_list",
"source_type",
"source",
"state_class",
"step",
"supported_color_modes",
"supported_features",
"swing_mode",
"swing_modes",
"target_temp_step",
"temperature_unit",
"title",
"token",
"unit_of_measurement",
"user_id",
"uuid",
"visibility_unit",
"wind_speed_unit",
"xy_color",
],
},
},
},
{
name: "above",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.numeric_state.threshold_type",
choices: {
value: {
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
input: {
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
},
},
},
},
{
name: "below",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.numeric_state.threshold_type",
choices: {
value: {
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
input: {
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
},
},
},
},
{
name: "value_template",
selector: { template: {} },
},
{
name: "for",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.numeric_state.for_type",
choices: {
duration: { selector: { duration: {} } },
template: { selector: { template: {} } },
},
},
},
},
] as const;
@customElement("ha-automation-trigger-numeric_state")
export class HaNumericStateTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -20,236 +192,6 @@ export class HaNumericStateTrigger extends LitElement {
@property({ type: Boolean }) public disabled = false;
@state() private _inputAboveIsEntity?: boolean;
@state() private _inputBelowIsEntity?: boolean;
private _schema = memoizeOne(
(
localize: LocalizeFunc,
inputAboveIsEntity?: boolean,
inputBelowIsEntity?: boolean
) =>
[
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
context: { filter_entity: "entity_id" },
selector: {
attribute: {
hide_attributes: [
"access_token",
"auto_update",
"available_modes",
"away_mode",
"changed_by",
"code_arm_required",
"code_format",
"color_mode",
"color_modes",
"current_activity",
"device_class",
"editable",
"effect_list",
"effect",
"entity_id",
"entity_picture",
"event_type",
"event_types",
"fan_mode",
"fan_modes",
"fan_speed_list",
"forecast",
"friendly_name",
"frontend_stream_type",
"has_date",
"has_time",
"hs_color",
"hvac_mode",
"hvac_modes",
"icon",
"id",
"latest_version",
"max_color_temp_kelvin",
"max_mireds",
"max_temp",
"media_album_name",
"media_artist",
"media_content_type",
"media_position_updated_at",
"media_title",
"min_color_temp_kelvin",
"min_mireds",
"min_temp",
"mode",
"next_dawn",
"next_dusk",
"next_midnight",
"next_noon",
"next_rising",
"next_setting",
"operation_list",
"operation_mode",
"options",
"percentage_step",
"precipitation_unit",
"preset_mode",
"preset_modes",
"pressure_unit",
"release_notes",
"release_summary",
"release_url",
"restored",
"rgb_color",
"rgbw_color",
"shuffle",
"skipped_version",
"sound_mode_list",
"sound_mode",
"source_list",
"source_type",
"source",
"state_class",
"step",
"supported_color_modes",
"supported_features",
"swing_mode",
"swing_modes",
"target_temp_step",
"temperature_unit",
"title",
"token",
"unit_of_measurement",
"user_id",
"uuid",
"visibility_unit",
"wind_speed_unit",
"xy_color",
],
},
},
},
{
name: "lower_limit",
type: "select",
required: true,
options: [
[
"value",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
),
],
[
"input",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
),
],
],
},
...(inputAboveIsEntity
? ([
{
name: "above",
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
] as const)
: ([
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
] as const)),
{
name: "upper_limit",
type: "select",
required: true,
options: [
[
"value",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
),
],
[
"input",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
),
],
],
},
...(inputBelowIsEntity
? ([
{
name: "below",
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
] as const)
: ([
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
] as const)),
{
name: "value_template",
selector: { template: {} },
},
{ name: "for", selector: { duration: {} } },
] as const
);
public willUpdate(changedProperties: PropertyValues<this>) {
this._inputAboveIsEntity =
this._inputAboveIsEntity ??
(typeof this.trigger.above === "string" &&
((this.trigger.above as string).startsWith("input_number.") ||
(this.trigger.above as string).startsWith("number.") ||
(this.trigger.above as string).startsWith("sensor.")));
this._inputBelowIsEntity =
this._inputBelowIsEntity ??
(typeof this.trigger.below === "string" &&
((this.trigger.below as string).startsWith("input_number.") ||
(this.trigger.below as string).startsWith("number.") ||
(this.trigger.below as string).startsWith("sensor.")));
if (!changedProperties.has("trigger")) {
return;
}
// Check for templates in trigger. If found, revert to YAML mode.
if (this.trigger && hasTemplate(this.trigger.for)) {
fireEvent(
this,
"ui-mode-not-available",
Error(this.hass.localize("ui.errors.config.no_template_editor_support"))
);
}
}
public static get defaultConfig(): NumericStateTrigger {
return {
trigger: "numeric_state",
@@ -257,39 +199,61 @@ export class HaNumericStateTrigger extends LitElement {
};
}
private _data = memoizeOne(
(
inputAboveIsEntity: boolean,
inputBelowIsEntity: boolean,
trigger: NumericStateTrigger
) => ({
lower_limit: inputAboveIsEntity ? "input" : "value",
upper_limit: inputBelowIsEntity ? "input" : "value",
...trigger,
entity_id: ensureArray(trigger.entity_id),
for: createDurationData(trigger.for),
})
);
private _wrapForValue(
forValue: NumericStateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): NumericStateTrigger["for"] {
if (!forValue || !forValue.active_choice) {
return forValue as NumericStateTrigger["for"];
}
if (forValue.active_choice === "template") {
return forValue.template as string;
}
return forValue.duration as NumericStateTrigger["for"];
}
private _unwrapThresholdValue(
value: Record<string, unknown> | number | string | undefined
): number | string | undefined {
if (value === undefined || typeof value !== "object") {
return value as number | string | undefined;
}
if (!value.active_choice) {
return undefined;
}
return value[value.active_choice as string] as number | string | undefined;
}
private _data = memoizeOne((trigger: NumericStateTrigger) => ({
...trigger,
entity_id: ensureArray(trigger.entity_id),
for: this._wrapForValue(trigger.for),
}));
public render() {
const schema = this._schema(
this.hass.localize,
this._inputAboveIsEntity,
this._inputBelowIsEntity
);
const data = this._data(
this._inputAboveIsEntity!,
this._inputBelowIsEntity!,
this.trigger
);
const data = this._data(this.trigger);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.schema=${SCHEMA}
.disabled=${this.disabled}
.localizeValue=${this.hass.localize}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
></ha-form>
@@ -300,11 +264,9 @@ export class HaNumericStateTrigger extends LitElement {
ev.stopPropagation();
const newTrigger = { ...ev.detail.value };
this._inputAboveIsEntity = newTrigger.lower_limit === "input";
this._inputBelowIsEntity = newTrigger.upper_limit === "input";
delete newTrigger.lower_limit;
delete newTrigger.upper_limit;
newTrigger.above = this._unwrapThresholdValue(newTrigger.above);
newTrigger.below = this._unwrapThresholdValue(newTrigger.below);
newTrigger.for = this._unwrapForValue(newTrigger.for);
if (newTrigger.value_template === "") {
delete newTrigger.value_template;
@@ -314,7 +276,7 @@ export class HaNumericStateTrigger extends LitElement {
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
schema: SchemaUnion<typeof SCHEMA>
): string => {
switch (schema.name) {
case "entity_id":
@@ -174,7 +174,19 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
},
},
{ name: "for", selector: { duration: {} } },
{
name: "for",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.state.for_type",
choices: {
duration: { selector: { duration: {} } },
template: { selector: { template: {} } },
},
},
},
},
] as const satisfies HaFormSchema[]
);
@@ -190,7 +202,9 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
delete this.trigger.for.milliseconds;
}
// Check for templates in trigger. If found, revert to YAML mode.
if (this.trigger && hasTemplate(this.trigger)) {
// Exclude "for" since the UI now supports templates there via choose.
const { for: _for, ...triggerWithoutFor } = this.trigger;
if (triggerWithoutFor && hasTemplate(triggerWithoutFor)) {
fireEvent(
this,
"ui-mode-not-available",
@@ -207,13 +221,38 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
return true;
}
protected render() {
const trgFor = createDurationData(this.trigger.for);
private _wrapForValue(
forValue: StateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): StateTrigger["for"] {
if (!forValue || !forValue.active_choice) {
return forValue as StateTrigger["for"];
}
if (forValue.active_choice === "template") {
return forValue.template as string;
}
return forValue.duration as StateTrigger["for"];
}
protected render() {
const data = {
...this.trigger,
entity_id: ensureArray(this.trigger.entity_id),
for: trgFor,
for: this._wrapForValue(this.trigger.for),
};
data.to = this._normalizeStates(this.trigger.to, data.attribute);
@@ -230,6 +269,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
.hass=${this.hass}
.data=${data}
.schema=${schema}
.localizeValue=${this.hass.localize}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.disabled=${this.disabled}
@@ -241,6 +281,8 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
ev.stopPropagation();
const newTrigger = ev.detail.value;
newTrigger.for = this._unwrapForValue(newTrigger.for);
newTrigger.to = this._applyAnyStateExclusive(
newTrigger.to,
newTrigger.attribute
@@ -1,4 +1,3 @@
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { TemplateTrigger } from "../../../../../data/automation";
@@ -11,7 +10,19 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
{ name: "for", selector: { duration: {} } },
{
name: "for",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.template.for_type",
choices: {
duration: { selector: { duration: {} } },
template: { selector: { template: {} } },
},
},
},
},
] as const;
@customElement("ha-automation-trigger-template")
@@ -26,26 +37,37 @@ export class HaTemplateTrigger extends LitElement {
return { trigger: "template", value_template: "" };
}
public willUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("trigger")) {
return;
private _wrapForValue(
forValue: TemplateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
// Check for templates in trigger. If found, revert to YAML mode.
if (this.trigger && hasTemplate(this.trigger.for)) {
fireEvent(
this,
"ui-mode-not-available",
Error(this.hass.localize("ui.errors.config.no_template_editor_support"))
);
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): TemplateTrigger["for"] {
if (!forValue || !forValue.active_choice) {
return forValue as TemplateTrigger["for"];
}
if (forValue.active_choice === "template") {
return forValue.template as string;
}
return forValue.duration as TemplateTrigger["for"];
}
protected render() {
const trgFor = createDurationData(this.trigger.for);
const data = {
...this.trigger,
for: trgFor,
for: this._wrapForValue(this.trigger.for),
};
return html`
@@ -53,6 +75,7 @@ export class HaTemplateTrigger extends LitElement {
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.localizeValue=${this.hass.localize}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.disabled=${this.disabled}
@@ -64,8 +87,11 @@ export class HaTemplateTrigger extends LitElement {
ev.stopPropagation();
const newTrigger = ev.detail.value;
newTrigger.for = this._unwrapForValue(newTrigger.for);
if (
newTrigger.for &&
typeof newTrigger.for === "object" &&
Object.values(newTrigger.for).every((value) => value === 0)
) {
delete newTrigger.for;
@@ -12,7 +12,10 @@ import type {
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-relative-time";
import { UNAVAILABLE, UNKNOWN } from "../../../../../data/entity/entity";
import type { RadioFrequencyTransmitter } from "../../../../../data/radio_frequency";
import {
formatFrequencyRanges,
type RadioFrequencyTransmitter,
} from "../../../../../data/radio_frequency";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
@@ -22,6 +25,7 @@ interface RadioFrequencyTransmitterRow {
id: string;
name: string;
type: string;
frequencies: string;
last_used?: string;
device_id: string | null;
}
@@ -64,6 +68,13 @@ export class RadioFrequencyDevicesPage extends LitElement {
filterable: true,
groupable: true,
},
frequencies: {
title: localize("ui.panel.config.radio_frequency.frequencies"),
sortable: true,
filterable: true,
flex: 2,
template: (transmitter) => transmitter.frequencies || "—",
},
last_used: {
title: localize("ui.panel.config.radio_frequency.last_used"),
sortable: true,
@@ -86,7 +97,8 @@ export class RadioFrequencyDevicesPage extends LitElement {
(
transmitters: RadioFrequencyTransmitter[],
states: HomeAssistant["states"],
localize: LocalizeFunc
localize: LocalizeFunc,
locale: HomeAssistant["locale"]
): RadioFrequencyTransmitterRow[] =>
transmitters.map((transmitter) => {
const stateObj = states[transmitter.entity_id];
@@ -104,6 +116,10 @@ export class RadioFrequencyDevicesPage extends LitElement {
id: transmitter.entity_id,
name: stateObj ? computeStateName(stateObj) : transmitter.entity_id,
type: localize("component.radio_frequency.entity_component._.name"),
frequencies: formatFrequencyRanges(
transmitter.supported_frequency_ranges,
locale
),
last_used,
device_id: transmitter.device_id,
};
@@ -122,7 +138,8 @@ export class RadioFrequencyDevicesPage extends LitElement {
.data=${this._data(
this.transmitters,
this.hass.states,
this.hass.localize
this.hass.localize,
this.hass.locale
)}
.noDataText=${this.hass.localize(
"ui.panel.config.radio_frequency.no_devices"
+12 -1
View File
@@ -8,6 +8,7 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { useAmPm } from "../../common/datetime/use_am_pm";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -126,6 +127,7 @@ class HaLogbookEntry extends LitElement {
[`node-${node}`]: true,
"last-of-day": this.lastOfDay,
[`category-${ctx.category}`]: true,
"time-am-pm": useAmPm(this.hass.locale),
})}"
>
${layout === "timeline"
@@ -591,7 +593,7 @@ class HaLogbookEntry extends LitElement {
width: 100%;
box-sizing: border-box;
/* No vertical padding: the rail must reach the row edges to stay continuous between nodes. */
padding: 0 var(--ha-space-4);
padding: 0 var(--logbook-horizontal-padding, var(--ha-space-4));
grid-auto-rows: minmax(60px, auto);
line-height: var(--ha-line-height-normal);
align-items: stretch;
@@ -913,6 +915,7 @@ class HaLogbookEntry extends LitElement {
.time-chip {
flex-shrink: 0;
text-align: end;
line-height: 1;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
@@ -921,6 +924,14 @@ class HaLogbookEntry extends LitElement {
user-select: none;
}
.time-chip {
min-width: 4.5em;
}
.entry.time-am-pm .time-chip {
min-width: 6em;
}
.time-chip:hover {
opacity: 0.75;
}
+2 -1
View File
@@ -197,7 +197,8 @@ class HaLogbookRenderer extends LitElement {
.date {
margin: var(--ha-space-2) 0 0;
padding: var(--ha-space-2) var(--ha-space-4) 0;
padding: var(--ha-space-2)
var(--logbook-horizontal-padding, var(--ha-space-4)) 0;
font-weight: var(--ha-font-weight-medium);
}
+22 -28
View File
@@ -1,4 +1,3 @@
import { mdiChevronRight } from "@mdi/js";
import { startOfYesterday } from "date-fns";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -10,10 +9,10 @@ import { ensureArray } from "../../../common/array/ensure-array";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { getEntityEntryContext } from "../../../common/entity/context/get_entity_context";
import { navigate } from "../../../common/navigate";
import { createSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-tooltip";
import { resolveEntityIDs } from "../../../data/selector";
import type { HomeAssistant } from "../../../types";
import "../../logbook/ha-logbook";
@@ -73,6 +72,8 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
@state() private _stateFilter?: string[];
private _showMoreLinkId = `logbook-${Math.random().toString(36).substring(2, 9)}`;
public getCardSize(): number {
return 9 + (this._config?.title ? 1 : 0);
}
@@ -142,10 +143,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
this._stateFilter = ensureArray(config.state_filter);
}
private _showMore() {
navigate(this._showMoreUrl());
}
private _showMoreUrl(): string {
const target = this._targetPickerValue;
const params: Record<string, string> = {
@@ -291,16 +288,21 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
return html`
<ha-card class=${classMap({ "no-header": !this._config!.title })}>
${this._config!.title
? html`<div class="card-header">
<h1 class="name">${this._config!.title}</h1>
<ha-icon-button
.path=${mdiChevronRight}
.label=${this.hass.localize(
? html`<h1 class="card-header">
${this._config!.title}
<a
id=${this._showMoreLinkId}
href=${this._showMoreUrl()}
aria-label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
@click=${this._showMore}
></ha-icon-button>
</div>`
>
<ha-icon-next></ha-icon-next>
</a>
<ha-tooltip for=${this._showMoreLinkId} placement="left">
${this.hass.localize("ui.dialogs.more_info_control.show_more")}
</ha-tooltip>
</h1>`
: nothing}
<div class="content">
<ha-logbook
@@ -336,26 +338,18 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 0;
padding-bottom: 0;
}
.card-header .name {
margin: 0;
font-size: var(--ha-card-header-font-size, 1.4rem);
font-weight: var(--ha-card-header-font-weight, 500);
color: var(--ha-card-header-color, var(--primary-text-color));
}
.card-header a {
.card-header ha-icon-next {
--ha-icon-button-size: 24px;
line-height: 24px;
color: var(--primary-text-color);
margin-right: calc(var(--ha-space-2) * -1);
margin-inline-end: calc(var(--ha-space-2) * -1);
margin-inline-start: initial;
}
.content {
height: 100%;
padding: 0 16px 16px;
padding: 0 0 16px;
}
.no-header .content {
@@ -5,7 +5,7 @@ import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-slider";
import "../../../components/input/ha-input";
import type { HaInput } from "../../../components/input/ha-input";
import { UNAVAILABLE } from "../../../data/entity/entity";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import { setValue } from "../../../data/input_text";
import type { HomeAssistant } from "../../../types";
import { hasConfigOrEntityChanged } from "../common/has-changed";
@@ -91,7 +91,9 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
@change=${this._selectedValueChanged}
></ha-slider>
<span class="state">
${this.hass.formatEntityState(stateObj)}
${stateObj.state === UNAVAILABLE || stateObj.state === UNKNOWN
? "—"
: this.hass.formatEntityState(stateObj)}
</span>
</div>
`
+11 -1
View File
@@ -56,8 +56,18 @@ export { tags } from "@lezer/highlight";
const _yamlWithJinja = jinja({ base: yaml() });
// The jinja2 mode is rendered on a YAML base, whose line comment is "#". In a
// template that is meaningless, so toggle-comment (Ctrl+/) should use the Jinja
// block comment "{# #}" instead. Scope this to the jinja2 language only so the
// plain YAML mode keeps its "#" comment.
const _jinjaCommentTokens = Prec.highest(
EditorState.languageData.of(() => [
{ commentTokens: { block: { open: "{#", close: "#}" } } },
])
);
export const langs = {
jinja2: _yamlWithJinja,
jinja2: [_yamlWithJinja, _jinjaCommentTokens],
yaml: _yamlWithJinja,
};
@@ -331,6 +331,28 @@ export function computeBarycenter(
return totalWeight > 0 ? weightedSum / totalWeight : fallback;
}
// Index of the single highest-weight neighbor present in the reference
// section (ties on weight broken by the earliest edge). Used only as a
// barycenter tie-break so a node stays beside its dominant neighbor's group
// instead of falling back to a stale seed index. Falls back to the node's own
// index when it has no resolvable neighbor, matching computeBarycenter.
export function dominantNeighborIndex(
neighbors: WeightedNeighbor[],
referenceIdIndexMap: Map<string, number>,
fallback: number
): number {
let bestIdx = fallback;
let bestWeight = -Infinity;
neighbors.forEach(({ id, weight }) => {
const idx = referenceIdIndexMap.get(id);
if (idx !== undefined && weight > bestWeight) {
bestWeight = weight;
bestIdx = idx;
}
});
return bestIdx;
}
function buildIdIndexMap(section: Node[]): Map<string, number> {
const map = new Map<string, number>();
section.forEach((node, index) => map.set(node.id, index));
@@ -342,12 +364,20 @@ function sortSectionByBarycenter(
referenceMap: Map<string, number>,
getNeighbors: (node: Node) => WeightedNeighbor[]
): { sorted: Node[]; changed: boolean } {
const decorated = section.map((node, index) => ({
node,
index,
barycenter: computeBarycenter(getNeighbors(node), referenceMap, index),
}));
decorated.sort((a, b) => a.barycenter - b.barycenter || a.index - b.index);
const decorated = section.map((node, index) => {
const neighbors = getNeighbors(node);
return {
node,
index,
barycenter: computeBarycenter(neighbors, referenceMap, index),
// Tie-break that keeps a node next to its dominant neighbor's group.
anchor: dominantNeighborIndex(neighbors, referenceMap, index),
};
});
decorated.sort(
(a, b) =>
a.barycenter - b.barycenter || a.anchor - b.anchor || a.index - b.index
);
const sorted = decorated.map((d) => d.node);
const changed = sorted.some((n, idx) => n !== section[idx]);
return { sorted, changed };
@@ -452,6 +482,55 @@ function crossingsAdjacentTo(
return total;
}
function countAllCrossings(
sections: Node[][],
sectionMaps: Map<string, number>[],
depths: number[],
edges: GraphEdge[]
): number {
let total = 0;
for (let i = 0; i < sections.length - 1; i++) {
total += countCrossings(
getEdgeSegmentsBetween(
depths[i],
depths[i + 1],
depths,
edges,
sectionMaps[i],
sectionMaps[i + 1]
)
);
}
return total;
}
// A section is "multi-parent" when at least one of its real nodes draws from
// two or more distinct parents in the previous section. In the energy/power/
// water cards only the source/home layers do this (grid/solar/battery feed
// home + battery_in + grid_return); every later section (floors, areas,
// devices) is a pure single-parent tree level. Pass-throughs are always
// single-parent (one source/target chain) and never make a section multi-parent.
function sectionHasMultipleParents(
section: Node[],
prevDepth: number,
depths: number[]
): boolean {
return section.some((node) => {
if (isPassThroughNode(node)) {
return false;
}
const parentIds = new Set(
getNeighborIds(node, "source", prevDepth, depths).map((n) => n.id)
);
return parentIds.size > 1;
});
}
// The head barycenter sweep (STEP 1) only touches the multi-parent head
// sections (the single-parent tree below is placed deterministically in
// STEP 2), so a single forward+backward pass converges in practice and the loop
// early-exits on the first no-change sweep. The cap is just headroom for unusual
// topologies with several interacting head sections.
const MAX_SORT_ITERATIONS = 4;
export function sortNodesInSections(
@@ -460,13 +539,30 @@ export function sortNodesInSections(
edges: GraphEdge[]
): Record<number, Node[]> {
const sections: Node[][] = depths.map((d) => [...(nodesPerSection[d] || [])]);
// Id→index lookup per section, kept in sync with sections. Rebuilt only when
// a section's order actually changes (inside tryReplace).
// Id→index lookup per section, kept in sync with sections.
const sectionMaps: Map<string, number>[] = sections.map(buildIdIndexMap);
// Replace a section with a candidate ordering only when crossings strictly
// drop on either side. This keeps user-intended ordering intact when
// barycenter would shuffle nodes without improving the layout.
// Classify each section past the root. Multi-parent sections (the
// intentionally-ordered source/home layers) are minimized by barycenter in
// PASS 2; the rest are single-parent tree levels placed deterministically in
// PASS 1. Classification reads only the graph, so it is stable across passes.
const multiParent = depths.map(
(_d, i) =>
i >= 1 && sectionHasMultipleParents(sections[i], depths[i - 1], depths)
);
// Best (fewest-crossing) head ordering seen so far, seeded from the original
// input so the head is provably never worse than the seed.
const snapshot = (): Node[][] => sections.map((s) => s.slice());
let liveCrossings = countAllCrossings(sections, sectionMaps, depths, edges);
let bestCrossings = liveCrossings;
let bestSections = snapshot();
// Replace a multi-parent section with a candidate ordering when crossings on
// its adjacent boundaries do not increase. Accepting equal-crossing
// ("plateau") moves lets the sweep escape local optima; the best snapshot
// (captured only on a strict global decrease) is what seeds the head order,
// so the result is deterministic and never worse than the seed.
const tryReplace = (i: number, candidate: Node[]): boolean => {
const before = crossingsAdjacentTo(i, sections, sectionMaps, depths, edges);
const sectionSnapshot = sections[i];
@@ -474,7 +570,12 @@ export function sortNodesInSections(
sections[i] = candidate;
sectionMaps[i] = buildIdIndexMap(candidate);
const after = crossingsAdjacentTo(i, sections, sectionMaps, depths, edges);
if (after < before) {
if (after <= before) {
liveCrossings += after - before;
if (liveCrossings < bestCrossings) {
bestCrossings = liveCrossings;
bestSections = snapshot();
}
return true;
}
sections[i] = sectionSnapshot;
@@ -482,39 +583,97 @@ export function sortNodesInSections(
return false;
};
for (let iter = 0; iter < MAX_SORT_ITERATIONS; iter++) {
let changed = false;
// STEP 1 — settle the multi-parent head sections (sources → home/battery_in/
// grid_return) by barycenter. These layers have nodes with several parents and
// so no single parent position to inherit; we minimize their crossings by the
// weighted average of neighbour positions, iterating forward then backward
// until the order is stable. The backward sweep is restricted to multi-parent
// neighbours, so the head order never depends on its single-parent children —
// that is what keeps the whole result idempotent, because STEP 2 re-derives
// those children purely from the settled head. The root section (index 0) is
// never reordered.
if (multiParent.some(Boolean)) {
for (let iter = 0; iter < MAX_SORT_ITERATIONS; iter++) {
let changed = false;
for (let i = 1; i < sections.length; i++) {
const prevDepth = depths[i - 1];
const result = sortSectionByBarycenter(
sections[i],
sectionMaps[i - 1],
(node) => getNeighborIds(node, "source", prevDepth, depths)
);
if (result.changed && tryReplace(i, result.sorted)) {
changed = true;
for (let i = 1; i < sections.length; i++) {
if (!multiParent[i]) {
continue;
}
const prevDepth = depths[i - 1];
const result = sortSectionByBarycenter(
sections[i],
sectionMaps[i - 1],
(node) => getNeighborIds(node, "source", prevDepth, depths)
);
if (result.changed && tryReplace(i, result.sorted)) {
changed = true;
}
}
}
for (let i = sections.length - 2; i >= 0; i--) {
const nextDepth = depths[i + 1];
const result = sortSectionByBarycenter(
sections[i],
sectionMaps[i + 1],
(node) => getNeighborIds(node, "target", nextDepth, depths)
);
if (result.changed && tryReplace(i, result.sorted)) {
changed = true;
for (let i = sections.length - 2; i >= 1; i--) {
if (!multiParent[i] || !multiParent[i + 1]) {
continue;
}
const nextDepth = depths[i + 1];
const result = sortSectionByBarycenter(
sections[i],
sectionMaps[i + 1],
(node) => getNeighborIds(node, "target", nextDepth, depths)
);
if (result.changed && tryReplace(i, result.sorted)) {
changed = true;
}
}
}
if (!changed) break;
if (!changed || bestCrossings === 0) break;
}
}
// STEP 2 — deterministic hierarchy placement. Starting from the best head
// ordering, walk left to right and order every single-parent section by the
// position of each node's single parent in the already-final previous section
// (sortSectionByBarycenter with one parent reduces to a stable sort by that
// parent's index). This is the classic layered-tree drawing: each parent's
// children stay contiguous, every single-parent boundary is crossing-free,
// pass-throughs travel along their chain, and the user-configured floor/area
// order is preserved because same-parent siblings keep their seed index.
// It runs unconditionally — grouping a parent's children under it must win
// even on a crossing-neutral plateau (the #52852 fix) — and because it is a
// pure function of the settled head, re-running yields the identical layout.
const finalSections = bestSections.map((s) => s.slice());
const finalMaps = finalSections.map(buildIdIndexMap);
for (let i = 1; i < finalSections.length; i++) {
if (multiParent[i]) {
continue;
}
const prevDepth = depths[i - 1];
const { sorted } = sortSectionByBarycenter(
finalSections[i],
finalMaps[i - 1],
(node) => getNeighborIds(node, "source", prevDepth, depths)
);
finalSections[i] = sorted;
finalMaps[i] = buildIdIndexMap(sorted);
}
// Hierarchy placement makes every single-parent boundary crossing-free, so on
// the energy/water cards (multi-parent only at the head) it can only lower the
// total. Guard the general graph: if regrouping somehow raised crossings (only
// possible for a multi-parent section sitting *below* single-parent ones,
// which the cards never produce), fall back to the gated best head so the
// never-worse-than-seed guarantee always holds. Ties keep the grouped layout.
const finalCrossings = countAllCrossings(
finalSections,
finalMaps,
depths,
edges
);
const chosen = finalCrossings <= bestCrossings ? finalSections : bestSections;
const sortedSections: Record<number, Node[]> = {};
depths.forEach((depth, i) => {
sortedSections[depth] = sections[i];
sortedSections[depth] = chosen[i];
});
return sortedSections;
}
+43 -2
View File
@@ -715,7 +715,18 @@
"retrieval_error": "Could not load activity",
"not_loaded": "[%key:ui::dialogs::helper_settings::platform_not_loaded%]",
"messages": {
"detected_event_no_type": "detected an event"
"detected_event_no_type": "Event detected",
"pressed": "Pressed",
"activated": "Activated",
"scanned": "Scanned",
"updated": "Updated",
"sent": "Sent",
"detected": "Detected",
"transcribed": "Transcribed",
"spoke": "Spoke",
"responded": "Responded",
"ran": "Ran",
"command_sent": "Command sent"
}
},
"entity": {
@@ -2492,6 +2503,7 @@
"edit_sidebar": "Edit sidebar",
"edit_subtitle": "Synced on all devices",
"migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device.",
"default": "default",
"reset_to_defaults": "Reset to defaults",
"reset_confirmation": "Are you sure you want to reset the sidebar to its default configuration? This will restore the original order and visibility of all panels."
},
@@ -5251,6 +5263,12 @@
"description": {
"picker": "Triggers when the state of an entity (or attribute) changes.",
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
},
"for_type": {
"choices": {
"duration": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::duration%]",
"template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::template%]"
}
}
},
"homeassistant": {
@@ -5281,12 +5299,24 @@
"upper_limit": "Upper limit",
"value_template": "Value template",
"type_value": "Fixed number",
"type_input": "Numeric value of another entity",
"type_input": "Value of an entity",
"description": {
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
},
"threshold_type": {
"choices": {
"value": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_value%]",
"input": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_input%]"
}
},
"for_type": {
"choices": {
"duration": "Duration",
"template": "Template"
}
}
},
"persistent_notification": {
@@ -5344,6 +5374,12 @@
"description": {
"picker": "Triggers when a template evaluates to true.",
"full": "When a template changes from false to true{hasDuration, select, \n true { for {duration}} \n other {}\n }"
},
"for_type": {
"choices": {
"duration": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::duration%]",
"template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::template%]"
}
}
},
"time": {
@@ -5440,6 +5476,10 @@
"invalid_condition": "Invalid condition configuration",
"validation_failed": "Condition validation failed",
"test_failed": "Error occurred while testing condition",
"duration_priming": {
"entity_not_recorded": "One or more of the selected entities aren''t being recorded, so their history can''t be used. After a restart or reload, this condition only becomes true once they''ve been in the matching state for the full duration.",
"history_capped": "Only the last {hours} hours of history are checked. For longer durations, after a restart or reload this condition only becomes true once the entities have been in the matching state for the full duration."
},
"duplicate": "[%key:ui::common::duplicate%]",
"re_order": "[%key:ui::panel::config::automation::editor::triggers::re_order%]",
"rename": "[%key:ui::panel::config::automation::editor::triggers::rename%]",
@@ -7164,6 +7204,7 @@
"loading_error": "Failed to load radio frequency devices",
"name": "Name",
"type": "Type",
"frequencies": "Frequencies",
"last_used": "Last used",
"devices_navigation": "Devices"
},
@@ -1,6 +1,8 @@
import { assert, describe, it } from "vitest";
import durationToSeconds from "../../../src/common/datetime/duration_to_seconds";
import durationToSeconds, {
durationDataToSeconds,
} from "../../../src/common/datetime/duration_to_seconds";
describe("durationToSeconds", () => {
it("works", () => {
@@ -8,3 +10,23 @@ describe("durationToSeconds", () => {
assert.strictEqual(durationToSeconds("11:01:05"), 39665);
});
});
describe("durationDataToSeconds", () => {
it("sums all duration fields", () => {
assert.strictEqual(
durationDataToSeconds({
days: 1,
hours: 2,
minutes: 3,
seconds: 4,
milliseconds: 500,
}),
93784.5
);
});
it("treats missing fields as zero", () => {
assert.strictEqual(durationDataToSeconds({}), 0);
assert.strictEqual(durationDataToSeconds({ hours: 6 }), 21600);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
formatFrequency,
formatFrequencyRange,
formatFrequencyRanges,
} from "../../src/data/radio_frequency";
import {
DateFormat,
FirstWeekday,
type FrontendLocaleData,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../src/data/translation";
const locale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
};
describe("formatFrequency", () => {
it("picks the largest unit that keeps the value >= 1", () => {
expect(formatFrequency(50, locale)).toBe("50 Hz");
expect(formatFrequency(2400, locale)).toBe("2.4 kHz");
expect(formatFrequency(433_920_000, locale)).toBe("433.92 MHz");
expect(formatFrequency(2_400_000_000, locale)).toBe("2.4 GHz");
});
it("rounds to at most three fractional digits", () => {
expect(formatFrequency(868_300_000, locale)).toBe("868.3 MHz");
expect(formatFrequency(123_456_789, locale)).toBe("123.457 MHz");
});
});
describe("formatFrequencyRange", () => {
it("collapses a range to a single value when min equals max", () => {
expect(formatFrequencyRange([433_920_000, 433_920_000], locale)).toBe(
"433.92 MHz"
);
});
it("formats a range with both bounds", () => {
expect(formatFrequencyRange([863_000_000, 870_000_000], locale)).toBe(
"863 MHz 870 MHz"
);
});
});
describe("formatFrequencyRanges", () => {
it("joins multiple ranges with commas", () => {
expect(
formatFrequencyRanges(
[
[433_920_000, 433_920_000],
[868_000_000, 870_000_000],
],
locale
)
).toBe("433.92 MHz, 868 MHz 870 MHz");
});
it("returns an empty string for no ranges", () => {
expect(formatFrequencyRanges([], locale)).toBe("");
});
});
@@ -12,6 +12,7 @@ import {
getPassThroughSections,
createPassThroughNode,
computeBarycenter,
dominantNeighborIndex,
sortNodesInSections,
} from "../../../../../src/resources/echarts/components/sankey/sankey-layout";
@@ -756,5 +757,470 @@ describe("Sankey Layout Functions", () => {
});
expectIdentityPreserved(result, input);
});
it("untangles a plateau-trapped subtree to remove an avoidable crossing (#52852)", () => {
// Realistic consumption tree: home → floors → areas → devices, plus two
// devices that attach higher up — one on a floor with no area
// (dev_floor_outside) and one straight on home (dev_home) — which the
// engine threads through with pass-throughs. The seed splits
// floor_outside's subtree: its pass-through child sits *after*
// floor_foundation's area. Pulling it back trades a crossing from the
// (1,2) boundary to the (2,3) boundary — a net-zero "plateau" the old
// strict gate refused, leaving the crossing. The plateau-escape must now
// take that step and let the device section follow, reaching 0 crossings.
const e = {
homeFo: { source: "home", target: "floor_outside", value: 1 },
homeFf: { source: "home", target: "floor_foundation", value: 1 },
foHvac: { source: "floor_outside", target: "area_hvac", value: 1 },
ffParking: {
source: "floor_foundation",
target: "area_parking",
value: 1,
},
hvacDev: { source: "area_hvac", target: "dev_hvac", value: 1 },
parkingDev: { source: "area_parking", target: "dev_parking", value: 1 },
foDev: {
source: "floor_outside",
target: "dev_floor_outside",
value: 1,
},
homeDev: { source: "home", target: "dev_home", value: 1 },
};
const testNodes: Record<string, TestNode> = {
home: {
id: "home",
depth: 0,
value: 4,
inEdges: [],
outEdges: [e.homeFo, e.homeFf, e.homeDev],
},
floor_outside: {
id: "floor_outside",
depth: 1,
value: 2,
inEdges: [e.homeFo],
outEdges: [e.foHvac, e.foDev],
},
floor_foundation: {
id: "floor_foundation",
depth: 1,
value: 1,
inEdges: [e.homeFf],
outEdges: [e.ffParking],
},
area_hvac: {
id: "area_hvac",
depth: 2,
value: 1,
inEdges: [e.foHvac],
outEdges: [e.hvacDev],
},
area_parking: {
id: "area_parking",
depth: 2,
value: 1,
inEdges: [e.ffParking],
outEdges: [e.parkingDev],
},
dev_hvac: {
id: "dev_hvac",
depth: 3,
value: 1,
inEdges: [e.hvacDev],
outEdges: [],
},
dev_parking: {
id: "dev_parking",
depth: 3,
value: 1,
inEdges: [e.parkingDev],
outEdges: [],
},
dev_floor_outside: {
id: "dev_floor_outside",
depth: 3,
value: 1,
inEdges: [e.foDev],
outEdges: [],
},
dev_home: {
id: "dev_home",
depth: 3,
value: 1,
inEdges: [e.homeDev],
outEdges: [],
},
};
const { nodes: graph, edges } = buildGraph(testNodes);
const ptHome1 = createPassThroughNode("home", "dev_home", 1, 1);
const ptFo2 = createPassThroughNode(
"floor_outside",
"dev_floor_outside",
2,
1
);
const ptHome2 = createPassThroughNode("home", "dev_home", 2, 1);
// Seed order from ha-sankey-chart: pass-throughs appended after the real
// children, so floor_outside's subtree is broken across the section.
const input = {
0: [graph.home],
1: [graph.floor_outside, graph.floor_foundation, ptHome1],
2: [graph.area_hvac, graph.area_parking, ptFo2, ptHome2],
3: [
graph.dev_hvac,
graph.dev_parking,
graph.dev_floor_outside,
graph.dev_home,
],
};
const result = sortNodesInSections(input, [0, 1, 2, 3], edges);
// floor_outside's children (area_hvac and its pass-through) are now
// contiguous, ahead of floor_foundation's; the layout is crossing-free.
expect(sectionIds(result)).toEqual({
0: ["home"],
1: ["floor_outside", "floor_foundation", "home-dev_home-1"],
2: [
"area_hvac",
"floor_outside-dev_floor_outside-2",
"area_parking",
"home-dev_home-2",
],
3: ["dev_hvac", "dev_floor_outside", "dev_parking", "dev_home"],
});
expectIdentityPreserved(result, input);
// Re-running on the result must not drift: plateau churn is discarded and
// the best snapshot is returned, so the order is stable (idempotent).
const again = sortNodesInSections(result, [0, 1, 2, 3], edges);
expect(sectionIds(again)).toEqual(sectionIds(result));
});
it("groups single-parent siblings under their parent and keeps configured sibling order", () => {
// Two floors, two areas each, fed to the engine interleaved (not grouped
// by floor). The deterministic hierarchy pass must regroup areas under
// their floor, and within a floor preserve the configured (seed) order:
// a1 before a2, b1 before b2.
const e = {
hFa: { source: "home", target: "floor_a", value: 2 },
hFb: { source: "home", target: "floor_b", value: 2 },
faA1: { source: "floor_a", target: "a1", value: 1 },
faA2: { source: "floor_a", target: "a2", value: 1 },
fbB1: { source: "floor_b", target: "b1", value: 1 },
fbB2: { source: "floor_b", target: "b2", value: 1 },
};
const testNodes: Record<string, TestNode> = {
home: {
id: "home",
depth: 0,
value: 4,
inEdges: [],
outEdges: [e.hFa, e.hFb],
},
floor_a: {
id: "floor_a",
depth: 1,
value: 2,
inEdges: [e.hFa],
outEdges: [e.faA1, e.faA2],
},
floor_b: {
id: "floor_b",
depth: 1,
value: 2,
inEdges: [e.hFb],
outEdges: [e.fbB1, e.fbB2],
},
a1: { id: "a1", depth: 2, value: 1, inEdges: [e.faA1], outEdges: [] },
a2: { id: "a2", depth: 2, value: 1, inEdges: [e.faA2], outEdges: [] },
b1: { id: "b1", depth: 2, value: 1, inEdges: [e.fbB1], outEdges: [] },
b2: { id: "b2", depth: 2, value: 1, inEdges: [e.fbB2], outEdges: [] },
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = {
0: [graph.home],
1: [graph.floor_a, graph.floor_b],
2: [graph.a1, graph.b1, graph.a2, graph.b2], // interleaved
};
const result = sortNodesInSections(input, [0, 1, 2], edges);
expect(sectionIds(result)).toEqual({
0: ["home"],
1: ["floor_a", "floor_b"],
2: ["a1", "a2", "b1", "b2"],
});
expectIdentityPreserved(result, input);
});
it("orders a single-parent section by parent position, ignoring flow magnitude", () => {
// childB carries a far larger flow than childA, but a single-parent
// section is ordered by parent position (hierarchy), never by value.
const edgeACa = { source: "A", target: "childA", value: 1 };
const edgeBCb = { source: "B", target: "childB", value: 100 };
const testNodes: Record<string, TestNode> = {
A: { id: "A", depth: 0, value: 1, inEdges: [], outEdges: [edgeACa] },
B: { id: "B", depth: 0, value: 100, inEdges: [], outEdges: [edgeBCb] },
childA: {
id: "childA",
depth: 1,
value: 1,
inEdges: [edgeACa],
outEdges: [],
},
childB: {
id: "childB",
depth: 1,
value: 100,
inEdges: [edgeBCb],
outEdges: [],
},
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = { 0: [graph.A, graph.B], 1: [graph.childB, graph.childA] };
const result = sortNodesInSections(input, [0, 1], edges);
expect(sectionIds(result)).toEqual({
0: ["A", "B"],
1: ["childA", "childB"],
});
expectIdentityPreserved(result, input);
});
it("keeps the single-parent tree hierarchical below a multi-parent source layer", () => {
// grid + solar feed home (a genuine multi-parent section that stays under
// the barycenter sweep); the floor/area tree below is single-parent and
// must regroup by parent regardless of the multi-parent head.
const e = {
gH: { source: "grid", target: "home", value: 2 },
sH: { source: "solar", target: "home", value: 2 },
hFa: { source: "home", target: "floor_a", value: 2 },
hFb: { source: "home", target: "floor_b", value: 2 },
faA: { source: "floor_a", target: "area_a", value: 2 },
fbB: { source: "floor_b", target: "area_b", value: 2 },
};
const testNodes: Record<string, TestNode> = {
grid: { id: "grid", depth: 0, value: 2, inEdges: [], outEdges: [e.gH] },
solar: {
id: "solar",
depth: 0,
value: 2,
inEdges: [],
outEdges: [e.sH],
},
home: {
id: "home",
depth: 1,
value: 4,
inEdges: [e.gH, e.sH],
outEdges: [e.hFa, e.hFb],
},
floor_a: {
id: "floor_a",
depth: 2,
value: 2,
inEdges: [e.hFa],
outEdges: [e.faA],
},
floor_b: {
id: "floor_b",
depth: 2,
value: 2,
inEdges: [e.hFb],
outEdges: [e.fbB],
},
area_a: {
id: "area_a",
depth: 3,
value: 2,
inEdges: [e.faA],
outEdges: [],
},
area_b: {
id: "area_b",
depth: 3,
value: 2,
inEdges: [e.fbB],
outEdges: [],
},
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = {
0: [graph.grid, graph.solar],
1: [graph.home],
2: [graph.floor_a, graph.floor_b],
3: [graph.area_b, graph.area_a], // reversed; must regroup under floors
};
const result = sortNodesInSections(input, [0, 1, 2, 3], edges);
expect(sectionIds(result)).toEqual({
0: ["grid", "solar"],
1: ["home"],
2: ["floor_a", "floor_b"],
3: ["area_a", "area_b"],
});
expectIdentityPreserved(result, input);
});
it("regroups single-parent children after a multi-parent head is reordered (idempotent)", () => {
// A multi-parent head section [H0,H1,H2] (only H1 draws from two sources)
// gets reordered by the barycenter sweep. Its single-parent children must
// then be regrouped under the *settled* head — H1's children before H2's
// child — and the result must be idempotent. Before the head/tree passes
// were ordered correctly the children stayed grouped against the head's
// SEED order, which both mis-grouped them and broke f(f(x)) === f(x).
const e = {
s0h0: { source: "S0", target: "H0", value: 6 },
s0h1: { source: "S0", target: "H1", value: 7 },
s1h1: { source: "S1", target: "H1", value: 5 },
s2h2: { source: "S2", target: "H2", value: 6 },
h1d0: { source: "H1", target: "D0", value: 7 },
h1d2: { source: "H1", target: "D2", value: 9 },
h2d1: { source: "H2", target: "D1", value: 7 },
};
const testNodes: Record<string, TestNode> = {
S0: {
id: "S0",
depth: 0,
value: 13,
inEdges: [],
outEdges: [e.s0h0, e.s0h1],
},
S1: { id: "S1", depth: 0, value: 5, inEdges: [], outEdges: [e.s1h1] },
S2: { id: "S2", depth: 0, value: 6, inEdges: [], outEdges: [e.s2h2] },
H0: { id: "H0", depth: 1, value: 6, inEdges: [e.s0h0], outEdges: [] },
H1: {
id: "H1",
depth: 1,
value: 12,
inEdges: [e.s1h1, e.s0h1],
outEdges: [e.h1d0, e.h1d2],
},
H2: {
id: "H2",
depth: 1,
value: 6,
inEdges: [e.s2h2],
outEdges: [e.h2d1],
},
D0: { id: "D0", depth: 2, value: 7, inEdges: [e.h1d0], outEdges: [] },
D1: { id: "D1", depth: 2, value: 7, inEdges: [e.h2d1], outEdges: [] },
D2: { id: "D2", depth: 2, value: 9, inEdges: [e.h1d2], outEdges: [] },
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = {
0: [graph.S0, graph.S1, graph.S2],
1: [graph.H2, graph.H1, graph.H0],
2: [graph.D1, graph.D2, graph.D0],
};
const result = sortNodesInSections(input, [0, 1, 2], edges);
// Head settles to barycenter order [H0,H1,H2]; children regroup under it:
// H1's children (D2,D0) precede H2's child (D1).
expect(sectionIds(result)).toEqual({
0: ["S0", "S1", "S2"],
1: ["H0", "H1", "H2"],
2: ["D2", "D0", "D1"],
});
expectIdentityPreserved(result, input);
// Idempotent: re-feeding the output yields the identical order.
const again = sortNodesInSections(result, [0, 1, 2], edges);
expect(sectionIds(again)).toEqual(sectionIds(result));
});
it("lets single-parent children follow their reordered multi-parent parent to reach 0 crossings", () => {
// root [a,b,c,d]; section 1 [m1,m2] is multi-parent with a clean split
// (c,d -> m1 ; a,b -> m2); section 2 [x<-m1, y<-m2] single-parent. The
// optimum needs BOTH the parent order swapped (m2,m1) AND the children to
// follow (y,x) — placing the tree after the head settles reaches it.
const e = {
am2: { source: "a", target: "m2", value: 1 },
bm2: { source: "b", target: "m2", value: 1 },
cm1: { source: "c", target: "m1", value: 1 },
dm1: { source: "d", target: "m1", value: 1 },
m1x: { source: "m1", target: "x", value: 2 },
m2y: { source: "m2", target: "y", value: 2 },
};
const testNodes: Record<string, TestNode> = {
a: { id: "a", depth: 0, value: 1, inEdges: [], outEdges: [e.am2] },
b: { id: "b", depth: 0, value: 1, inEdges: [], outEdges: [e.bm2] },
c: { id: "c", depth: 0, value: 1, inEdges: [], outEdges: [e.cm1] },
d: { id: "d", depth: 0, value: 1, inEdges: [], outEdges: [e.dm1] },
m1: {
id: "m1",
depth: 1,
value: 2,
inEdges: [e.cm1, e.dm1],
outEdges: [e.m1x],
},
m2: {
id: "m2",
depth: 1,
value: 2,
inEdges: [e.am2, e.bm2],
outEdges: [e.m2y],
},
x: { id: "x", depth: 2, value: 2, inEdges: [e.m1x], outEdges: [] },
y: { id: "y", depth: 2, value: 2, inEdges: [e.m2y], outEdges: [] },
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = {
0: [graph.a, graph.b, graph.c, graph.d],
1: [graph.m1, graph.m2],
2: [graph.x, graph.y],
};
const result = sortNodesInSections(input, [0, 1, 2], edges);
expect(sectionIds(result)).toEqual({
0: ["a", "b", "c", "d"],
1: ["m2", "m1"],
2: ["y", "x"],
});
expectIdentityPreserved(result, input);
});
});
describe("dominantNeighborIndex", () => {
it("returns the index of the single heaviest neighbor", () => {
const map = new Map([
["light", 0],
["heavy", 3],
]);
const result = dominantNeighborIndex(
[
{ id: "light", weight: 1 },
{ id: "heavy", weight: 5 },
],
map,
9
);
expect(result).toBe(3);
});
it("breaks weight ties by the earliest edge", () => {
const map = new Map([
["a", 2],
["b", 4],
]);
const result = dominantNeighborIndex(
[
{ id: "a", weight: 1 },
{ id: "b", weight: 1 },
],
map,
9
);
expect(result).toBe(2);
});
it("falls back when no neighbor is in the reference section", () => {
const result = dominantNeighborIndex(
[{ id: "missing", weight: 1 }],
new Map(),
7
);
expect(result).toBe(7);
});
});
});