mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-26 02:02:39 +00:00
Compare commits
12 Commits
dev
...
20260624.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 43ff58010a | |||
| c391d571d7 | |||
| 18e15f8a99 | |||
| dfdd55b649 | |||
| bed98776c3 | |||
| ad37f1bb58 | |||
| 2a00b0d0ec | |||
| 20efc35da3 | |||
| ac71b4c400 | |||
| b85422e652 | |||
| 4ff69aab8f | |||
| ebb15d1118 |
@@ -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,
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"config/scene/config",
|
||||
"search/related",
|
||||
"tag/list",
|
||||
"assist_pipeline/",
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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(", ");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+226
-264
@@ -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;
|
||||
|
||||
+20
-3
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user