mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-18 15:30:24 +00:00
Compare commits
31 Commits
calendar-s
...
add-device
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9331282521 | ||
|
|
9299b84708 | ||
|
|
df7a36e743 | ||
|
|
5786fe4b8d | ||
|
|
6fa274e4bf | ||
|
|
1bd1e015ff | ||
|
|
7588490419 | ||
|
|
2e80a3ddab | ||
|
|
332694549c | ||
|
|
396ddef722 | ||
|
|
d02804449a | ||
|
|
4ab24cdc72 | ||
|
|
81c27090d2 | ||
|
|
09bdfd3ad7 | ||
|
|
97e49f751c | ||
|
|
e0d241a2db | ||
|
|
83e065ae98 | ||
|
|
a6ee670682 | ||
|
|
c457f92826 | ||
|
|
c73bd96a1f | ||
|
|
711f8e2fc3 | ||
|
|
91a0066544 | ||
|
|
aee7b8b8d4 | ||
|
|
d38d770e1a | ||
|
|
0036679553 | ||
|
|
2b85108242 | ||
|
|
c74320cb82 | ||
|
|
8ebe6e24d2 | ||
|
|
d3182da587 | ||
|
|
41fbc5e44b | ||
|
|
3fea41eb0e |
@@ -260,7 +260,6 @@ const createRspackConfig = ({
|
||||
),
|
||||
},
|
||||
experiments: {
|
||||
layers: true,
|
||||
outputModule: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-yaml-editor";
|
||||
import type { Trigger } from "../../../../src/data/automation";
|
||||
import type { LegacyTrigger } from "../../../../src/data/automation";
|
||||
import { describeTrigger } from "../../../../src/data/automation_i18n";
|
||||
import { getEntity } from "../../../../src/fake_data/entity";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
@@ -66,7 +66,7 @@ const triggers = [
|
||||
},
|
||||
];
|
||||
|
||||
const initialTrigger: Trigger = {
|
||||
const initialTrigger: LegacyTrigger = {
|
||||
trigger: "state",
|
||||
entity_id: "light.kitchen",
|
||||
};
|
||||
|
||||
55
gallery/src/pages/components/ha-dropdown.markdown
Normal file
55
gallery/src/pages/components/ha-dropdown.markdown
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: Dropdown
|
||||
---
|
||||
|
||||
# Dropdown `<ha-dropdown>`
|
||||
|
||||
## Implementation
|
||||
|
||||
A compact, accessible dropdown menu for choosing actions or settings. `ha-dropdown` supports composed menu items (`<ha-dropdown-item>`) for icons, submenus, checkboxes, disabled entries, and destructive variants. Use composition with `slot="trigger"` to control the trigger button and use `<ha-dropdown-item>` for rich item content.
|
||||
|
||||
### Example usage (composition)
|
||||
|
||||
```html
|
||||
<ha-dropdown open>
|
||||
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
|
||||
|
||||
<ha-dropdown-item>
|
||||
<ha-svg-icon .path="mdiContentCut" slot="icon"></ha-svg-icon>
|
||||
Cut
|
||||
</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item>
|
||||
<ha-svg-icon .path="mdiContentCopy" slot="icon"></ha-svg-icon>
|
||||
Copy
|
||||
</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item disabled>
|
||||
<ha-svg-icon .path="mdiContentPaste" slot="icon"></ha-svg-icon>
|
||||
Paste
|
||||
</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item>
|
||||
Show images
|
||||
<ha-dropdown-item slot="submenu" value="show-all-images"
|
||||
>Show all images</ha-dropdown-item
|
||||
>
|
||||
<ha-dropdown-item slot="submenu" value="show-thumbnails"
|
||||
>Show thumbnails</ha-dropdown-item
|
||||
>
|
||||
</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item type="checkbox" checked>Emoji shortcuts</ha-dropdown-item>
|
||||
<ha-dropdown-item type="checkbox" checked>Word wrap</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item variant="danger">
|
||||
<ha-svg-icon .path="mdiDelete" slot="icon"></ha-svg-icon>
|
||||
Delete
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
This component is based on the webawesome dropdown component.
|
||||
Check the [webawesome documentation](https://webawesome.com/docs/components/dropdown/) for more details.
|
||||
133
gallery/src/pages/components/ha-dropdown.ts
Normal file
133
gallery/src/pages/components/ha-dropdown.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import "@home-assistant/webawesome/dist/components/button/button";
|
||||
import "@home-assistant/webawesome/dist/components/dropdown/dropdown";
|
||||
import "@home-assistant/webawesome/dist/components/icon/icon";
|
||||
import "@home-assistant/webawesome/dist/components/popup/popup";
|
||||
import {
|
||||
mdiContentCopy,
|
||||
mdiContentCut,
|
||||
mdiContentPaste,
|
||||
mdiDelete,
|
||||
} from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-dropdown";
|
||||
import "../../../../src/components/ha-dropdown-item";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
|
||||
@customElement("demo-components-ha-dropdown")
|
||||
export class DemoHaDropdown extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-button in ${mode}">
|
||||
<div class="card-content">
|
||||
<ha-dropdown open>
|
||||
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
|
||||
|
||||
<ha-dropdown-item>
|
||||
<ha-svg-icon
|
||||
.path=${mdiContentCut}
|
||||
slot="icon"
|
||||
></ha-svg-icon>
|
||||
Cut
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item>
|
||||
<ha-svg-icon
|
||||
.path=${mdiContentCopy}
|
||||
slot="icon"
|
||||
></ha-svg-icon>
|
||||
Copy
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item disabled>
|
||||
<ha-svg-icon
|
||||
.path=${mdiContentPaste}
|
||||
slot="icon"
|
||||
></ha-svg-icon>
|
||||
Paste
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item>
|
||||
Show images
|
||||
<ha-dropdown-item slot="submenu" value="show-all-images"
|
||||
>Show All Images</ha-dropdown-item
|
||||
>
|
||||
<ha-dropdown-item slot="submenu" value="show-thumbnails"
|
||||
>Show Thumbnails</ha-dropdown-item
|
||||
>
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item type="checkbox" checked
|
||||
>Emoji Shortcuts</ha-dropdown-item
|
||||
>
|
||||
<ha-dropdown-item type="checkbox" checked
|
||||
>Word Wrap</ha-dropdown-item
|
||||
>
|
||||
<ha-dropdown-item variant="danger">
|
||||
<ha-svg-icon .path=${mdiDelete} slot="icon"></ha-svg-icon>
|
||||
Delete
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 50px;
|
||||
}
|
||||
.button {
|
||||
padding: unset;
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.card-content div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-dropdown": DemoHaDropdown;
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
"@fullcalendar/list": "6.1.19",
|
||||
"@fullcalendar/luxon3": "6.1.19",
|
||||
"@fullcalendar/timegrid": "6.1.19",
|
||||
"@home-assistant/webawesome": "3.0.0-beta.6.ha.7",
|
||||
"@home-assistant/webawesome": "3.0.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.0.9",
|
||||
"@lit-labs/observers": "2.0.6",
|
||||
@@ -122,7 +122,7 @@
|
||||
"lit": "3.3.1",
|
||||
"lit-html": "3.3.1",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "16.4.2",
|
||||
"marked": "17.0.0",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.3",
|
||||
"object-hash": "3.0.0",
|
||||
|
||||
36
src/common/condition/extract.ts
Normal file
36
src/common/condition/extract.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
Condition,
|
||||
TimeCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
|
||||
/**
|
||||
* Extract media queries from conditions recursively
|
||||
*/
|
||||
export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
return conditions.reduce<string[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractMediaQueries(c.conditions));
|
||||
}
|
||||
if (c.condition === "screen" && c.media_query) {
|
||||
array.push(c.media_query);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract time conditions from conditions recursively
|
||||
*/
|
||||
export function extractTimeConditions(
|
||||
conditions: Condition[]
|
||||
): TimeCondition[] {
|
||||
return conditions.reduce<TimeCondition[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractTimeConditions(c.conditions));
|
||||
}
|
||||
if (c.condition === "time") {
|
||||
array.push(c);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
}
|
||||
89
src/common/condition/listeners.ts
Normal file
89
src/common/condition/listeners.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { listenMediaQuery } from "../dom/media_query";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Condition } from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import { extractMediaQueries, extractTimeConditions } from "./extract";
|
||||
import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
|
||||
/** Maximum delay for setTimeout (2^31 - 1 milliseconds, ~24.8 days)
|
||||
* Values exceeding this will overflow and execute immediately
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value
|
||||
*/
|
||||
const MAX_TIMEOUT_DELAY = 2147483647;
|
||||
|
||||
/**
|
||||
* Helper to setup media query listeners for conditional visibility
|
||||
*/
|
||||
export function setupMediaQueryListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void
|
||||
): void {
|
||||
const mediaQueries = extractMediaQueries(conditions);
|
||||
|
||||
if (mediaQueries.length === 0) return;
|
||||
|
||||
// Optimization for single media query
|
||||
const hasOnlyMediaQuery =
|
||||
conditions.length === 1 &&
|
||||
conditions[0].condition === "screen" &&
|
||||
!!conditions[0].media_query;
|
||||
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
const unsub = listenMediaQuery(mediaQuery, (matches) => {
|
||||
if (hasOnlyMediaQuery) {
|
||||
onUpdate(matches);
|
||||
} else {
|
||||
const conditionsMet = checkConditionsMet(conditions, hass);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
});
|
||||
addListener(unsub);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to setup time-based listeners for conditional visibility
|
||||
*/
|
||||
export function setupTimeListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void
|
||||
): void {
|
||||
const timeConditions = extractTimeConditions(conditions);
|
||||
|
||||
if (timeConditions.length === 0) return;
|
||||
|
||||
timeConditions.forEach((timeCondition) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
const delay = calculateNextTimeUpdate(hass, timeCondition);
|
||||
|
||||
if (delay === undefined) return;
|
||||
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
const conditionsMet = checkConditionsMet(conditions, hass);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
});
|
||||
}
|
||||
73
src/common/condition/time-calculator.ts
Normal file
73
src/common/condition/time-calculator.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import {
|
||||
startOfDay,
|
||||
addDays,
|
||||
addMinutes,
|
||||
differenceInMilliseconds,
|
||||
} from "date-fns";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
import { parseTimeString } from "../datetime/check_time";
|
||||
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
|
||||
|
||||
/**
|
||||
* Calculate milliseconds until next time boundary for a time condition
|
||||
* @param hass Home Assistant object
|
||||
* @param timeCondition Time condition to calculate next update for
|
||||
* @returns Milliseconds until next boundary, or undefined if no boundaries
|
||||
*/
|
||||
export function calculateNextTimeUpdate(
|
||||
hass: HomeAssistant,
|
||||
{ after, before, weekdays }: Omit<TimeCondition, "condition">
|
||||
): number | undefined {
|
||||
const timezone =
|
||||
hass.locale.time_zone === TimeZone.server
|
||||
? hass.config.time_zone
|
||||
: Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const now = new TZDate(new Date(), timezone);
|
||||
const updates: Date[] = [];
|
||||
|
||||
// Calculate next occurrence of after time
|
||||
if (after) {
|
||||
let afterDate = parseTimeString(after, timezone);
|
||||
if (afterDate <= now) {
|
||||
// If time has passed today, schedule for tomorrow
|
||||
afterDate = addDays(afterDate, 1);
|
||||
}
|
||||
updates.push(afterDate);
|
||||
}
|
||||
|
||||
// Calculate next occurrence of before time
|
||||
if (before) {
|
||||
let beforeDate = parseTimeString(before, timezone);
|
||||
if (beforeDate <= now) {
|
||||
// If time has passed today, schedule for tomorrow
|
||||
beforeDate = addDays(beforeDate, 1);
|
||||
}
|
||||
updates.push(beforeDate);
|
||||
}
|
||||
|
||||
// If weekdays are specified, check for midnight (weekday transition)
|
||||
if (weekdays && weekdays.length > 0 && weekdays.length < 7) {
|
||||
// Calculate next midnight using startOfDay + addDays
|
||||
const tomorrow = addDays(now, 1);
|
||||
const midnight = startOfDay(tomorrow);
|
||||
updates.push(midnight);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find the soonest update time
|
||||
const nextUpdate = updates.reduce((soonest, current) =>
|
||||
current < soonest ? current : soonest
|
||||
);
|
||||
|
||||
// Add 1 minute buffer to ensure we're past the boundary
|
||||
const updateWithBuffer = addMinutes(nextUpdate, 1);
|
||||
|
||||
// Calculate difference in milliseconds
|
||||
return differenceInMilliseconds(updateWithBuffer, now);
|
||||
}
|
||||
131
src/common/datetime/check_time.ts
Normal file
131
src/common/datetime/check_time.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import { isBefore, isAfter, isWithinInterval } from "date-fns";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
import { WEEKDAY_MAP } from "./weekday";
|
||||
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
|
||||
|
||||
/**
|
||||
* Validate a time string format and value ranges without creating Date objects
|
||||
* @param timeString Time string to validate (HH:MM or HH:MM:SS)
|
||||
* @returns true if valid, false otherwise
|
||||
*/
|
||||
export function isValidTimeString(timeString: string): boolean {
|
||||
// Reject empty strings
|
||||
if (!timeString || timeString.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = timeString.split(":");
|
||||
|
||||
if (parts.length < 2 || parts.length > 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure each part contains only digits (and optional leading zeros)
|
||||
// This prevents "8:00 AM" from passing validation
|
||||
if (!parts.every((part) => /^\d+$/.test(part))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hours = parseInt(parts[0], 10);
|
||||
const minutes = parseInt(parts[1], 10);
|
||||
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
|
||||
|
||||
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
hours >= 0 &&
|
||||
hours <= 23 &&
|
||||
minutes >= 0 &&
|
||||
minutes <= 59 &&
|
||||
seconds >= 0 &&
|
||||
seconds <= 59
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a time string (HH:MM or HH:MM:SS) and set it on today's date in the given timezone
|
||||
*
|
||||
* Note: This function assumes the time string has already been validated by
|
||||
* isValidTimeString() at configuration time. It does not re-validate at runtime
|
||||
* for consistency with other condition types (screen, user, location, etc.)
|
||||
*
|
||||
* @param timeString The time string to parse (must be pre-validated)
|
||||
* @param timezone The timezone to use
|
||||
* @returns The Date object
|
||||
*/
|
||||
export const parseTimeString = (timeString: string, timezone: string): Date => {
|
||||
const parts = timeString.split(":");
|
||||
const hours = parseInt(parts[0], 10);
|
||||
const minutes = parseInt(parts[1], 10);
|
||||
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
|
||||
|
||||
const now = new TZDate(new Date(), timezone);
|
||||
const dateWithTime = new TZDate(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
0,
|
||||
timezone
|
||||
);
|
||||
|
||||
return new Date(dateWithTime.getTime());
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the current time matches the time condition (after/before/weekday)
|
||||
* @param hass Home Assistant object
|
||||
* @param timeCondition Time condition to check
|
||||
* @returns true if current time matches the condition
|
||||
*/
|
||||
export const checkTimeInRange = (
|
||||
hass: HomeAssistant,
|
||||
{ after, before, weekdays }: Omit<TimeCondition, "condition">
|
||||
): boolean => {
|
||||
const timezone =
|
||||
hass.locale.time_zone === TimeZone.server
|
||||
? hass.config.time_zone
|
||||
: Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const now = new TZDate(new Date(), timezone);
|
||||
|
||||
// Check weekday condition
|
||||
if (weekdays && weekdays.length > 0) {
|
||||
const currentWeekday = WEEKDAY_MAP[now.getDay()];
|
||||
if (!weekdays.includes(currentWeekday)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check time conditions
|
||||
if (!after && !before) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const afterDate = after ? parseTimeString(after, timezone) : undefined;
|
||||
const beforeDate = before ? parseTimeString(before, timezone) : undefined;
|
||||
|
||||
if (afterDate && beforeDate) {
|
||||
if (isBefore(beforeDate, afterDate)) {
|
||||
// Crosses midnight (e.g., 22:00 to 06:00)
|
||||
return !isBefore(now, afterDate) || !isAfter(now, beforeDate);
|
||||
}
|
||||
return isWithinInterval(now, { start: afterDate, end: beforeDate });
|
||||
}
|
||||
|
||||
if (afterDate) {
|
||||
return !isBefore(now, afterDate);
|
||||
}
|
||||
|
||||
if (beforeDate) {
|
||||
return !isAfter(now, beforeDate);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,18 +1,7 @@
|
||||
import { getWeekStartByLocale } from "weekstart";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { FirstWeekday } from "../../data/translation";
|
||||
|
||||
export const weekdays = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
] as const;
|
||||
|
||||
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
|
||||
|
||||
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
|
||||
if (locale.first_weekday === FirstWeekday.language) {
|
||||
@@ -23,12 +12,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
|
||||
}
|
||||
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
|
||||
}
|
||||
return weekdays.includes(locale.first_weekday)
|
||||
? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex)
|
||||
return WEEKDAYS_LONG.includes(locale.first_weekday)
|
||||
? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex)
|
||||
: 1;
|
||||
};
|
||||
|
||||
export const firstWeekday = (locale: FrontendLocaleData) => {
|
||||
const index = firstWeekdayIndex(locale);
|
||||
return weekdays[index];
|
||||
return WEEKDAYS_LONG[index];
|
||||
};
|
||||
|
||||
59
src/common/datetime/weekday.ts
Normal file
59
src/common/datetime/weekday.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export type WeekdayShort =
|
||||
| "sun"
|
||||
| "mon"
|
||||
| "tue"
|
||||
| "wed"
|
||||
| "thu"
|
||||
| "fri"
|
||||
| "sat";
|
||||
|
||||
export type WeekdayLong =
|
||||
| "sunday"
|
||||
| "monday"
|
||||
| "tuesday"
|
||||
| "wednesday"
|
||||
| "thursday"
|
||||
| "friday"
|
||||
| "saturday";
|
||||
|
||||
export const WEEKDAYS_SHORT = [
|
||||
"sun",
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
] as const satisfies readonly WeekdayShort[];
|
||||
|
||||
export const WEEKDAYS_LONG = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
] as const satisfies readonly WeekdayLong[];
|
||||
|
||||
export const WEEKDAY_MAP = {
|
||||
0: "sun",
|
||||
1: "mon",
|
||||
2: "tue",
|
||||
3: "wed",
|
||||
4: "thu",
|
||||
5: "fri",
|
||||
6: "sat",
|
||||
} as const satisfies Record<WeekdayIndex, WeekdayShort>;
|
||||
|
||||
export const WEEKDAY_SHORT_TO_LONG = {
|
||||
sun: "sunday",
|
||||
mon: "monday",
|
||||
tue: "tuesday",
|
||||
wed: "wednesday",
|
||||
thu: "thursday",
|
||||
fri: "friday",
|
||||
sat: "saturday",
|
||||
} as const satisfies Record<WeekdayShort, WeekdayLong>;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ThemeVars } from "../../data/ws-themes";
|
||||
import { darkColorVariables } from "../../resources/theme/color";
|
||||
import { darkSemanticVariables } from "../../resources/theme/semantic.globals";
|
||||
import { derivedStyles } from "../../resources/theme/theme";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import {
|
||||
@@ -52,7 +53,7 @@ export const applyThemesOnElement = (
|
||||
|
||||
if (themeToApply && darkMode) {
|
||||
cacheKey = `${cacheKey}__dark`;
|
||||
themeRules = { ...darkColorVariables };
|
||||
themeRules = { ...darkSemanticVariables, ...darkColorVariables };
|
||||
}
|
||||
|
||||
if (themeToApply === "default") {
|
||||
|
||||
36
src/common/util/parse-animation-duration.ts
Normal file
36
src/common/util/parse-animation-duration.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Parses a CSS duration string (e.g., "300ms", "3s") and returns the duration in milliseconds.
|
||||
*
|
||||
* @param duration - A CSS duration string (e.g., "300ms", "3s", "0.5s")
|
||||
* @returns The duration in milliseconds, or 0 if the input is invalid
|
||||
*
|
||||
* @example
|
||||
* parseAnimationDuration("300ms") // Returns 300
|
||||
* parseAnimationDuration("3s") // Returns 3000
|
||||
* parseAnimationDuration("0.5s") // Returns 500
|
||||
* parseAnimationDuration("invalid") // Returns 0
|
||||
*/
|
||||
export const parseAnimationDuration = (duration: string): number => {
|
||||
const trimmed = duration.trim();
|
||||
|
||||
let value: number;
|
||||
let multiplier: number;
|
||||
|
||||
if (trimmed.endsWith("ms")) {
|
||||
value = parseFloat(trimmed.slice(0, -2));
|
||||
multiplier = 1;
|
||||
} else if (trimmed.endsWith("s")) {
|
||||
value = parseFloat(trimmed.slice(0, -1));
|
||||
multiplier = 1000;
|
||||
} else {
|
||||
// No recognized unit, try parsing as number (assume ms)
|
||||
value = parseFloat(trimmed);
|
||||
multiplier = 1;
|
||||
}
|
||||
|
||||
if (!isFinite(value) || value < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return value * multiplier;
|
||||
};
|
||||
30
src/common/util/view-transition.ts
Normal file
30
src/common/util/view-transition.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Executes a callback within a View Transition if supported, otherwise runs it directly.
|
||||
*
|
||||
* @param callback - Function to execute. Can be synchronous or return a Promise. The callback will be passed a boolean indicating whether the view transition is available.
|
||||
* @returns Promise that resolves when the transition completes (or immediately if not supported)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Synchronous callback
|
||||
* withViewTransition(() => {
|
||||
* this.large = !this.large;
|
||||
* });
|
||||
*
|
||||
* // Async callback
|
||||
* await withViewTransition(async () => {
|
||||
* await this.updateData();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const withViewTransition = (
|
||||
callback: (viewTransitionAvailable: boolean) => void | Promise<void>
|
||||
): Promise<void> => {
|
||||
if (document.startViewTransition) {
|
||||
return document.startViewTransition(() => callback(true)).finished;
|
||||
}
|
||||
|
||||
// Fallback: Execute callback directly without transition
|
||||
const result = callback(false);
|
||||
return result instanceof Promise ? result : Promise.resolve();
|
||||
};
|
||||
@@ -21,7 +21,6 @@ import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-input-helper-text";
|
||||
import type {
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
@@ -475,6 +474,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.helper=${this.helper}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
|
||||
41
src/components/ha-dropdown-item.ts
Normal file
41
src/components/ha-dropdown-item.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-item/dropdown-item";
|
||||
import { css, type CSSResultGroup } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Home Assistant dropdown item component
|
||||
*
|
||||
* @element ha-dropdown-item
|
||||
* @extends {DropdownItem}
|
||||
*
|
||||
* @summary
|
||||
* A stylable dropdown item component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown item.
|
||||
*
|
||||
*/
|
||||
@customElement("ha-dropdown-item")
|
||||
export class HaDropdownItem extends DropdownItem {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
DropdownItem.styles,
|
||||
css`
|
||||
:host {
|
||||
min-height: var(--ha-space-10);
|
||||
}
|
||||
|
||||
#icon ::slotted(*) {
|
||||
color: var(--ha-color-on-neutral-normal);
|
||||
}
|
||||
|
||||
:host([variant="danger"]) #icon ::slotted(*) {
|
||||
color: var(--ha-color-on-danger-quiet);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-dropdown-item": HaDropdownItem;
|
||||
}
|
||||
}
|
||||
45
src/components/ha-dropdown.ts
Normal file
45
src/components/ha-dropdown.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
|
||||
import { css, type CSSResultGroup } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Home Assistant dropdown component
|
||||
*
|
||||
* @element ha-dropdown
|
||||
* @extends {Dropdown}
|
||||
*
|
||||
* @summary
|
||||
* A stylable dropdown component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown.
|
||||
*
|
||||
*/
|
||||
@customElement("ha-dropdown")
|
||||
export class HaDropdown extends Dropdown {
|
||||
@property({ attribute: false }) dropdownTag = "ha-dropdown";
|
||||
|
||||
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
Dropdown.styles,
|
||||
css`
|
||||
:host {
|
||||
font-size: var(--ha-dropdown-font-size, var(--ha-font-size-m));
|
||||
--wa-color-surface-raised: var(
|
||||
--card-background-color,
|
||||
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
|
||||
);
|
||||
}
|
||||
|
||||
#menu {
|
||||
padding: var(--ha-space-1);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-dropdown": HaDropdown;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { StateSelector } from "../../data/selector";
|
||||
import { extractFromTarget } from "../../data/target";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../entity/ha-entity-state-picker";
|
||||
@@ -25,15 +27,29 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public context?: {
|
||||
filter_attribute?: string;
|
||||
filter_entity?: string | string[];
|
||||
filter_target?: HassServiceTarget;
|
||||
};
|
||||
|
||||
@state() private _entityIds?: string | string[];
|
||||
|
||||
willUpdate(changedProps) {
|
||||
if (changedProps.has("selector") || changedProps.has("context")) {
|
||||
this._resolveEntityIds(
|
||||
this.selector.state?.entity_id,
|
||||
this.context?.filter_entity,
|
||||
this.context?.filter_target
|
||||
).then((entityIds) => {
|
||||
this._entityIds = entityIds;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this.selector.state?.multiple) {
|
||||
return html`
|
||||
<ha-entity-states-picker
|
||||
.hass=${this.hass}
|
||||
.entityId=${this.selector.state?.entity_id ||
|
||||
this.context?.filter_entity}
|
||||
.entityId=${this._entityIds}
|
||||
.attribute=${this.selector.state?.attribute ||
|
||||
this.context?.filter_attribute}
|
||||
.extraOptions=${this.selector.state?.extra_options}
|
||||
@@ -50,8 +66,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
||||
return html`
|
||||
<ha-entity-state-picker
|
||||
.hass=${this.hass}
|
||||
.entityId=${this.selector.state?.entity_id ||
|
||||
this.context?.filter_entity}
|
||||
.entityId=${this._entityIds}
|
||||
.attribute=${this.selector.state?.attribute ||
|
||||
this.context?.filter_attribute}
|
||||
.extraOptions=${this.selector.state?.extra_options}
|
||||
@@ -65,6 +80,24 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
||||
></ha-entity-state-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _resolveEntityIds(
|
||||
selectorEntityId: string | string[] | undefined,
|
||||
contextFilterEntity: string | string[] | undefined,
|
||||
contextFilterTarget: HassServiceTarget | undefined
|
||||
): Promise<string | string[] | undefined> {
|
||||
if (selectorEntityId !== undefined) {
|
||||
return selectorEntityId;
|
||||
}
|
||||
if (contextFilterEntity !== undefined) {
|
||||
return contextFilterEntity;
|
||||
}
|
||||
if (contextFilterTarget !== undefined) {
|
||||
const result = await extractFromTarget(this.hass, contextFilterTarget);
|
||||
return result.referenced_entities;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -712,6 +712,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._selectedSection = section as TargetTypeFloorless | undefined;
|
||||
|
||||
return this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this.entityFilter,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
@@ -725,6 +726,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _getItemsMemoized = memoizeOne(
|
||||
(
|
||||
localize: HomeAssistant["localize"],
|
||||
entityFilter: this["entityFilter"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
includeDomains: this["includeDomains"],
|
||||
@@ -742,7 +744,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
)[] = [];
|
||||
|
||||
if (!filterType || filterType === "entity") {
|
||||
let entities = this._getEntitiesMemoized(
|
||||
let entityItems = this._getEntitiesMemoized(
|
||||
this.hass,
|
||||
includeDomains,
|
||||
undefined,
|
||||
@@ -758,27 +760,25 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
if (searchTerm) {
|
||||
entities = this._filterGroup(
|
||||
entityItems = this._filterGroup(
|
||||
"entity",
|
||||
entities,
|
||||
entityItems,
|
||||
searchTerm,
|
||||
(item: EntityComboBoxItem) =>
|
||||
item.stateObj?.entity_id === searchTerm
|
||||
) as EntityComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!filterType && entities.length) {
|
||||
if (!filterType && entityItems.length) {
|
||||
// show group title
|
||||
items.push(
|
||||
this.hass.localize("ui.components.target-picker.type.entities")
|
||||
);
|
||||
items.push(localize("ui.components.target-picker.type.entities"));
|
||||
}
|
||||
|
||||
items.push(...entities);
|
||||
items.push(...entityItems);
|
||||
}
|
||||
|
||||
if (!filterType || filterType === "device") {
|
||||
let devices = this._getDevicesMemoized(
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
includeDomains,
|
||||
@@ -794,17 +794,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
if (searchTerm) {
|
||||
devices = this._filterGroup("device", devices, searchTerm);
|
||||
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
|
||||
}
|
||||
|
||||
if (!filterType && devices.length) {
|
||||
if (!filterType && deviceItems.length) {
|
||||
// show group title
|
||||
items.push(
|
||||
this.hass.localize("ui.components.target-picker.type.devices")
|
||||
);
|
||||
items.push(localize("ui.components.target-picker.type.devices"));
|
||||
}
|
||||
|
||||
items.push(...devices);
|
||||
items.push(...deviceItems);
|
||||
}
|
||||
|
||||
if (!filterType || filterType === "area") {
|
||||
@@ -836,9 +834,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType && areasAndFloors.length) {
|
||||
// show group title
|
||||
items.push(
|
||||
this.hass.localize("ui.components.target-picker.type.areas")
|
||||
);
|
||||
items.push(localize("ui.components.target-picker.type.areas"));
|
||||
}
|
||||
|
||||
items.push(
|
||||
@@ -879,9 +875,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType && labels.length) {
|
||||
// show group title
|
||||
items.push(
|
||||
this.hass.localize("ui.components.target-picker.type.labels")
|
||||
);
|
||||
items.push(localize("ui.components.target-picker.type.labels"));
|
||||
}
|
||||
|
||||
items.push(...labels);
|
||||
|
||||
97
src/components/ha-trigger-icon.ts
Normal file
97
src/components/ha-trigger-icon.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
mdiAvTimer,
|
||||
mdiCalendar,
|
||||
mdiClockOutline,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiFormatListBulleted,
|
||||
mdiGestureDoubleTap,
|
||||
mdiHomeAssistant,
|
||||
mdiMapMarker,
|
||||
mdiMapMarkerRadius,
|
||||
mdiMessageAlert,
|
||||
mdiMicrophoneMessage,
|
||||
mdiNfcVariant,
|
||||
mdiNumeric,
|
||||
mdiStateMachine,
|
||||
mdiSwapHorizontal,
|
||||
mdiWeatherSunny,
|
||||
mdiWebhook,
|
||||
} from "@mdi/js";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
export const TRIGGER_ICONS = {
|
||||
calendar: mdiCalendar,
|
||||
device: mdiDevices,
|
||||
event: mdiGestureDoubleTap,
|
||||
state: mdiStateMachine,
|
||||
geo_location: mdiMapMarker,
|
||||
homeassistant: mdiHomeAssistant,
|
||||
mqtt: mdiSwapHorizontal,
|
||||
numeric_state: mdiNumeric,
|
||||
sun: mdiWeatherSunny,
|
||||
conversation: mdiMicrophoneMessage,
|
||||
tag: mdiNfcVariant,
|
||||
template: mdiCodeBraces,
|
||||
time: mdiClockOutline,
|
||||
time_pattern: mdiAvTimer,
|
||||
webhook: mdiWebhook,
|
||||
persistent_notification: mdiMessageAlert,
|
||||
zone: mdiMapMarkerRadius,
|
||||
list: mdiFormatListBulleted,
|
||||
};
|
||||
|
||||
@customElement("ha-trigger-icon")
|
||||
export class HaTriggerIcon extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public trigger?: string;
|
||||
|
||||
@property() public icon?: string;
|
||||
|
||||
protected render() {
|
||||
if (this.icon) {
|
||||
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
||||
}
|
||||
|
||||
if (!this.trigger) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this.hass) {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = triggerIcon(this.hass, this.trigger).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
return this._renderFallback();
|
||||
});
|
||||
|
||||
return html`${until(icon)}`;
|
||||
}
|
||||
|
||||
private _renderFallback() {
|
||||
const domain = computeDomain(this.trigger!);
|
||||
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
.path=${TRIGGER_ICONS[this.trigger!] || FALLBACK_DOMAIN_ICONS[domain]}
|
||||
></ha-svg-icon>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-trigger-icon": HaTriggerIcon;
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device_id: {},
|
||||
serviceGroups: {},
|
||||
dynamicGroups: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -117,14 +117,6 @@ export const VIRTUAL_ACTIONS: Partial<
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const SERVICE_PREFIX = "__SERVICE__";
|
||||
|
||||
export const isService = (key: string | undefined): boolean | undefined =>
|
||||
key?.startsWith(SERVICE_PREFIX);
|
||||
|
||||
export const getService = (key: string): string =>
|
||||
key.substring(SERVICE_PREFIX.length);
|
||||
|
||||
export const COLLAPSIBLE_ACTION_ELEMENTS = [
|
||||
"ha-automation-action-choose",
|
||||
"ha-automation-action-condition",
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface AnalyticsPreferences {
|
||||
diagnostics?: boolean;
|
||||
usage?: boolean;
|
||||
statistics?: boolean;
|
||||
snapshots?: boolean;
|
||||
}
|
||||
|
||||
export interface Analytics {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
HassEntityAttributeBase,
|
||||
HassEntityBase,
|
||||
HassServiceTarget,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import type { WeekdayShort } from "../common/datetime/weekday";
|
||||
import { navigate } from "../common/navigate";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import { createSearchParam } from "../common/url/search-params";
|
||||
@@ -12,10 +14,19 @@ import { CONDITION_BUILDING_BLOCKS } from "./condition";
|
||||
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
|
||||
import type { Action, Field, MODES } from "./script";
|
||||
import { migrateAutomationAction } from "./script";
|
||||
import type { TriggerDescription } from "./trigger";
|
||||
|
||||
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
|
||||
export const AUTOMATION_DEFAULT_MAX = 10;
|
||||
|
||||
export const DYNAMIC_PREFIX = "__DYNAMIC__";
|
||||
|
||||
export const isDynamic = (key: string | undefined): boolean | undefined =>
|
||||
key?.startsWith(DYNAMIC_PREFIX);
|
||||
|
||||
export const getValueFromDynamic = (key: string): string =>
|
||||
key.substring(DYNAMIC_PREFIX.length);
|
||||
|
||||
export interface AutomationEntity extends HassEntityBase {
|
||||
attributes: HassEntityAttributeBase & {
|
||||
id?: string;
|
||||
@@ -85,6 +96,12 @@ export interface BaseTrigger {
|
||||
id?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PlatformTrigger extends BaseTrigger {
|
||||
trigger: Exclude<string, LegacyTrigger["trigger"]>;
|
||||
target?: HassServiceTarget;
|
||||
}
|
||||
|
||||
export interface StateTrigger extends BaseTrigger {
|
||||
@@ -194,7 +211,7 @@ export interface CalendarTrigger extends BaseTrigger {
|
||||
offset: string;
|
||||
}
|
||||
|
||||
export type Trigger =
|
||||
export type LegacyTrigger =
|
||||
| StateTrigger
|
||||
| MqttTrigger
|
||||
| GeoLocationTrigger
|
||||
@@ -211,8 +228,9 @@ export type Trigger =
|
||||
| TemplateTrigger
|
||||
| EventTrigger
|
||||
| DeviceTrigger
|
||||
| CalendarTrigger
|
||||
| TriggerList;
|
||||
| CalendarTrigger;
|
||||
|
||||
export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
|
||||
|
||||
interface BaseCondition {
|
||||
condition: string;
|
||||
@@ -257,13 +275,11 @@ export interface ZoneCondition extends BaseCondition {
|
||||
zone: string;
|
||||
}
|
||||
|
||||
type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
|
||||
|
||||
export interface TimeCondition extends BaseCondition {
|
||||
condition: "time";
|
||||
after?: string;
|
||||
before?: string;
|
||||
weekday?: Weekday | Weekday[];
|
||||
weekday?: WeekdayShort | WeekdayShort[];
|
||||
}
|
||||
|
||||
export interface TemplateCondition extends BaseCondition {
|
||||
@@ -576,6 +592,7 @@ export interface TriggerSidebarConfig extends BaseSidebarConfig {
|
||||
insertAfter: (value: Trigger | Trigger[]) => boolean;
|
||||
toggleYamlMode: () => void;
|
||||
config: Trigger;
|
||||
description?: TriggerDescription;
|
||||
yamlMode: boolean;
|
||||
uiSupported: boolean;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
formatListWithAnds,
|
||||
formatListWithOrs,
|
||||
} from "../common/string/format-list";
|
||||
import { hasTemplate } from "../common/string/has-template";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { Condition, ForDict, Trigger } from "./automation";
|
||||
import type { Condition, ForDict, LegacyTrigger, Trigger } from "./automation";
|
||||
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
|
||||
import {
|
||||
localizeDeviceAutomationCondition,
|
||||
@@ -25,8 +26,7 @@ import {
|
||||
} from "./device_automation";
|
||||
import type { EntityRegistryEntry } from "./entity_registry";
|
||||
import type { FrontendLocaleData } from "./translation";
|
||||
import { isTriggerList } from "./trigger";
|
||||
import { hasTemplate } from "../common/string/has-template";
|
||||
import { getTriggerDomain, getTriggerObjectId, isTriggerList } from "./trigger";
|
||||
|
||||
const triggerTranslationBaseKey =
|
||||
"ui.panel.config.automation.editor.triggers.type";
|
||||
@@ -121,6 +121,37 @@ const tryDescribeTrigger = (
|
||||
return trigger.alias;
|
||||
}
|
||||
|
||||
const description = describeLegacyTrigger(
|
||||
trigger as LegacyTrigger,
|
||||
hass,
|
||||
entityRegistry
|
||||
);
|
||||
|
||||
if (description) {
|
||||
return description;
|
||||
}
|
||||
|
||||
const triggerType = trigger.trigger;
|
||||
|
||||
const domain = getTriggerDomain(trigger.trigger);
|
||||
const type = getTriggerObjectId(trigger.trigger);
|
||||
|
||||
return (
|
||||
hass.localize(
|
||||
`component.${domain}.triggers.${type}.description_configured`
|
||||
) ||
|
||||
hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.${triggerType as LegacyTrigger["trigger"]}.label`
|
||||
) ||
|
||||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
|
||||
);
|
||||
};
|
||||
|
||||
const describeLegacyTrigger = (
|
||||
trigger: LegacyTrigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
) => {
|
||||
// Event Trigger
|
||||
if (trigger.trigger === "event" && trigger.event_type) {
|
||||
const eventTypes: string[] = [];
|
||||
@@ -802,13 +833,7 @@ const tryDescribeTrigger = (
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`
|
||||
) ||
|
||||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const describeCondition = (
|
||||
|
||||
@@ -102,6 +102,7 @@ export type EnergySolarForecasts = Record<string, EnergySolarForecast>;
|
||||
export interface DeviceConsumptionEnergyPreference {
|
||||
// This is an ever increasing value
|
||||
stat_consumption: string;
|
||||
stat_rate?: string;
|
||||
name?: string;
|
||||
included_in_stat?: string;
|
||||
}
|
||||
@@ -130,11 +131,17 @@ export interface FlowToGridSourceEnergyPreference {
|
||||
number_energy_price: number | null;
|
||||
}
|
||||
|
||||
export interface GridPowerSourceEnergyPreference {
|
||||
// W meter
|
||||
stat_rate: string;
|
||||
}
|
||||
|
||||
export interface GridSourceTypeEnergyPreference {
|
||||
type: "grid";
|
||||
|
||||
flow_from: FlowFromGridSourceEnergyPreference[];
|
||||
flow_to: FlowToGridSourceEnergyPreference[];
|
||||
power?: GridPowerSourceEnergyPreference[];
|
||||
|
||||
cost_adjustment_day: number;
|
||||
}
|
||||
@@ -143,6 +150,7 @@ export interface SolarSourceTypeEnergyPreference {
|
||||
type: "solar";
|
||||
|
||||
stat_energy_from: string;
|
||||
stat_rate?: string;
|
||||
config_entry_solar_forecast: string[] | null;
|
||||
}
|
||||
|
||||
@@ -150,6 +158,7 @@ export interface BatterySourceTypeEnergyPreference {
|
||||
type: "battery";
|
||||
stat_energy_from: string;
|
||||
stat_energy_to: string;
|
||||
stat_rate?: string;
|
||||
}
|
||||
export interface GasSourceTypeEnergyPreference {
|
||||
type: "gas";
|
||||
@@ -351,6 +360,35 @@ export const getReferencedStatisticIds = (
|
||||
return statIDs;
|
||||
};
|
||||
|
||||
export const getReferencedStatisticIdsPower = (
|
||||
prefs: EnergyPreferences
|
||||
): string[] => {
|
||||
const statIDs: (string | undefined)[] = [];
|
||||
|
||||
for (const source of prefs.energy_sources) {
|
||||
if (source.type === "gas" || source.type === "water") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.type === "solar") {
|
||||
statIDs.push(source.stat_rate);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.type === "battery") {
|
||||
statIDs.push(source.stat_rate);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.power) {
|
||||
statIDs.push(...source.power.map((p) => p.stat_rate));
|
||||
}
|
||||
}
|
||||
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
|
||||
|
||||
return statIDs.filter(Boolean) as string[];
|
||||
};
|
||||
|
||||
export const enum CompareMode {
|
||||
NONE = "",
|
||||
PREVIOUS = "previous",
|
||||
@@ -398,9 +436,10 @@ const getEnergyData = async (
|
||||
"gas",
|
||||
"device",
|
||||
]);
|
||||
const powerStatIds = getReferencedStatisticIdsPower(prefs);
|
||||
const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]);
|
||||
|
||||
const allStatIDs = [...energyStatIds, ...waterStatIds];
|
||||
const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds];
|
||||
|
||||
const dayDifference = differenceInDays(end || new Date(), start);
|
||||
const period =
|
||||
@@ -411,6 +450,8 @@ const getEnergyData = async (
|
||||
: dayDifference > 2
|
||||
? "day"
|
||||
: "hour";
|
||||
const finePeriod =
|
||||
dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
|
||||
|
||||
const statsMetadata: Record<string, StatisticsMetaData> = {};
|
||||
const statsMetadataArray = allStatIDs.length
|
||||
@@ -432,6 +473,9 @@ const getEnergyData = async (
|
||||
? (gasUnit as (typeof VOLUME_UNITS)[number])
|
||||
: undefined,
|
||||
};
|
||||
const powerUnits: StatisticsUnitConfiguration = {
|
||||
power: "kW",
|
||||
};
|
||||
const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata);
|
||||
const waterUnits: StatisticsUnitConfiguration = {
|
||||
volume: waterUnit,
|
||||
@@ -442,6 +486,12 @@ const getEnergyData = async (
|
||||
"change",
|
||||
])
|
||||
: {};
|
||||
const _powerStats: Statistics | Promise<Statistics> = powerStatIds.length
|
||||
? fetchStatistics(hass!, start, end, powerStatIds, finePeriod, powerUnits, [
|
||||
"mean",
|
||||
])
|
||||
: {};
|
||||
|
||||
const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length
|
||||
? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [
|
||||
"change",
|
||||
@@ -548,6 +598,7 @@ const getEnergyData = async (
|
||||
|
||||
const [
|
||||
energyStats,
|
||||
powerStats,
|
||||
waterStats,
|
||||
energyStatsCompare,
|
||||
waterStatsCompare,
|
||||
@@ -555,13 +606,14 @@ const getEnergyData = async (
|
||||
fossilEnergyConsumptionCompare,
|
||||
] = await Promise.all([
|
||||
_energyStats,
|
||||
_powerStats,
|
||||
_waterStats,
|
||||
_energyStatsCompare,
|
||||
_waterStatsCompare,
|
||||
_fossilEnergyConsumption,
|
||||
_fossilEnergyConsumptionCompare,
|
||||
]);
|
||||
const stats = { ...energyStats, ...waterStats };
|
||||
const stats = { ...energyStats, ...waterStats, ...powerStats };
|
||||
if (compare) {
|
||||
statsCompare = { ...energyStatsCompare, ...waterStatsCompare };
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
} from "./entity_registry";
|
||||
|
||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
||||
import { getTriggerDomain, getTriggerObjectId } from "./trigger";
|
||||
|
||||
/** Icon to use when no icon specified for service. */
|
||||
export const DEFAULT_SERVICE_ICON = mdiRoomService;
|
||||
@@ -133,14 +134,19 @@ const resources: {
|
||||
all?: Promise<Record<string, ServiceIcons>>;
|
||||
domains: Record<string, ServiceIcons | Promise<ServiceIcons>>;
|
||||
};
|
||||
triggers: {
|
||||
all?: Promise<Record<string, TriggerIcons>>;
|
||||
domains: Record<string, TriggerIcons | Promise<TriggerIcons>>;
|
||||
};
|
||||
} = {
|
||||
entity: {},
|
||||
entity_component: {},
|
||||
services: { domains: {} },
|
||||
triggers: { domains: {} },
|
||||
};
|
||||
|
||||
interface IconResources<
|
||||
T extends ComponentIcons | PlatformIcons | ServiceIcons,
|
||||
T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons,
|
||||
> {
|
||||
resources: Record<string, T>;
|
||||
}
|
||||
@@ -184,12 +190,22 @@ type ServiceIcons = Record<
|
||||
{ service: string; sections?: Record<string, string> }
|
||||
>;
|
||||
|
||||
export type IconCategory = "entity" | "entity_component" | "services";
|
||||
type TriggerIcons = Record<
|
||||
string,
|
||||
{ trigger: string; sections?: Record<string, string> }
|
||||
>;
|
||||
|
||||
export type IconCategory =
|
||||
| "entity"
|
||||
| "entity_component"
|
||||
| "services"
|
||||
| "triggers";
|
||||
|
||||
interface CategoryType {
|
||||
entity: PlatformIcons;
|
||||
entity_component: ComponentIcons;
|
||||
services: ServiceIcons;
|
||||
triggers: TriggerIcons;
|
||||
}
|
||||
|
||||
export const getHassIcons = async <T extends IconCategory>(
|
||||
@@ -258,42 +274,59 @@ export const getComponentIcons = async (
|
||||
return resources.entity_component.resources.then((res) => res[domain]);
|
||||
};
|
||||
|
||||
export const getServiceIcons = async (
|
||||
export const getCategoryIcons = async <
|
||||
T extends Exclude<IconCategory, "entity" | "entity_component">,
|
||||
>(
|
||||
hass: HomeAssistant,
|
||||
category: T,
|
||||
domain?: string,
|
||||
force = false
|
||||
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> => {
|
||||
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
|
||||
if (!domain) {
|
||||
if (!force && resources.services.all) {
|
||||
return resources.services.all;
|
||||
if (!force && resources[category].all) {
|
||||
return resources[category].all as Promise<
|
||||
Record<string, CategoryType[T]>
|
||||
>;
|
||||
}
|
||||
resources.services.all = getHassIcons(hass, "services", domain).then(
|
||||
(res) => {
|
||||
resources.services.domains = res.resources;
|
||||
return res?.resources;
|
||||
}
|
||||
);
|
||||
return resources.services.all;
|
||||
resources[category].all = getHassIcons(hass, category).then((res) => {
|
||||
resources[category].domains = res.resources as any;
|
||||
return res?.resources as Record<string, CategoryType[T]>;
|
||||
}) as any;
|
||||
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
|
||||
}
|
||||
if (!force && domain in resources.services.domains) {
|
||||
return resources.services.domains[domain];
|
||||
if (!force && domain in resources[category].domains) {
|
||||
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||
}
|
||||
if (resources.services.all && !force) {
|
||||
await resources.services.all;
|
||||
if (domain in resources.services.domains) {
|
||||
return resources.services.domains[domain];
|
||||
if (resources[category].all && !force) {
|
||||
await resources[category].all;
|
||||
if (domain in resources[category].domains) {
|
||||
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||
}
|
||||
}
|
||||
if (!isComponentLoaded(hass, domain)) {
|
||||
return undefined;
|
||||
}
|
||||
const result = getHassIcons(hass, "services", domain);
|
||||
resources.services.domains[domain] = result.then(
|
||||
const result = getHassIcons(hass, category, domain);
|
||||
resources[category].domains[domain] = result.then(
|
||||
(res) => res?.resources[domain]
|
||||
);
|
||||
return resources.services.domains[domain];
|
||||
) as any;
|
||||
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||
};
|
||||
|
||||
export const getServiceIcons = async (
|
||||
hass: HomeAssistant,
|
||||
domain?: string,
|
||||
force = false
|
||||
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> =>
|
||||
getCategoryIcons(hass, "services", domain, force);
|
||||
|
||||
export const getTriggerIcons = async (
|
||||
hass: HomeAssistant,
|
||||
domain?: string,
|
||||
force = false
|
||||
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
|
||||
getCategoryIcons(hass, "triggers", domain, force);
|
||||
|
||||
// Cache for sorted range keys
|
||||
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
|
||||
|
||||
@@ -473,6 +506,26 @@ export const attributeIcon = async (
|
||||
return icon;
|
||||
};
|
||||
|
||||
export const triggerIcon = async (
|
||||
hass: HomeAssistant,
|
||||
trigger: string
|
||||
): Promise<string | undefined> => {
|
||||
let icon: string | undefined;
|
||||
|
||||
const domain = getTriggerDomain(trigger);
|
||||
const triggerName = getTriggerObjectId(trigger);
|
||||
|
||||
const triggerIcons = await getTriggerIcons(hass, domain);
|
||||
if (triggerIcons) {
|
||||
const trgrIcon = triggerIcons[triggerName] as TriggerIcons[string];
|
||||
icon = trgrIcon?.trigger;
|
||||
}
|
||||
if (!icon) {
|
||||
icon = await domainIcon(hass, domain);
|
||||
}
|
||||
return icon;
|
||||
};
|
||||
|
||||
export const serviceIcon = async (
|
||||
hass: HomeAssistant,
|
||||
service: string
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface TodoItem {
|
||||
status: TodoItemStatus | null;
|
||||
description?: string | null;
|
||||
due?: string | null;
|
||||
completed?: string | null;
|
||||
}
|
||||
|
||||
export const enum TodoListEntityFeature {
|
||||
|
||||
@@ -73,7 +73,8 @@ export type TranslationCategory =
|
||||
| "application_credentials"
|
||||
| "issues"
|
||||
| "selector"
|
||||
| "services";
|
||||
| "services"
|
||||
| "triggers";
|
||||
|
||||
export const subscribeTranslationPreferences = (
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -1,57 +1,20 @@
|
||||
import {
|
||||
mdiAvTimer,
|
||||
mdiCalendar,
|
||||
mdiClockOutline,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiFormatListBulleted,
|
||||
mdiGestureDoubleTap,
|
||||
mdiMapClock,
|
||||
mdiMapMarker,
|
||||
mdiMapMarkerRadius,
|
||||
mdiMessageAlert,
|
||||
mdiMicrophoneMessage,
|
||||
mdiNfcVariant,
|
||||
mdiNumeric,
|
||||
mdiShape,
|
||||
mdiStateMachine,
|
||||
mdiSwapHorizontal,
|
||||
mdiWeatherSunny,
|
||||
mdiWebhook,
|
||||
} from "@mdi/js";
|
||||
import { mdiMapClock, mdiShape } from "@mdi/js";
|
||||
|
||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type {
|
||||
AutomationElementGroupCollection,
|
||||
Trigger,
|
||||
TriggerList,
|
||||
} from "./automation";
|
||||
|
||||
export const TRIGGER_ICONS = {
|
||||
calendar: mdiCalendar,
|
||||
device: mdiDevices,
|
||||
event: mdiGestureDoubleTap,
|
||||
state: mdiStateMachine,
|
||||
geo_location: mdiMapMarker,
|
||||
homeassistant: mdiHomeAssistant,
|
||||
mqtt: mdiSwapHorizontal,
|
||||
numeric_state: mdiNumeric,
|
||||
sun: mdiWeatherSunny,
|
||||
conversation: mdiMicrophoneMessage,
|
||||
tag: mdiNfcVariant,
|
||||
template: mdiCodeBraces,
|
||||
time: mdiClockOutline,
|
||||
time_pattern: mdiAvTimer,
|
||||
webhook: mdiWebhook,
|
||||
persistent_notification: mdiMessageAlert,
|
||||
zone: mdiMapMarkerRadius,
|
||||
list: mdiFormatListBulleted,
|
||||
};
|
||||
import type { Selector, TargetSelector } from "./selector";
|
||||
|
||||
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device: {},
|
||||
dynamicGroups: {},
|
||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
@@ -83,3 +46,33 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
|
||||
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
|
||||
"triggers" in trigger;
|
||||
|
||||
export interface TriggerDescription {
|
||||
target?: TargetSelector["target"];
|
||||
fields: Record<
|
||||
string,
|
||||
{
|
||||
example?: string | boolean | number;
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
selector?: Selector;
|
||||
context?: Record<string, string>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export type TriggerDescriptions = Record<string, TriggerDescription>;
|
||||
|
||||
export const subscribeTriggers = (
|
||||
hass: HomeAssistant,
|
||||
callback: (triggers: TriggerDescriptions) => void
|
||||
) =>
|
||||
hass.connection.subscribeMessage<TriggerDescriptions>(callback, {
|
||||
type: "trigger_platforms/subscribe",
|
||||
});
|
||||
|
||||
export const getTriggerDomain = (trigger: string) =>
|
||||
trigger.includes(".") ? computeDomain(trigger) : trigger;
|
||||
|
||||
export const getTriggerObjectId = (trigger: string) =>
|
||||
trigger.includes(".") ? computeObjectId(trigger) : "_";
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { createCloseHeading } from "../../components/ha-dialog";
|
||||
import "../../components/ha-wa-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-formfield";
|
||||
import "../../components/ha-switch";
|
||||
import "../../components/ha-button";
|
||||
@@ -28,6 +29,8 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public async showDialog(
|
||||
params: ConfigEntrySystemOptionsDialogParams
|
||||
): Promise<void> {
|
||||
@@ -35,9 +38,14 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
this._error = undefined;
|
||||
this._disableNewEntities = params.entry.pref_disable_new_entities;
|
||||
this._disablePolling = params.entry.pref_disable_polling;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._error = "";
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
@@ -49,18 +57,19 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
this.hass.localize("ui.dialogs.config_entry_system_options.title", {
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.dialogs.config_entry_system_options.title",
|
||||
{
|
||||
integration:
|
||||
this.hass.localize(
|
||||
`component.${this._params.entry.domain}.title`
|
||||
) || this._params.entry.domain,
|
||||
})
|
||||
}
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
|
||||
<ha-formfield
|
||||
@@ -82,10 +91,10 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
</p>`}
|
||||
>
|
||||
<ha-switch
|
||||
autofocus
|
||||
.checked=${!this._disableNewEntities}
|
||||
@change=${this._disableNewEntitiesChanged}
|
||||
.disabled=${this._submitting}
|
||||
dialogInitialFocus
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
|
||||
@@ -113,22 +122,27 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
.disabled=${this._submitting}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="primaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._updateEntry}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.dialogs.config_entry_system_options.update")}
|
||||
</ha-button>
|
||||
</ha-dialog>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._updateEntry}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.config_entry_system_options.update"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,23 +20,44 @@
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<%= renderTemplate("_style_base.html.template") %>
|
||||
<style>
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
::view-transition-group(launch-screen) {
|
||||
animation-duration: var(--ha-animation-base-duration, 350ms);
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
::view-transition-old(launch-screen) {
|
||||
animation: fade-out var(--ha-animation-base-duration, 350ms) ease-out;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
height: 100vh;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
background-color: var(--primary-background-color, #111111);
|
||||
color: var(--primary-text-color, #e1e1e1);
|
||||
}
|
||||
}
|
||||
#ha-launch-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
view-transition-name: launch-screen;
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
z-index: 100;
|
||||
}
|
||||
#ha-launch-screen.removing {
|
||||
opacity: 0;
|
||||
}
|
||||
#ha-launch-screen svg {
|
||||
width: 112px;
|
||||
@@ -59,6 +80,14 @@
|
||||
opacity: .66;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
background-color: var(--primary-background-color, #111111);
|
||||
color: var(--primary-text-color, #e1e1e1);
|
||||
}
|
||||
/* body selector to avoid minification causing bad jinja2 */
|
||||
body #ha-launch-screen {
|
||||
background-color: var(--primary-background-color, #111111);
|
||||
}
|
||||
.ohf-logo {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const COMPONENTS = {
|
||||
light: () => import("../panels/light/ha-panel-light"),
|
||||
security: () => import("../panels/security/ha-panel-security"),
|
||||
climate: () => import("../panels/climate/ha-panel-climate"),
|
||||
home: () => import("../panels/home/ha-panel-home"),
|
||||
};
|
||||
|
||||
@customElement("partial-panel-resolver")
|
||||
|
||||
@@ -1,82 +1,56 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { listenMediaQuery } from "../common/dom/media_query";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import {
|
||||
setupMediaQueryListeners,
|
||||
setupTimeListeners,
|
||||
} from "../common/condition/listeners";
|
||||
import type { Condition } from "../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../panels/lovelace/common/validate-condition";
|
||||
|
||||
type Constructor<T> = abstract new (...args: any[]) => T;
|
||||
|
||||
/**
|
||||
* Extract media queries from conditions recursively
|
||||
* Base config type that can be used with conditional listeners
|
||||
*/
|
||||
export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
return conditions.reduce<string[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractMediaQueries(c.conditions));
|
||||
}
|
||||
if (c.condition === "screen" && c.media_query) {
|
||||
array.push(c.media_query);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to setup media query listeners for conditional visibility
|
||||
*/
|
||||
export function setupMediaQueryListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void
|
||||
): void {
|
||||
const mediaQueries = extractMediaQueries(conditions);
|
||||
|
||||
if (mediaQueries.length === 0) return;
|
||||
|
||||
// Optimization for single media query
|
||||
const hasOnlyMediaQuery =
|
||||
conditions.length === 1 &&
|
||||
conditions[0].condition === "screen" &&
|
||||
!!conditions[0].media_query;
|
||||
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
const unsub = listenMediaQuery(mediaQuery, (matches) => {
|
||||
if (hasOnlyMediaQuery) {
|
||||
onUpdate(matches);
|
||||
} else {
|
||||
const conditionsMet = checkConditionsMet(conditions, hass);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
});
|
||||
addListener(unsub);
|
||||
});
|
||||
export interface ConditionalConfig {
|
||||
visibility?: Condition[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixin to handle conditional listeners for visibility control
|
||||
*
|
||||
* Provides lifecycle management for listeners (media queries, time-based, state changes, etc.)
|
||||
* that control conditional visibility of components.
|
||||
* Provides lifecycle management for listeners that control conditional
|
||||
* visibility of components.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Extend your component with ConditionalListenerMixin(ReactiveElement)
|
||||
* 2. Override setupConditionalListeners() to setup your listeners
|
||||
* 3. Use addConditionalListener() to register unsubscribe functions
|
||||
* 4. Call clearConditionalListeners() and setupConditionalListeners() when config changes
|
||||
* 1. Extend your component with ConditionalListenerMixin<YourConfigType>(ReactiveElement)
|
||||
* 2. Ensure component has config.visibility or _config.visibility property with conditions
|
||||
* 3. Ensure component has _updateVisibility() or _updateElement() method
|
||||
* 4. Override setupConditionalListeners() if custom behavior needed (e.g., filter conditions)
|
||||
*
|
||||
* The mixin automatically:
|
||||
* - Sets up listeners when component connects to DOM
|
||||
* - Cleans up listeners when component disconnects from DOM
|
||||
* - Handles conditional visibility based on defined conditions
|
||||
*/
|
||||
export const ConditionalListenerMixin = <
|
||||
T extends Constructor<ReactiveElement>,
|
||||
TConfig extends ConditionalConfig = ConditionalConfig,
|
||||
>(
|
||||
superClass: T
|
||||
superClass: Constructor<ReactiveElement>
|
||||
) => {
|
||||
abstract class ConditionalListenerClass extends superClass {
|
||||
private __listeners: (() => void)[] = [];
|
||||
|
||||
protected _config?: TConfig;
|
||||
|
||||
public config?: TConfig;
|
||||
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
protected _updateElement?(config: TConfig): void;
|
||||
|
||||
protected _updateVisibility?(conditionsMet?: boolean): void;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setupConditionalListeners();
|
||||
@@ -87,17 +61,72 @@ export const ConditionalListenerMixin = <
|
||||
this.clearConditionalListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conditional listeners
|
||||
*
|
||||
* This method is called when the component is disconnected from the DOM.
|
||||
* It clears all the listeners that were set up by the setupConditionalListeners() method.
|
||||
*/
|
||||
protected clearConditionalListeners(): void {
|
||||
this.__listeners.forEach((unsub) => unsub());
|
||||
this.__listeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a conditional listener to the list of listeners
|
||||
*
|
||||
* This method is called when a new listener is added.
|
||||
* It adds the listener to the list of listeners.
|
||||
*
|
||||
* @param unsubscribe - The unsubscribe function to call when the listener is no longer needed
|
||||
* @returns void
|
||||
*/
|
||||
protected addConditionalListener(unsubscribe: () => void): void {
|
||||
this.__listeners.push(unsubscribe);
|
||||
}
|
||||
|
||||
protected setupConditionalListeners(): void {
|
||||
// Override in subclass
|
||||
/**
|
||||
* Setup conditional listeners for visibility control
|
||||
*
|
||||
* Default implementation:
|
||||
* - Checks config.visibility or _config.visibility for conditions (if not provided)
|
||||
* - Sets up appropriate listeners based on condition types
|
||||
* - Calls _updateVisibility() or _updateElement() when conditions change
|
||||
*
|
||||
* Override this method to customize behavior (e.g., filter conditions first)
|
||||
* and call super.setupConditionalListeners(customConditions) to reuse the base implementation
|
||||
*
|
||||
* @param conditions - Optional conditions array. If not provided, will check config.visibility or _config.visibility
|
||||
*/
|
||||
protected setupConditionalListeners(conditions?: Condition[]): void {
|
||||
const config = this.config || this._config;
|
||||
const finalConditions = conditions || config?.visibility;
|
||||
|
||||
if (!finalConditions || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onUpdate = (conditionsMet: boolean) => {
|
||||
if (this._updateVisibility) {
|
||||
this._updateVisibility(conditionsMet);
|
||||
} else if (this._updateElement && config) {
|
||||
this._updateElement(config);
|
||||
}
|
||||
};
|
||||
|
||||
setupMediaQueryListeners(
|
||||
finalConditions,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
onUpdate
|
||||
);
|
||||
|
||||
setupTimeListeners(
|
||||
finalConditions,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
onUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
return ConditionalListenerClass;
|
||||
|
||||
@@ -14,11 +14,13 @@ import "../../../../components/ha-sortable";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import {
|
||||
ACTION_BUILDING_BLOCKS,
|
||||
getService,
|
||||
isService,
|
||||
VIRTUAL_ACTIONS,
|
||||
} from "../../../../data/action";
|
||||
import type { AutomationClipboard } from "../../../../data/automation";
|
||||
import {
|
||||
getValueFromDynamic,
|
||||
isDynamic,
|
||||
type AutomationClipboard,
|
||||
} from "../../../../data/automation";
|
||||
import type { Action } from "../../../../data/script";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
@@ -217,9 +219,9 @@ export default class HaAutomationAction extends LitElement {
|
||||
actions = this.actions.concat(deepClone(this._clipboard!.action));
|
||||
} else if (action in VIRTUAL_ACTIONS) {
|
||||
actions = this.actions.concat(VIRTUAL_ACTIONS[action]);
|
||||
} else if (isService(action)) {
|
||||
} else if (isDynamic(action)) {
|
||||
actions = this.actions.concat({
|
||||
action: getService(action),
|
||||
action: getValueFromDynamic(action),
|
||||
metadata: {},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import {
|
||||
@@ -40,32 +41,39 @@ import "../../../components/ha-md-list";
|
||||
import type { HaMdList } from "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import "../../../components/ha-service-icon";
|
||||
import { TRIGGER_ICONS } from "../../../components/ha-trigger-icon";
|
||||
import "../../../components/ha-wa-dialog";
|
||||
import "../../../components/search-input";
|
||||
import {
|
||||
ACTION_BUILDING_BLOCKS_GROUP,
|
||||
ACTION_COLLECTIONS,
|
||||
ACTION_ICONS,
|
||||
SERVICE_PREFIX,
|
||||
getService,
|
||||
isService,
|
||||
} from "../../../data/action";
|
||||
import type {
|
||||
AutomationElementGroup,
|
||||
AutomationElementGroupCollection,
|
||||
import {
|
||||
DYNAMIC_PREFIX,
|
||||
getValueFromDynamic,
|
||||
isDynamic,
|
||||
type AutomationElementGroup,
|
||||
type AutomationElementGroupCollection,
|
||||
} from "../../../data/automation";
|
||||
import {
|
||||
CONDITION_BUILDING_BLOCKS_GROUP,
|
||||
CONDITION_COLLECTIONS,
|
||||
CONDITION_ICONS,
|
||||
} from "../../../data/condition";
|
||||
import { getServiceIcons } from "../../../data/icons";
|
||||
import { getServiceIcons, getTriggerIcons } from "../../../data/icons";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import {
|
||||
domainToName,
|
||||
fetchIntegrationManifests,
|
||||
} from "../../../data/integration";
|
||||
import { TRIGGER_COLLECTIONS, TRIGGER_ICONS } from "../../../data/trigger";
|
||||
import type { TriggerDescriptions } from "../../../data/trigger";
|
||||
import {
|
||||
TRIGGER_COLLECTIONS,
|
||||
getTriggerDomain,
|
||||
getTriggerObjectId,
|
||||
subscribeTriggers,
|
||||
} from "../../../data/trigger";
|
||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
|
||||
import { HaFuse } from "../../../resources/fuse";
|
||||
@@ -111,7 +119,7 @@ const ENTITY_DOMAINS_OTHER = new Set([
|
||||
|
||||
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
|
||||
|
||||
const ACTION_SERVICE_KEYWORDS = ["serviceGroups", "helpers", "other"];
|
||||
const ACTION_SERVICE_KEYWORDS = ["dynamicGroups", "helpers", "other"];
|
||||
|
||||
@customElement("add-automation-element-dialog")
|
||||
class DialogAddAutomationElement
|
||||
@@ -142,6 +150,8 @@ class DialogAddAutomationElement
|
||||
|
||||
@state() private _narrow = false;
|
||||
|
||||
@state() private _triggerDescriptions: TriggerDescriptions = {};
|
||||
|
||||
@query(".items ha-md-list ha-md-list-item")
|
||||
private _itemsListFirstElement?: HaMdList;
|
||||
|
||||
@@ -152,6 +162,8 @@ class DialogAddAutomationElement
|
||||
|
||||
private _removeKeyboardShortcuts?: () => void;
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
public showDialog(params): void {
|
||||
this._params = params;
|
||||
|
||||
@@ -163,6 +175,17 @@ class DialogAddAutomationElement
|
||||
this._calculateUsedDomains();
|
||||
getServiceIcons(this.hass);
|
||||
}
|
||||
if (this._params?.type === "trigger") {
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
this._fetchManifests();
|
||||
getTriggerIcons(this.hass);
|
||||
this._unsub = subscribeTriggers(this.hass, (triggers) => {
|
||||
this._triggerDescriptions = {
|
||||
...this._triggerDescriptions,
|
||||
...triggers,
|
||||
};
|
||||
});
|
||||
}
|
||||
this._fullScreen = matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
@@ -176,6 +199,10 @@ class DialogAddAutomationElement
|
||||
|
||||
public closeDialog() {
|
||||
this.removeKeyboardShortcuts();
|
||||
if (this._unsub) {
|
||||
this._unsub.then((unsub) => unsub());
|
||||
this._unsub = undefined;
|
||||
}
|
||||
if (this._params) {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
@@ -317,6 +344,11 @@ class DialogAddAutomationElement
|
||||
);
|
||||
|
||||
const items = flattenGroups(groups).flat();
|
||||
if (type === "trigger") {
|
||||
items.push(
|
||||
...this._triggers(localize, this._triggerDescriptions, manifests)
|
||||
);
|
||||
}
|
||||
if (type === "action") {
|
||||
items.push(...this._services(localize, services, manifests));
|
||||
}
|
||||
@@ -339,6 +371,7 @@ class DialogAddAutomationElement
|
||||
domains: Set<string> | undefined,
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"],
|
||||
triggerDescriptions: TriggerDescriptions,
|
||||
manifests?: DomainManifestLookup
|
||||
): {
|
||||
titleKey?: LocalizeKeys;
|
||||
@@ -362,7 +395,32 @@ class DialogAddAutomationElement
|
||||
services,
|
||||
manifests,
|
||||
domains,
|
||||
collection.groups.serviceGroups
|
||||
collection.groups.dynamicGroups
|
||||
? undefined
|
||||
: collection.groups.helpers
|
||||
? "helper"
|
||||
: "other"
|
||||
)
|
||||
);
|
||||
|
||||
collectionGroups = collectionGroups.filter(
|
||||
([key]) => !ACTION_SERVICE_KEYWORDS.includes(key)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
type === "trigger" &&
|
||||
Object.keys(collection.groups).some((item) =>
|
||||
ACTION_SERVICE_KEYWORDS.includes(item)
|
||||
)
|
||||
) {
|
||||
groups.push(
|
||||
...this._triggerGroups(
|
||||
localize,
|
||||
triggerDescriptions,
|
||||
manifests,
|
||||
domains,
|
||||
collection.groups.dynamicGroups
|
||||
? undefined
|
||||
: collection.groups.helpers
|
||||
? "helper"
|
||||
@@ -429,10 +487,19 @@ class DialogAddAutomationElement
|
||||
services: HomeAssistant["services"],
|
||||
manifests?: DomainManifestLookup
|
||||
): ListItem[] => {
|
||||
if (type === "action" && isService(group)) {
|
||||
if (type === "action" && isDynamic(group)) {
|
||||
return this._services(localize, services, manifests, group);
|
||||
}
|
||||
|
||||
if (type === "trigger" && isDynamic(group)) {
|
||||
return this._triggers(
|
||||
localize,
|
||||
this._triggerDescriptions,
|
||||
manifests,
|
||||
group
|
||||
);
|
||||
}
|
||||
|
||||
const groups = this._getGroups(type, group, collectionIndex);
|
||||
|
||||
const result = Object.entries(groups).map(([key, options]) =>
|
||||
@@ -514,7 +581,7 @@ class DialogAddAutomationElement
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
`,
|
||||
key: `${SERVICE_PREFIX}${domain}`,
|
||||
key: `${DYNAMIC_PREFIX}${domain}`,
|
||||
name: domainToName(localize, domain, manifest),
|
||||
description: "",
|
||||
});
|
||||
@@ -525,6 +592,102 @@ class DialogAddAutomationElement
|
||||
);
|
||||
};
|
||||
|
||||
private _triggerGroups = (
|
||||
localize: LocalizeFunc,
|
||||
triggers: TriggerDescriptions,
|
||||
manifests: DomainManifestLookup | undefined,
|
||||
domains: Set<string> | undefined,
|
||||
type: "helper" | "other" | undefined
|
||||
): ListItem[] => {
|
||||
if (!triggers || !manifests) {
|
||||
return [];
|
||||
}
|
||||
const result: ListItem[] = [];
|
||||
const addedDomains = new Set<string>();
|
||||
Object.keys(triggers).forEach((trigger) => {
|
||||
const domain = getTriggerDomain(trigger);
|
||||
|
||||
if (addedDomains.has(domain)) {
|
||||
return;
|
||||
}
|
||||
addedDomains.add(domain);
|
||||
|
||||
const manifest = manifests[domain];
|
||||
const domainUsed = !domains ? true : domains.has(domain);
|
||||
|
||||
if (
|
||||
(type === undefined &&
|
||||
(ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(manifest?.integration_type === "entity" &&
|
||||
domainUsed &&
|
||||
!ENTITY_DOMAINS_OTHER.has(domain)))) ||
|
||||
(type === "helper" && manifest?.integration_type === "helper") ||
|
||||
(type === "other" &&
|
||||
!ENTITY_DOMAINS_MAIN.has(domain) &&
|
||||
(ENTITY_DOMAINS_OTHER.has(domain) ||
|
||||
(!domainUsed && manifest?.integration_type === "entity") ||
|
||||
!["helper", "entity"].includes(manifest?.integration_type || "")))
|
||||
) {
|
||||
result.push({
|
||||
icon: html`
|
||||
<ha-domain-icon
|
||||
.hass=${this.hass}
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
`,
|
||||
key: `${DYNAMIC_PREFIX}${domain}`,
|
||||
name: domainToName(localize, domain, manifest),
|
||||
description: "",
|
||||
});
|
||||
}
|
||||
});
|
||||
return result.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
};
|
||||
|
||||
private _triggers = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
triggers: TriggerDescriptions,
|
||||
_manifests: DomainManifestLookup | undefined,
|
||||
group?: string
|
||||
): ListItem[] => {
|
||||
if (!triggers) {
|
||||
return [];
|
||||
}
|
||||
const result: ListItem[] = [];
|
||||
|
||||
for (const trigger of Object.keys(triggers)) {
|
||||
const domain = getTriggerDomain(trigger);
|
||||
const triggerName = getTriggerObjectId(trigger);
|
||||
|
||||
if (group && group !== `${DYNAMIC_PREFIX}${domain}`) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({
|
||||
icon: html`
|
||||
<ha-trigger-icon
|
||||
.hass=${this.hass}
|
||||
.trigger=${trigger}
|
||||
></ha-trigger-icon>
|
||||
`,
|
||||
key: `${DYNAMIC_PREFIX}${trigger}`,
|
||||
name:
|
||||
localize(`component.${domain}.triggers.${triggerName}.name`) ||
|
||||
trigger,
|
||||
description:
|
||||
localize(
|
||||
`component.${domain}.triggers.${triggerName}.description`
|
||||
) || trigger,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
private _services = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
@@ -539,8 +702,8 @@ class DialogAddAutomationElement
|
||||
|
||||
let domain: string | undefined;
|
||||
|
||||
if (isService(group)) {
|
||||
domain = getService(group!);
|
||||
if (isDynamic(group)) {
|
||||
domain = getValueFromDynamic(group!);
|
||||
}
|
||||
|
||||
const addDomain = (dmn: string) => {
|
||||
@@ -554,7 +717,7 @@ class DialogAddAutomationElement
|
||||
.service=${`${dmn}.${service}`}
|
||||
></ha-service-icon>
|
||||
`,
|
||||
key: `${SERVICE_PREFIX}${dmn}.${service}`,
|
||||
key: `${DYNAMIC_PREFIX}${dmn}.${service}`,
|
||||
name: `${domain ? "" : `${domainToName(localize, dmn)}: `}${
|
||||
this.hass.localize(`component.${dmn}.services.${service}.name`) ||
|
||||
services[dmn][service]?.name ||
|
||||
@@ -668,14 +831,15 @@ class DialogAddAutomationElement
|
||||
this._domains,
|
||||
this.hass.localize,
|
||||
this.hass.services,
|
||||
this._triggerDescriptions,
|
||||
this._manifests
|
||||
);
|
||||
|
||||
const groupName = isService(this._selectedGroup)
|
||||
const groupName = isDynamic(this._selectedGroup)
|
||||
? domainToName(
|
||||
this.hass.localize,
|
||||
getService(this._selectedGroup!),
|
||||
this._manifests?.[getService(this._selectedGroup!)]
|
||||
getValueFromDynamic(this._selectedGroup!),
|
||||
this._manifests?.[getValueFromDynamic(this._selectedGroup!)]
|
||||
)
|
||||
: this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${this._params!.type}s.groups.${this._selectedGroup}.label` as LocalizeKeys
|
||||
|
||||
@@ -28,7 +28,6 @@ import type HaAutomationConditionEditor from "../action/ha-automation-action-edi
|
||||
import { getAutomationActionType } from "../action/ha-automation-action-row";
|
||||
import { getRepeatType } from "../action/types/ha-automation-action-repeat";
|
||||
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
||||
import "../trigger/ha-automation-trigger-editor";
|
||||
import "./ha-automation-sidebar-card";
|
||||
|
||||
@customElement("ha-automation-sidebar-action")
|
||||
|
||||
@@ -17,7 +17,6 @@ import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-md-button-menu";
|
||||
import "../../../../components/ha-md-divider";
|
||||
import "../../../../components/ha-md-menu-item";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../ha-automation-editor-warning";
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
} from "@mdi/js";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import "../../../../components/ha-md-divider";
|
||||
import "../../../../components/ha-md-menu-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { OptionSidebarConfig } from "../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
|
||||
@@ -15,8 +15,15 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { handleStructError } from "../../../../common/structs/handle-errors";
|
||||
import type { TriggerSidebarConfig } from "../../../../data/automation";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import type {
|
||||
LegacyTrigger,
|
||||
TriggerSidebarConfig,
|
||||
} from "../../../../data/automation";
|
||||
import {
|
||||
getTriggerDomain,
|
||||
getTriggerObjectId,
|
||||
isTriggerList,
|
||||
} from "../../../../data/trigger";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
||||
@@ -63,8 +70,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
|
||||
protected render() {
|
||||
const rowDisabled =
|
||||
this.disabled ||
|
||||
("enabled" in this.config.config && this.config.config.enabled === false);
|
||||
"enabled" in this.config.config && this.config.config.enabled === false;
|
||||
const type = isTriggerList(this.config.config)
|
||||
? "list"
|
||||
: this.config.config.trigger;
|
||||
@@ -73,9 +79,18 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
"ui.panel.config.automation.editor.triggers.trigger"
|
||||
);
|
||||
|
||||
const title = this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.${type}.label`
|
||||
);
|
||||
const domain =
|
||||
"trigger" in this.config.config &&
|
||||
getTriggerDomain(this.config.config.trigger);
|
||||
const triggerName =
|
||||
"trigger" in this.config.config &&
|
||||
getTriggerObjectId(this.config.config.trigger);
|
||||
|
||||
const title =
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.${type as LegacyTrigger["trigger"]}.label`
|
||||
) ||
|
||||
this.hass.localize(`component.${domain}.triggers.${triggerName}.name`);
|
||||
|
||||
return html`
|
||||
<ha-automation-sidebar-card
|
||||
@@ -269,6 +284,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
class="sidebar-editor"
|
||||
.hass=${this.hass}
|
||||
.trigger=${this.config.config}
|
||||
.description=${this.config.description}
|
||||
@value-changed=${this._valueChangedSidebar}
|
||||
@yaml-changed=${this._yamlChangedSidebar}
|
||||
.uiSupported=${this.config.uiSupported}
|
||||
|
||||
@@ -9,10 +9,12 @@ import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { Trigger } from "../../../../data/automation";
|
||||
import { migrateAutomationTrigger } from "../../../../data/automation";
|
||||
import type { TriggerDescription } from "../../../../data/trigger";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../ha-automation-editor-warning";
|
||||
import "./types/ha-automation-trigger-platform";
|
||||
|
||||
@customElement("ha-automation-trigger-editor")
|
||||
export default class HaAutomationTriggerEditor extends LitElement {
|
||||
@@ -31,6 +33,8 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
|
||||
@property({ type: Boolean, attribute: "show-id" }) public showId = false;
|
||||
|
||||
@property({ attribute: false }) public description?: TriggerDescription;
|
||||
|
||||
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
|
||||
|
||||
protected render() {
|
||||
@@ -87,11 +91,18 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
<div @value-changed=${this._onUiChanged}>
|
||||
${dynamicElement(`ha-automation-trigger-${type}`, {
|
||||
hass: this.hass,
|
||||
trigger: this.trigger,
|
||||
disabled: this.disabled,
|
||||
})}
|
||||
${this.description
|
||||
? html`<ha-automation-trigger-platform
|
||||
.hass=${this.hass}
|
||||
.trigger=${this.trigger}
|
||||
.description=${this.description}
|
||||
.disabled=${this.disabled}
|
||||
></ha-automation-trigger-platform>`
|
||||
: dynamicElement(`ha-automation-trigger-${type}`, {
|
||||
hass: this.hass,
|
||||
trigger: this.trigger,
|
||||
disabled: this.disabled,
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
@@ -40,9 +40,11 @@ import "../../../../components/ha-md-button-menu";
|
||||
import "../../../../components/ha-md-divider";
|
||||
import "../../../../components/ha-md-menu-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { TRIGGER_ICONS } from "../../../../components/ha-trigger-icon";
|
||||
import type {
|
||||
AutomationClipboard,
|
||||
Trigger,
|
||||
TriggerList,
|
||||
TriggerSidebarConfig,
|
||||
} from "../../../../data/automation";
|
||||
import { isTrigger, subscribeTrigger } from "../../../../data/automation";
|
||||
@@ -50,7 +52,8 @@ import { describeTrigger } from "../../../../data/automation_i18n";
|
||||
import { validateConfig } from "../../../../data/config";
|
||||
import { fullEntitiesContext } from "../../../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
|
||||
import { TRIGGER_ICONS, isTriggerList } from "../../../../data/trigger";
|
||||
import type { TriggerDescriptions } from "../../../../data/trigger";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showPromptDialog,
|
||||
@@ -72,6 +75,7 @@ import "./types/ha-automation-trigger-list";
|
||||
import "./types/ha-automation-trigger-mqtt";
|
||||
import "./types/ha-automation-trigger-numeric_state";
|
||||
import "./types/ha-automation-trigger-persistent_notification";
|
||||
import "./types/ha-automation-trigger-platform";
|
||||
import "./types/ha-automation-trigger-state";
|
||||
import "./types/ha-automation-trigger-sun";
|
||||
import "./types/ha-automation-trigger-tag";
|
||||
@@ -137,6 +141,9 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
|
||||
@state() private _warnings?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public triggerDescriptions: TriggerDescriptions = {};
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@query("ha-automation-trigger-editor")
|
||||
@@ -178,18 +185,24 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
}
|
||||
|
||||
private _renderRow() {
|
||||
const type = this._getType(this.trigger);
|
||||
const type = this._getType(this.trigger, this.triggerDescriptions);
|
||||
|
||||
const supported = this._uiSupported(type);
|
||||
|
||||
const yamlMode = this._yamlMode || !supported;
|
||||
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
class="trigger-icon"
|
||||
.path=${TRIGGER_ICONS[type]}
|
||||
></ha-svg-icon>
|
||||
${type === "list"
|
||||
? html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
class="trigger-icon"
|
||||
.path=${TRIGGER_ICONS[type]}
|
||||
></ha-svg-icon>`
|
||||
: html`<ha-trigger-icon
|
||||
slot="leading-icon"
|
||||
.hass=${this.hass}
|
||||
.trigger=${(this.trigger as Exclude<Trigger, TriggerList>).trigger}
|
||||
></ha-trigger-icon>`}
|
||||
<h3 slot="header">
|
||||
${describeTrigger(this.trigger, this.hass, this._entityReg)}
|
||||
</h3>
|
||||
@@ -393,6 +406,9 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
<ha-automation-trigger-editor
|
||||
.hass=${this.hass}
|
||||
.trigger=${this.trigger}
|
||||
.description=${"trigger" in this.trigger
|
||||
? this.triggerDescriptions[this.trigger.trigger]
|
||||
: undefined}
|
||||
.disabled=${this.disabled}
|
||||
.yamlMode=${this._yamlMode}
|
||||
.uiSupported=${supported}
|
||||
@@ -552,6 +568,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
}
|
||||
|
||||
public openSidebar(trigger?: Trigger): void {
|
||||
trigger = trigger || this.trigger;
|
||||
fireEvent(this, "open-sidebar", {
|
||||
save: (value) => {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
@@ -576,8 +593,14 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
duplicate: this._duplicateTrigger,
|
||||
cut: this._cutTrigger,
|
||||
insertAfter: this._insertAfter,
|
||||
config: trigger || this.trigger,
|
||||
uiSupported: this._uiSupported(this._getType(trigger || this.trigger)),
|
||||
config: trigger,
|
||||
uiSupported: this._uiSupported(
|
||||
this._getType(trigger, this.triggerDescriptions)
|
||||
),
|
||||
description:
|
||||
"trigger" in trigger
|
||||
? this.triggerDescriptions[trigger.trigger]
|
||||
: undefined,
|
||||
yamlMode: this._yamlMode,
|
||||
} satisfies TriggerSidebarConfig);
|
||||
this._selected = true;
|
||||
@@ -759,8 +782,18 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _getType = memoizeOne((trigger: Trigger) =>
|
||||
isTriggerList(trigger) ? "list" : trigger.trigger
|
||||
private _getType = memoizeOne(
|
||||
(trigger: Trigger, triggerDescriptions: TriggerDescriptions) => {
|
||||
if (isTriggerList(trigger)) {
|
||||
return "list";
|
||||
}
|
||||
|
||||
if (trigger.trigger in triggerDescriptions) {
|
||||
return "platform";
|
||||
}
|
||||
|
||||
return trigger.trigger;
|
||||
}
|
||||
);
|
||||
|
||||
private _uiSupported = memoizeOne(
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
@@ -12,12 +13,16 @@ import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-button-menu";
|
||||
import "../../../../components/ha-sortable";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type {
|
||||
AutomationClipboard,
|
||||
Trigger,
|
||||
TriggerList,
|
||||
import {
|
||||
getValueFromDynamic,
|
||||
isDynamic,
|
||||
type AutomationClipboard,
|
||||
type Trigger,
|
||||
type TriggerList,
|
||||
} from "../../../../data/automation";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import type { TriggerDescriptions } from "../../../../data/trigger";
|
||||
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
PASTE_VALUE,
|
||||
@@ -26,10 +31,9 @@ import {
|
||||
import { automationRowsStyles } from "../styles";
|
||||
import "./ha-automation-trigger-row";
|
||||
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
|
||||
@customElement("ha-automation-trigger")
|
||||
export default class HaAutomationTrigger extends LitElement {
|
||||
export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public triggers!: Trigger[];
|
||||
@@ -62,6 +66,23 @@ export default class HaAutomationTrigger extends LitElement {
|
||||
|
||||
private _triggerKeys = new WeakMap<Trigger, string>();
|
||||
|
||||
@state() private _triggerDescriptions: TriggerDescriptions = {};
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
subscribeTriggers(this.hass, (triggers) => this._addTriggers(triggers)),
|
||||
];
|
||||
}
|
||||
|
||||
private _addTriggers(triggers: TriggerDescriptions) {
|
||||
this._triggerDescriptions = { ...this._triggerDescriptions, ...triggers };
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable
|
||||
@@ -85,6 +106,7 @@ export default class HaAutomationTrigger extends LitElement {
|
||||
.first=${idx === 0}
|
||||
.last=${idx === this.triggers.length - 1}
|
||||
.trigger=${trg}
|
||||
.triggerDescriptions=${this._triggerDescriptions}
|
||||
@duplicate=${this._duplicateTrigger}
|
||||
@insert-after=${this._insertAfter}
|
||||
@move-down=${this._moveDown}
|
||||
@@ -156,6 +178,10 @@ export default class HaAutomationTrigger extends LitElement {
|
||||
let triggers: Trigger[];
|
||||
if (value === PASTE_VALUE) {
|
||||
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger));
|
||||
} else if (isDynamic(value)) {
|
||||
triggers = this.triggers.concat({
|
||||
trigger: getValueFromDynamic(value),
|
||||
});
|
||||
} else {
|
||||
const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
|
||||
const elClass = customElements.get(
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { mdiHelpCircle } 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 { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../../../../common/entity/compute_domain";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import type { PlatformTrigger } from "../../../../../data/automation";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
import {
|
||||
getTriggerDomain,
|
||||
getTriggerObjectId,
|
||||
type TriggerDescription,
|
||||
} from "../../../../../data/trigger";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
|
||||
const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
|
||||
field.selector &&
|
||||
!field.required &&
|
||||
!("boolean" in field.selector && field.default);
|
||||
|
||||
@customElement("ha-automation-trigger-platform")
|
||||
export class HaPlatformTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public trigger!: PlatformTrigger;
|
||||
|
||||
@property({ attribute: false }) public description?: TriggerDescription;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _checkedKeys = new Set();
|
||||
|
||||
@state() private _manifest?: IntegrationManifest;
|
||||
|
||||
public static get defaultConfig(): PlatformTrigger {
|
||||
return { trigger: "" };
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
super.willUpdate(changedProperties);
|
||||
if (!this.hasUpdated) {
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
this.hass.loadBackendTranslation("selector");
|
||||
}
|
||||
if (!changedProperties.has("trigger")) {
|
||||
return;
|
||||
}
|
||||
const oldValue = changedProperties.get("trigger") as
|
||||
| undefined
|
||||
| this["trigger"];
|
||||
|
||||
// Fetch the manifest if we have a trigger selected and the trigger domain changed.
|
||||
// If no trigger is selected, clear the manifest.
|
||||
if (this.trigger?.trigger) {
|
||||
const domain = getTriggerDomain(this.trigger.trigger);
|
||||
|
||||
const oldDomain = getTriggerDomain(oldValue?.trigger || "");
|
||||
|
||||
if (domain !== oldDomain) {
|
||||
this._fetchManifest(domain);
|
||||
}
|
||||
} else {
|
||||
this._manifest = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const domain = getTriggerDomain(this.trigger.trigger);
|
||||
const triggerName = getTriggerObjectId(this.trigger.trigger);
|
||||
|
||||
const description = this.hass.localize(
|
||||
`component.${domain}.triggers.${triggerName}.description`
|
||||
);
|
||||
|
||||
const triggerDesc = this.description;
|
||||
|
||||
const shouldRenderDataYaml = !triggerDesc?.fields;
|
||||
|
||||
const hasOptional = Boolean(
|
||||
triggerDesc?.fields &&
|
||||
Object.values(triggerDesc.fields).some((field) =>
|
||||
showOptionalToggle(field)
|
||||
)
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
${description ? html`<p>${description}</p>` : nothing}
|
||||
${this._manifest
|
||||
? html`<a
|
||||
href=${this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
)
|
||||
: this._manifest.documentation}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.service-control.integration_doc"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ha-icon-button
|
||||
.path=${mdiHelpCircle}
|
||||
class="help-icon"
|
||||
></ha-icon-button>
|
||||
</a>`
|
||||
: nothing}
|
||||
</div>
|
||||
${triggerDesc && "target" in triggerDesc
|
||||
? html`<ha-settings-row narrow>
|
||||
${hasOptional
|
||||
? html`<div slot="prefix" class="checkbox-spacer"></div>`
|
||||
: nothing}
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target"
|
||||
)}</span
|
||||
>
|
||||
<span slot="description"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target_secondary"
|
||||
)}</span
|
||||
><ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(triggerDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.trigger?.target}
|
||||
></ha-selector
|
||||
></ha-settings-row>`
|
||||
: nothing}
|
||||
${shouldRenderDataYaml
|
||||
? html`<ha-yaml-editor
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.service-control.action_data"
|
||||
)}
|
||||
.name=${"data"}
|
||||
.readOnly=${this.disabled}
|
||||
.defaultValue=${this.trigger?.options}
|
||||
@value-changed=${this._dataChanged}
|
||||
></ha-yaml-editor>`
|
||||
: Object.entries(triggerDesc.fields).map(([fieldName, dataField]) =>
|
||||
this._renderField(
|
||||
fieldName,
|
||||
dataField,
|
||||
hasOptional,
|
||||
domain,
|
||||
triggerName
|
||||
)
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _targetSelector = memoizeOne(
|
||||
(targetSelector: TargetSelector["target"] | null | undefined) =>
|
||||
targetSelector ? { target: { ...targetSelector } } : { target: {} }
|
||||
);
|
||||
|
||||
private _renderField = (
|
||||
fieldName: string,
|
||||
dataField: TriggerDescription["fields"][string],
|
||||
hasOptional: boolean,
|
||||
domain: string | undefined,
|
||||
triggerName: string | undefined
|
||||
) => {
|
||||
const selector = dataField?.selector ?? { text: null };
|
||||
|
||||
const showOptional = showOptionalToggle(dataField);
|
||||
|
||||
return dataField.selector
|
||||
? html`<ha-settings-row narrow>
|
||||
${!showOptional
|
||||
? hasOptional
|
||||
? html`<div slot="prefix" class="checkbox-spacer"></div>`
|
||||
: nothing
|
||||
: html`<ha-checkbox
|
||||
.key=${fieldName}
|
||||
.checked=${this._checkedKeys.has(fieldName) ||
|
||||
(this.trigger?.options &&
|
||||
this.trigger.options[fieldName] !== undefined)}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._checkboxChanged}
|
||||
slot="prefix"
|
||||
></ha-checkbox>`}
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.triggers.${triggerName}.fields.${fieldName}.name`
|
||||
) || triggerName}</span
|
||||
>
|
||||
<span slot="description"
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.triggers.${triggerName}.fields.${fieldName}.description`
|
||||
)}</span
|
||||
>
|
||||
<ha-selector
|
||||
.disabled=${this.disabled ||
|
||||
(showOptional &&
|
||||
!this._checkedKeys.has(fieldName) &&
|
||||
(!this.trigger?.options ||
|
||||
this.trigger.options[fieldName] === undefined))}
|
||||
.hass=${this.hass}
|
||||
.selector=${selector}
|
||||
.context=${this._generateContext(dataField)}
|
||||
.key=${fieldName}
|
||||
@value-changed=${this._dataChanged}
|
||||
.value=${this.trigger?.options
|
||||
? this.trigger.options[fieldName]
|
||||
: undefined}
|
||||
.placeholder=${dataField.default}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
></ha-selector>
|
||||
</ha-settings-row>`
|
||||
: nothing;
|
||||
};
|
||||
|
||||
private _generateContext(
|
||||
field: TriggerDescription["fields"][string]
|
||||
): Record<string, any> | undefined {
|
||||
if (!field.context) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const context = {};
|
||||
for (const [context_key, data_key] of Object.entries(field.context)) {
|
||||
context[context_key] =
|
||||
data_key === "target"
|
||||
? this.trigger.target
|
||||
: this.trigger.options?.[data_key];
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private _dataChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (ev.detail.isValid === false) {
|
||||
// Don't clear an object selector that returns invalid YAML
|
||||
return;
|
||||
}
|
||||
const key = (ev.currentTarget as any).key;
|
||||
const value = ev.detail.value;
|
||||
if (
|
||||
this.trigger?.options?.[key] === value ||
|
||||
((!this.trigger?.options || !(key in this.trigger.options)) &&
|
||||
(value === "" || value === undefined))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = { ...this.trigger?.options, [key]: value };
|
||||
|
||||
if (
|
||||
value === "" ||
|
||||
value === undefined ||
|
||||
(typeof value === "object" && !Object.keys(value).length)
|
||||
) {
|
||||
delete options[key];
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.trigger,
|
||||
options,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _targetChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.trigger,
|
||||
target: ev.detail.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _checkboxChanged(ev) {
|
||||
const checked = ev.currentTarget.checked;
|
||||
const key = ev.currentTarget.key;
|
||||
let options;
|
||||
|
||||
if (checked) {
|
||||
this._checkedKeys.add(key);
|
||||
const field =
|
||||
this.description &&
|
||||
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
|
||||
let defaultValue = field?.default;
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"constant" in field.selector
|
||||
) {
|
||||
defaultValue = field.selector.constant?.value;
|
||||
}
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"boolean" in field.selector
|
||||
) {
|
||||
defaultValue = false;
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
options = {
|
||||
...this.trigger?.options,
|
||||
[key]: defaultValue,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
this._checkedKeys.delete(key);
|
||||
options = { ...this.trigger?.options };
|
||||
delete options[key];
|
||||
}
|
||||
if (options) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.trigger,
|
||||
options,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.requestUpdate("_checkedKeys");
|
||||
}
|
||||
|
||||
private _localizeValueCallback = (key: string) => {
|
||||
if (!this.trigger?.trigger) {
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`component.${computeDomain(this.trigger.trigger)}.selector.${key}`
|
||||
);
|
||||
};
|
||||
|
||||
private async _fetchManifest(integration: string) {
|
||||
this._manifest = undefined;
|
||||
try {
|
||||
this._manifest = await fetchIntegrationManifest(this.hass, integration);
|
||||
} catch (_err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Unable to fetch integration manifest for ${integration}`);
|
||||
// Ignore if loading manifest fails. Probably bad JSON in manifest
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-settings-row {
|
||||
padding: 0 var(--ha-space-4);
|
||||
}
|
||||
ha-settings-row[narrow] {
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
ha-settings-row {
|
||||
--settings-row-content-width: 100%;
|
||||
--settings-row-prefix-display: contents;
|
||||
border-top: var(
|
||||
--service-control-items-border-top,
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-service-picker,
|
||||
ha-entity-picker,
|
||||
ha-yaml-editor {
|
||||
display: block;
|
||||
margin: 0 var(--ha-space-4);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
p {
|
||||
margin: 0 var(--ha-space-4);
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
:host([hide-picker]) p {
|
||||
padding-top: 0;
|
||||
}
|
||||
.checkbox-spacer {
|
||||
width: 32px;
|
||||
}
|
||||
ha-checkbox {
|
||||
margin-left: calc(var(--ha-space-4) * -1);
|
||||
margin-inline-start: calc(var(--ha-space-4) * -1);
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
.help-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.description {
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 2px;
|
||||
padding-inline-end: 2px;
|
||||
padding-inline-start: initial;
|
||||
}
|
||||
.description p {
|
||||
direction: ltr;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-trigger-platform": HaPlatformTrigger;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import { mdiOpenInNew } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import "../../../components/ha-analytics";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-checkbox";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { Analytics } from "../../../data/analytics";
|
||||
import {
|
||||
getAnalyticsDetails,
|
||||
@@ -17,6 +13,8 @@ import {
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { isDevVersion } from "../../../common/config/version";
|
||||
import type { HaSwitch } from "../../../components/ha-switch";
|
||||
|
||||
@customElement("ha-config-analytics")
|
||||
class ConfigAnalytics extends LitElement {
|
||||
@@ -34,10 +32,22 @@ class ConfigAnalytics extends LitElement {
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize("ui.panel.config.analytics.header") ||
|
||||
"Home Assistant analytics"}
|
||||
>
|
||||
<div class="card-content">
|
||||
${error ? html`<div class="error">${error}</div>` : ""}
|
||||
<p>${this.hass.localize("ui.panel.config.analytics.intro")}</p>
|
||||
${error ? html`<div class="error">${error}</div>` : nothing}
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.analytics.intro")}
|
||||
<a
|
||||
href=${documentationUrl(this.hass, "/integrations/analytics/")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize("ui.panel.config.analytics.learn_more")}</a
|
||||
>.
|
||||
</p>
|
||||
<ha-analytics
|
||||
translation_key_panel="config"
|
||||
@analytics-preferences-changed=${this._preferencesChanged}
|
||||
@@ -45,26 +55,50 @@ class ConfigAnalytics extends LitElement {
|
||||
.analytics=${this._analyticsDetails}
|
||||
></ha-analytics>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button @click=${this._save}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.save_button"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
<div class="footer">
|
||||
<ha-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
href=${documentationUrl(this.hass, "/integrations/analytics/")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.analytics.learn_more")}
|
||||
</ha-button>
|
||||
</div>
|
||||
${isDevVersion(this.hass.config.version)
|
||||
? html`<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.analytics.preferences.snapshots.header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.analytics.preferences.snapshots.info"
|
||||
)}
|
||||
<a
|
||||
href=${documentationUrl(this.hass, "/device-database/")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.analytics.preferences.snapshots.learn_more"
|
||||
)}</a
|
||||
>.
|
||||
</p>
|
||||
<ha-settings-row>
|
||||
<span slot="heading" data-for="snapshots">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.analytics.preferences.snapshots.title`
|
||||
)}
|
||||
</span>
|
||||
<span slot="description" data-for="snapshots">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.analytics.preferences.snapshots.description`
|
||||
)}
|
||||
</span>
|
||||
<ha-switch
|
||||
@change=${this._handleDeviceRowClick}
|
||||
.checked=${!!this._analyticsDetails?.preferences.snapshots}
|
||||
.disabled=${this._analyticsDetails === undefined}
|
||||
name="snapshots"
|
||||
>
|
||||
</ha-switch>
|
||||
</ha-settings-row>
|
||||
</div>
|
||||
</ha-card>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -96,11 +130,25 @@ class ConfigAnalytics extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleDeviceRowClick(ev: Event) {
|
||||
const target = ev.target as HaSwitch;
|
||||
|
||||
this._analyticsDetails = {
|
||||
...this._analyticsDetails!,
|
||||
preferences: {
|
||||
...this._analyticsDetails!.preferences,
|
||||
snapshots: target.checked,
|
||||
},
|
||||
};
|
||||
this._save();
|
||||
}
|
||||
|
||||
private _preferencesChanged(event: CustomEvent): void {
|
||||
this._analyticsDetails = {
|
||||
...this._analyticsDetails!,
|
||||
preferences: event.detail.preferences,
|
||||
};
|
||||
this._save();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@@ -117,21 +165,10 @@ class ConfigAnalytics extends LitElement {
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
ha-card:not(:first-of-type) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.footer {
|
||||
padding: 32px 0 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ha-button[size="small"] ha-svg-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
`, // row-reverse so we tab first to "save"
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
EnergySource,
|
||||
FlowFromGridSourceEnergyPreference,
|
||||
FlowToGridSourceEnergyPreference,
|
||||
GridPowerSourceEnergyPreference,
|
||||
GridSourceTypeEnergyPreference,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import {
|
||||
showEnergySettingsGridFlowFromDialog,
|
||||
showEnergySettingsGridFlowToDialog,
|
||||
showEnergySettingsGridPowerDialog,
|
||||
} from "../dialogs/show-dialogs-energy";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
@@ -226,6 +228,58 @@ export class EnergyGridSettings extends LitElement {
|
||||
>
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
${this.hass.localize("ui.panel.config.energy.grid.grid_power")}
|
||||
</h3>
|
||||
${gridSource.power?.map((power) => {
|
||||
const entityState = this.hass.states[power.stat_rate];
|
||||
return html`
|
||||
<div class="row" .source=${power}>
|
||||
${entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${mdiTransmissionTower}
|
||||
></ha-svg-icon>`}
|
||||
<span class="content"
|
||||
>${getStatisticLabel(
|
||||
this.hass,
|
||||
power.stat_rate,
|
||||
this.statsMetadata?.[power.stat_rate]
|
||||
)}</span
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.edit_power"
|
||||
)}
|
||||
@click=${this._editPowerSource}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.delete_power"
|
||||
)}
|
||||
@click=${this._deletePowerSource}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="row border-bottom">
|
||||
<ha-svg-icon .path=${mdiTransmissionTower}></ha-svg-icon>
|
||||
<ha-button
|
||||
@click=${this._addPowerSource}
|
||||
appearance="filled"
|
||||
size="small"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.add_power"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.grid_carbon_footprint"
|
||||
@@ -499,6 +553,97 @@ export class EnergyGridSettings extends LitElement {
|
||||
await this._savePreferences(cleanedPreferences);
|
||||
}
|
||||
|
||||
private _addPowerSource() {
|
||||
const gridSource = this.preferences.energy_sources.find(
|
||||
(src) => src.type === "grid"
|
||||
) as GridSourceTypeEnergyPreference | undefined;
|
||||
showEnergySettingsGridPowerDialog(this, {
|
||||
grid_source: gridSource,
|
||||
saveCallback: async (power) => {
|
||||
let preferences: EnergyPreferences;
|
||||
if (!gridSource) {
|
||||
preferences = {
|
||||
...this.preferences,
|
||||
energy_sources: [
|
||||
...this.preferences.energy_sources,
|
||||
{
|
||||
...emptyGridSourceEnergyPreference(),
|
||||
power: [power],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
preferences = {
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.map((src) =>
|
||||
src.type === "grid"
|
||||
? { ...src, power: [...(gridSource.power || []), power] }
|
||||
: src
|
||||
),
|
||||
};
|
||||
}
|
||||
await this._savePreferences(preferences);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _editPowerSource(ev) {
|
||||
const origSource: GridPowerSourceEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
const gridSource = this.preferences.energy_sources.find(
|
||||
(src) => src.type === "grid"
|
||||
) as GridSourceTypeEnergyPreference | undefined;
|
||||
showEnergySettingsGridPowerDialog(this, {
|
||||
source: { ...origSource },
|
||||
grid_source: gridSource,
|
||||
saveCallback: async (source) => {
|
||||
const power =
|
||||
energySourcesByType(this.preferences).grid![0].power || [];
|
||||
|
||||
const preferences: EnergyPreferences = {
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.map((src) =>
|
||||
src.type === "grid"
|
||||
? {
|
||||
...src,
|
||||
power: power.map((p) => (p === origSource ? source : p)),
|
||||
}
|
||||
: src
|
||||
),
|
||||
};
|
||||
await this._savePreferences(preferences);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async _deletePowerSource(ev) {
|
||||
const sourceToDelete: GridPowerSourceEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const power =
|
||||
energySourcesByType(this.preferences).grid![0].power?.filter(
|
||||
(p) => p !== sourceToDelete
|
||||
) || [];
|
||||
|
||||
const preferences: EnergyPreferences = {
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.map((source) =>
|
||||
source.type === "grid" ? { ...source, power } : source
|
||||
),
|
||||
};
|
||||
|
||||
const cleanedPreferences = this._removeEmptySources(preferences);
|
||||
await this._savePreferences(cleanedPreferences);
|
||||
}
|
||||
|
||||
private _removeEmptySources(preferences: EnergyPreferences) {
|
||||
// Check if grid sources became an empty type and remove if so
|
||||
preferences.energy_sources = preferences.energy_sources.reduce<
|
||||
@@ -507,7 +652,8 @@ export class EnergyGridSettings extends LitElement {
|
||||
if (
|
||||
source.type !== "grid" ||
|
||||
source.flow_from.length > 0 ||
|
||||
source.flow_to.length > 0
|
||||
source.flow_to.length > 0 ||
|
||||
(source.power && source.power.length > 0)
|
||||
) {
|
||||
acc.push(source);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const energyUnitClasses = ["energy"];
|
||||
const powerUnitClasses = ["power"];
|
||||
|
||||
@customElement("dialog-energy-battery-settings")
|
||||
export class DialogEnergyBatterySettings
|
||||
@@ -32,10 +33,14 @@ export class DialogEnergyBatterySettings
|
||||
|
||||
@state() private _energy_units?: string[];
|
||||
|
||||
@state() private _power_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsBatteryDialogParams
|
||||
): Promise<void> {
|
||||
@@ -46,6 +51,9 @@ export class DialogEnergyBatterySettings
|
||||
this._energy_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
||||
).units;
|
||||
this._power_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||
).units;
|
||||
const allSources: string[] = [];
|
||||
this._params.battery_sources.forEach((entry) => {
|
||||
allSources.push(entry.stat_energy_from);
|
||||
@@ -56,6 +64,9 @@ export class DialogEnergyBatterySettings
|
||||
id !== this._source?.stat_energy_from &&
|
||||
id !== this._source?.stat_energy_to
|
||||
);
|
||||
this._excludeListPower = this._params.battery_sources
|
||||
.map((entry) => entry.stat_rate)
|
||||
.filter((id) => id && id !== this._source?.stat_rate) as string[];
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
@@ -72,8 +83,6 @@ export class DialogEnergyBatterySettings
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@@ -85,12 +94,6 @@ export class DialogEnergyBatterySettings
|
||||
@closed=${this.closeDialog}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
<div>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.dialog.entity_para",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -105,6 +108,10 @@ export class DialogEnergyBatterySettings
|
||||
this._source.stat_energy_from,
|
||||
]}
|
||||
@value-changed=${this._statisticToChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.dialog.energy_helper_into",
|
||||
{ unit: this._energy_units?.join(", ") || "" }
|
||||
)}
|
||||
dialogInitialFocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
@@ -121,6 +128,25 @@ export class DialogEnergyBatterySettings
|
||||
this._source.stat_energy_to,
|
||||
]}
|
||||
@value-changed=${this._statisticFromChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.dialog.energy_helper_out",
|
||||
{ unit: this._energy_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
.includeUnitClass=${powerUnitClasses}
|
||||
.value=${this._source.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.dialog.power"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListPower}
|
||||
@value-changed=${this._powerChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.dialog.power_helper",
|
||||
{ unit: this._power_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-button
|
||||
@@ -150,6 +176,10 @@ export class DialogEnergyBatterySettings
|
||||
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
|
||||
}
|
||||
|
||||
private _powerChanged(ev: CustomEvent<{ value: string }>) {
|
||||
this._source = { ...this._source!, stat_rate: ev.detail.value };
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
try {
|
||||
await this._params!.saveCallback(this._source!);
|
||||
@@ -168,7 +198,11 @@ export class DialogEnergyBatterySettings
|
||||
--mdc-dialog-max-width: 430px;
|
||||
}
|
||||
ha-statistic-picker {
|
||||
width: 100%;
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import type { EnergySettingsDeviceDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const energyUnitClasses = ["energy"];
|
||||
const powerUnitClasses = ["power"];
|
||||
|
||||
@customElement("dialog-energy-device-settings")
|
||||
export class DialogEnergyDeviceSettings
|
||||
@@ -35,10 +36,14 @@ export class DialogEnergyDeviceSettings
|
||||
|
||||
@state() private _energy_units?: string[];
|
||||
|
||||
@state() private _power_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
|
||||
private _possibleParents: DeviceConsumptionEnergyPreference[] = [];
|
||||
|
||||
public async showDialog(
|
||||
@@ -50,9 +55,15 @@ export class DialogEnergyDeviceSettings
|
||||
this._energy_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
||||
).units;
|
||||
this._power_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||
).units;
|
||||
this._excludeList = this._params.device_consumptions
|
||||
.map((entry) => entry.stat_consumption)
|
||||
.filter((id) => id !== this._device?.stat_consumption);
|
||||
this._excludeListPower = this._params.device_consumptions
|
||||
.map((entry) => entry.stat_rate)
|
||||
.filter((id) => id && id !== this._device?.stat_rate) as string[];
|
||||
}
|
||||
|
||||
private _computePossibleParents() {
|
||||
@@ -93,8 +104,6 @@ export class DialogEnergyDeviceSettings
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@@ -108,12 +117,6 @@ export class DialogEnergyDeviceSettings
|
||||
@closed=${this.closeDialog}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
<div>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption.dialog.selected_stat_intro",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -125,9 +128,28 @@ export class DialogEnergyDeviceSettings
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption.dialog.selected_stat_intro",
|
||||
{ unit: this._energy_units?.join(", ") || "" }
|
||||
)}
|
||||
dialogInitialFocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
.includeUnitClass=${powerUnitClasses}
|
||||
.value=${this._device?.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption.dialog.device_consumption_power"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListPower}
|
||||
@value-changed=${this._powerStatisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption.dialog.selected_stat_intro",
|
||||
{ unit: this._power_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-textfield
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption.dialog.display_name"
|
||||
@@ -210,6 +232,20 @@ export class DialogEnergyDeviceSettings
|
||||
this._computePossibleParents();
|
||||
}
|
||||
|
||||
private _powerStatisticChanged(ev: CustomEvent<{ value: string }>) {
|
||||
if (!this._device) {
|
||||
return;
|
||||
}
|
||||
const newDevice = {
|
||||
...this._device,
|
||||
stat_rate: ev.detail.value,
|
||||
} as DeviceConsumptionEnergyPreference;
|
||||
if (!newDevice.stat_rate) {
|
||||
delete newDevice.stat_rate;
|
||||
}
|
||||
this._device = newDevice;
|
||||
}
|
||||
|
||||
private _nameChanged(ev) {
|
||||
const newDevice = {
|
||||
...this._device!,
|
||||
@@ -245,15 +281,19 @@ export class DialogEnergyDeviceSettings
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
ha-statistic-picker {
|
||||
width: 100%;
|
||||
}
|
||||
ha-select {
|
||||
margin-top: 16px;
|
||||
margin-top: var(--ha-space-4);
|
||||
width: 100%;
|
||||
}
|
||||
ha-textfield {
|
||||
margin-top: 16px;
|
||||
margin-top: var(--ha-space-4);
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -104,8 +104,6 @@ export class DialogEnergyGridFlowSettings
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
||||
|
||||
const unitPriceFixed = `${this.hass.config.currency}/kWh`;
|
||||
|
||||
const externalSource =
|
||||
@@ -135,19 +133,11 @@ export class DialogEnergyGridFlowSettings
|
||||
@closed=${this.closeDialog}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
<div>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.entity_para`,
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -163,6 +153,10 @@ export class DialogEnergyGridFlowSettings
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.entity_para`,
|
||||
{ unit: this._energy_units?.join(", ") || "" }
|
||||
)}
|
||||
dialogInitialFocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
@@ -361,6 +355,10 @@ export class DialogEnergyGridFlowSettings
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 430px;
|
||||
}
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin: var(--ha-space-4) 0;
|
||||
}
|
||||
ha-formfield {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { mdiTransmissionTower } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-button";
|
||||
import type { GridPowerSourceEnergyPreference } from "../../../../data/energy";
|
||||
import { energyStatisticHelpUrl } from "../../../../data/energy";
|
||||
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { EnergySettingsGridPowerDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const powerUnitClasses = ["power"];
|
||||
|
||||
@customElement("dialog-energy-grid-power-settings")
|
||||
export class DialogEnergyGridPowerSettings
|
||||
extends LitElement
|
||||
implements HassDialog<EnergySettingsGridPowerDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: EnergySettingsGridPowerDialogParams;
|
||||
|
||||
@state() private _source?: GridPowerSourceEnergyPreference;
|
||||
|
||||
@state() private _power_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsGridPowerDialogParams
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
this._source = params.source ? { ...params.source } : { stat_rate: "" };
|
||||
|
||||
const initialSourceIdPower = this._source.stat_rate;
|
||||
|
||||
this._power_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||
).units;
|
||||
|
||||
this._excludeListPower = [
|
||||
...(this._params.grid_source?.power?.map((entry) => entry.stat_rate) ||
|
||||
[]),
|
||||
].filter((id) => id && id !== initialSourceIdPower) as string[];
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._params = undefined;
|
||||
this._source = undefined;
|
||||
this._error = undefined;
|
||||
this._excludeListPower = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
return true;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this._source) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
.heading=${html`<ha-svg-icon
|
||||
.path=${mdiTransmissionTower}
|
||||
style="--mdc-icon-size: 32px;"
|
||||
></ha-svg-icon
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.power_dialog.header"
|
||||
)}`}
|
||||
@closed=${this.closeDialog}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
.helpMissingEntityUrl=${energyStatisticHelpUrl}
|
||||
.includeUnitClass=${powerUnitClasses}
|
||||
.value=${this._source.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.power_dialog.power_stat"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListPower}
|
||||
@value-changed=${this._powerStatisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.power_dialog.power_helper",
|
||||
{ unit: this._power_units?.join(", ") || "" }
|
||||
)}
|
||||
dialogInitialFocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
slot="primaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${!this._source.stat_rate}
|
||||
slot="primaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _powerStatisticChanged(ev: CustomEvent<{ value: string }>) {
|
||||
this._source = {
|
||||
...this._source!,
|
||||
stat_rate: ev.detail.value,
|
||||
};
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
try {
|
||||
await this._params!.saveCallback(this._source!);
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 430px;
|
||||
}
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin: var(--ha-space-4) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-energy-grid-power-settings": DialogEnergyGridPowerSettings;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { brandsUrl } from "../../../../util/brands-url";
|
||||
import type { EnergySettingsSolarDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const energyUnitClasses = ["energy"];
|
||||
const powerUnitClasses = ["power"];
|
||||
|
||||
@customElement("dialog-energy-solar-settings")
|
||||
export class DialogEnergySolarSettings
|
||||
@@ -46,10 +47,14 @@ export class DialogEnergySolarSettings
|
||||
|
||||
@state() private _energy_units?: string[];
|
||||
|
||||
@state() private _power_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsSolarDialogParams
|
||||
): Promise<void> {
|
||||
@@ -62,9 +67,15 @@ export class DialogEnergySolarSettings
|
||||
this._energy_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
||||
).units;
|
||||
this._power_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||
).units;
|
||||
this._excludeList = this._params.solar_sources
|
||||
.map((entry) => entry.stat_energy_from)
|
||||
.filter((id) => id !== this._source?.stat_energy_from);
|
||||
this._excludeListPower = this._params.solar_sources
|
||||
.map((entry) => entry.stat_rate)
|
||||
.filter((id) => id && id !== this._source?.stat_rate) as string[];
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
@@ -81,8 +92,6 @@ export class DialogEnergySolarSettings
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@@ -94,12 +103,6 @@ export class DialogEnergySolarSettings
|
||||
@closed=${this.closeDialog}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
<div>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.entity_para",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -111,9 +114,28 @@ export class DialogEnergySolarSettings
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.entity_para",
|
||||
{ unit: this._energy_units?.join(", ") || "" }
|
||||
)}
|
||||
dialogInitialFocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
.includeUnitClass=${powerUnitClasses}
|
||||
.value=${this._source.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.solar_production_power"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListPower}
|
||||
@value-changed=${this._powerStatisticChanged}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.entity_para",
|
||||
{ unit: this._power_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.solar_production_forecast"
|
||||
@@ -267,6 +289,10 @@ export class DialogEnergySolarSettings
|
||||
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
|
||||
}
|
||||
|
||||
private _powerStatisticChanged(ev: CustomEvent<{ value: string }>) {
|
||||
this._source = { ...this._source!, stat_rate: ev.detail.value };
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
try {
|
||||
if (!this._forecast) {
|
||||
@@ -287,6 +313,10 @@ export class DialogEnergySolarSettings
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 430px;
|
||||
}
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
img {
|
||||
height: 24px;
|
||||
margin-right: 16px;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
FlowFromGridSourceEnergyPreference,
|
||||
FlowToGridSourceEnergyPreference,
|
||||
GasSourceTypeEnergyPreference,
|
||||
GridPowerSourceEnergyPreference,
|
||||
GridSourceTypeEnergyPreference,
|
||||
SolarSourceTypeEnergyPreference,
|
||||
WaterSourceTypeEnergyPreference,
|
||||
@@ -41,6 +42,12 @@ export interface EnergySettingsGridFlowToDialogParams {
|
||||
saveCallback: (source: FlowToGridSourceEnergyPreference) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface EnergySettingsGridPowerDialogParams {
|
||||
source?: GridPowerSourceEnergyPreference;
|
||||
grid_source?: GridSourceTypeEnergyPreference;
|
||||
saveCallback: (source: GridPowerSourceEnergyPreference) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface EnergySettingsSolarDialogParams {
|
||||
info: EnergyInfo;
|
||||
source?: SolarSourceTypeEnergyPreference;
|
||||
@@ -152,3 +159,14 @@ export const showEnergySettingsGridFlowToDialog = (
|
||||
dialogParams: { ...dialogParams, direction: "to" },
|
||||
});
|
||||
};
|
||||
|
||||
export const showEnergySettingsGridPowerDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: EnergySettingsGridPowerDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-energy-grid-power-settings",
|
||||
dialogImport: () => import("./dialog-energy-grid-power-settings"),
|
||||
dialogParams: dialogParams,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -426,6 +426,10 @@ class DialogZHAReconfigureDevice extends LitElement {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 800px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 1fr 2fr;
|
||||
|
||||
@@ -360,6 +360,20 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.hass.panels.home) {
|
||||
result.push({
|
||||
icon: this.hass.panels.home.icon || "mdi:home",
|
||||
title: this.hass.localize("panel.home"),
|
||||
show_in_sidebar: true,
|
||||
mode: "storage",
|
||||
url_path: "home",
|
||||
filename: "",
|
||||
default: false,
|
||||
require_admin: false,
|
||||
type: this._localizeType("built_in"),
|
||||
});
|
||||
}
|
||||
|
||||
result.push(
|
||||
...dashboards
|
||||
.sort((a, b) =>
|
||||
@@ -470,13 +484,18 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
}
|
||||
|
||||
private _canDelete(urlPath: string) {
|
||||
return !["lovelace", "energy", "light", "security", "climate"].includes(
|
||||
urlPath
|
||||
);
|
||||
return ![
|
||||
"lovelace",
|
||||
"energy",
|
||||
"light",
|
||||
"security",
|
||||
"climate",
|
||||
"home",
|
||||
].includes(urlPath);
|
||||
}
|
||||
|
||||
private _canEdit(urlPath: string) {
|
||||
return !["light", "security", "climate"].includes(urlPath);
|
||||
return !["light", "security", "climate", "home"].includes(urlPath);
|
||||
}
|
||||
|
||||
private _handleDelete = async (item: DataTableItem) => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state, query } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-md-dialog";
|
||||
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-wa-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-button";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { LovelaceResourcesMutableParams } from "../../../../data/lovelace/resource";
|
||||
@@ -43,7 +41,7 @@ export class DialogLovelaceResourceDetail extends LitElement {
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
@state() private _open = false;
|
||||
|
||||
public showDialog(params: LovelaceResourceDetailsDialogParams): void {
|
||||
this._params = params;
|
||||
@@ -58,6 +56,11 @@ export class DialogLovelaceResourceDetail extends LitElement {
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
@@ -65,10 +68,6 @@ export class DialogLovelaceResourceDetail extends LitElement {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._dialog?.close();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
@@ -81,56 +80,45 @@ export class DialogLovelaceResourceDetail extends LitElement {
|
||||
"ui.panel.config.lovelace.resources.detail.new_resource"
|
||||
);
|
||||
|
||||
const ariaLabel = this._params.resource?.url
|
||||
? this.hass!.localize(
|
||||
"ui.panel.config.lovelace.resources.detail.edit_resource"
|
||||
)
|
||||
: this.hass!.localize(
|
||||
"ui.panel.config.lovelace.resources.detail.new_resource"
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-md-dialog
|
||||
open
|
||||
disable-cancel-action
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
prevent-scrim-close
|
||||
header-title=${dialogTitle}
|
||||
@closed=${this._dialogClosed}
|
||||
.ariaLabel=${ariaLabel}
|
||||
>
|
||||
<ha-dialog-header slot="headline">
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
.label=${this.hass.localize("ui.common.close") ?? "Close"}
|
||||
.path=${mdiClose}
|
||||
@click=${this.closeDialog}
|
||||
></ha-icon-button>
|
||||
<span slot="title" .title=${dialogTitle}> ${dialogTitle} </span>
|
||||
</ha-dialog-header>
|
||||
<div slot="content">
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass!.localize(
|
||||
"ui.panel.config.lovelace.resources.detail.warning_header"
|
||||
)}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.lovelace.resources.detail.warning_text"
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.hass!.localize(
|
||||
"ui.panel.config.lovelace.resources.detail.warning_header"
|
||||
)}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.lovelace.resources.detail.warning_text"
|
||||
)}
|
||||
</ha-alert>
|
||||
|
||||
<ha-form
|
||||
.schema=${this._schema(this._data)}
|
||||
.data=${this._data}
|
||||
.hass=${this.hass}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
</div>
|
||||
<div slot="actions">
|
||||
<ha-button appearance="plain" @click=${this.closeDialog}>
|
||||
<ha-form
|
||||
autofocus
|
||||
.schema=${this._schema(this._data)}
|
||||
.data=${this._data}
|
||||
.hass=${this.hass}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass!.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._updateResource}
|
||||
.disabled=${urlInvalid || !this._data?.res_type || this._submitting}
|
||||
>
|
||||
@@ -142,8 +130,8 @@ export class DialogLovelaceResourceDetail extends LitElement {
|
||||
"ui.panel.config.lovelace.resources.detail.create"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,13 +65,15 @@ export default class HaScriptFieldRow extends LitElement {
|
||||
private _selectorRowElement?: HaAutomationRow;
|
||||
|
||||
protected render() {
|
||||
const hasSelector =
|
||||
this.field.selector && typeof this.field.selector === "object";
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._toggleSidebar}
|
||||
.selected=${this._selected}
|
||||
left-chevron
|
||||
.leftChevron=${hasSelector}
|
||||
@toggle-collapsed=${this._toggleCollapse}
|
||||
.collapsed=${this._collapsed}
|
||||
.highlight=${this.highlight}
|
||||
@@ -140,117 +142,124 @@ export default class HaScriptFieldRow extends LitElement {
|
||||
<slot name="icons" slot="icons"></slot>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
<div
|
||||
class=${classMap({
|
||||
"selector-row": true,
|
||||
"parent-selected": this._selected,
|
||||
hidden: this._collapsed,
|
||||
})}
|
||||
>
|
||||
<ha-card>
|
||||
<ha-automation-row
|
||||
.selected=${this._selectorRowSelected}
|
||||
@click=${this._toggleSelectorSidebar}
|
||||
.collapsed=${this._selectorRowCollapsed}
|
||||
@toggle-collapsed=${this._toggleSelectorRowCollapse}
|
||||
.leftChevron=${SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
|
||||
Object.keys(this.field.selector)[0]
|
||||
)}
|
||||
.highlight=${this.highlight}
|
||||
>
|
||||
<h3 slot="header">
|
||||
${this.hass.localize(
|
||||
`ui.components.selectors.selector.types.${Object.keys(this.field.selector)[0]}` as LocalizeKeys
|
||||
)}
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.script.editor.field.selector"
|
||||
)}
|
||||
</h3>
|
||||
<ha-md-button-menu
|
||||
quick
|
||||
slot="icons"
|
||||
@click=${preventDefaultStopPropagation}
|
||||
@keydown=${stopPropagation}
|
||||
@closed=${stopPropagation}
|
||||
positioning="fixed"
|
||||
anchor-corner="end-end"
|
||||
menu-corner="start-end"
|
||||
${hasSelector
|
||||
? html`
|
||||
<div
|
||||
class=${classMap({
|
||||
"selector-row": true,
|
||||
"parent-selected": this._selected,
|
||||
hidden: this._collapsed,
|
||||
})}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-md-menu-item
|
||||
.clickAction=${this._toggleYamlMode}
|
||||
selector-row
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPlaylistEdit}
|
||||
></ha-svg-icon>
|
||||
<div class="overflow-label">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
|
||||
<ha-card>
|
||||
<ha-automation-row
|
||||
.selected=${this._selectorRowSelected}
|
||||
@click=${this._toggleSelectorSidebar}
|
||||
.collapsed=${this._selectorRowCollapsed}
|
||||
@toggle-collapsed=${this._toggleSelectorRowCollapse}
|
||||
.leftChevron=${SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
|
||||
Object.keys(this.field.selector)[0]
|
||||
)}
|
||||
<span
|
||||
class="shortcut-placeholder ${isMac ? "mac" : ""}"
|
||||
></span>
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item
|
||||
.clickAction=${this._onDelete}
|
||||
.disabled=${this.disabled}
|
||||
class="warning"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
|
||||
<div class="overflow-label">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.delete"
|
||||
)}
|
||||
${!this.narrow
|
||||
? html`<span class="shortcut">
|
||||
.highlight=${this.highlight}
|
||||
>
|
||||
<h3 slot="header">
|
||||
${this.hass.localize(
|
||||
`ui.components.selectors.selector.types.${Object.keys(this.field.selector)[0]}` as LocalizeKeys
|
||||
)}
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.script.editor.field.selector"
|
||||
)}
|
||||
</h3>
|
||||
<ha-md-button-menu
|
||||
quick
|
||||
slot="icons"
|
||||
@click=${preventDefaultStopPropagation}
|
||||
@keydown=${stopPropagation}
|
||||
@closed=${stopPropagation}
|
||||
positioning="fixed"
|
||||
anchor-corner="end-end"
|
||||
menu-corner="start-end"
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-md-menu-item
|
||||
.clickAction=${this._toggleYamlMode}
|
||||
selector-row
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPlaylistEdit}
|
||||
></ha-svg-icon>
|
||||
<div class="overflow-label">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
|
||||
)}
|
||||
<span
|
||||
>${isMac
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiAppleKeyboardCommand}
|
||||
></ha-svg-icon>`
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.ctrl"
|
||||
)}</span
|
||||
>
|
||||
<span>+</span>
|
||||
<span
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.del"
|
||||
)}</span
|
||||
>
|
||||
</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
</ha-md-button-menu>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
${typeof this.field.selector === "object" &&
|
||||
SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
|
||||
Object.keys(this.field.selector)[0]
|
||||
)
|
||||
? html`
|
||||
<ha-script-field-selector-editor
|
||||
class=${this._selectorRowCollapsed ? "hidden" : ""}
|
||||
.selected=${this._selectorRowSelected}
|
||||
.hass=${this.hass}
|
||||
.field=${this.field}
|
||||
.disabled=${this.disabled}
|
||||
indent
|
||||
@value-changed=${this._selectorValueChanged}
|
||||
.narrow=${this.narrow}
|
||||
></ha-script-field-selector-editor>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
class="shortcut-placeholder ${isMac ? "mac" : ""}"
|
||||
></span>
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item
|
||||
.clickAction=${this._onDelete}
|
||||
.disabled=${this.disabled}
|
||||
class="warning"
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiDelete}
|
||||
></ha-svg-icon>
|
||||
<div class="overflow-label">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.delete"
|
||||
)}
|
||||
${!this.narrow
|
||||
? html`<span class="shortcut">
|
||||
<span
|
||||
>${isMac
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiAppleKeyboardCommand}
|
||||
></ha-svg-icon>`
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.ctrl"
|
||||
)}</span
|
||||
>
|
||||
<span>+</span>
|
||||
<span
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.del"
|
||||
)}</span
|
||||
>
|
||||
</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
</ha-md-button-menu>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
${typeof this.field.selector === "object" &&
|
||||
SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
|
||||
Object.keys(this.field.selector)[0]
|
||||
)
|
||||
? html`
|
||||
<ha-script-field-selector-editor
|
||||
class=${this._selectorRowCollapsed ? "hidden" : ""}
|
||||
.selected=${this._selectorRowSelected}
|
||||
.hass=${this.hass}
|
||||
.field=${this.field}
|
||||
.disabled=${this.disabled}
|
||||
indent
|
||||
@value-changed=${this._selectorValueChanged}
|
||||
.narrow=${this.narrow}
|
||||
></ha-script-field-selector-editor>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
144
src/panels/home/ha-panel-home.ts
Normal file
144
src/panels/home/ha-panel-home.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
|
||||
import type { HomeAssistant, PanelInfo, Route } from "../../types";
|
||||
import "../lovelace/hui-root";
|
||||
import { generateLovelaceDashboardStrategy } from "../lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import { showAlertDialog } from "../lovelace/custom-card-helpers";
|
||||
|
||||
const HOME_LOVELACE_CONFIG: LovelaceDashboardStrategyConfig = {
|
||||
strategy: {
|
||||
type: "home",
|
||||
},
|
||||
};
|
||||
|
||||
@customElement("ha-panel-home")
|
||||
class PanelHome extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public route?: Route;
|
||||
|
||||
@property({ attribute: false }) public panel?: PanelInfo;
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
if (!this.hasUpdated) {
|
||||
this.hass.loadFragmentTranslation("lovelace");
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!changedProps.has("hass")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
oldHass.areas !== this.hass.areas ||
|
||||
oldHass.floors !== this.hass.floors
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If ha started, refresh the config
|
||||
if (
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
() => this._registriesChanged(),
|
||||
200
|
||||
);
|
||||
|
||||
private _registriesChanged = async () => {
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
protected render() {
|
||||
if (!this._lovelace) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<hui-root
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.route=${this.route}
|
||||
.panel=${this.panel}
|
||||
></hui-root>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
const config = await generateLovelaceDashboardStrategy(
|
||||
HOME_LOVELACE_CONFIG,
|
||||
this.hass
|
||||
);
|
||||
|
||||
if (deepEqual(config, this._lovelace?.config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lovelace = {
|
||||
config: config,
|
||||
rawConfig: config,
|
||||
editMode: false,
|
||||
urlPath: "home",
|
||||
mode: "generated",
|
||||
locale: this.hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
saveConfig: async () => undefined,
|
||||
deleteConfig: async () => undefined,
|
||||
setEditMode: this._setEditMode,
|
||||
showToast: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private _setEditMode = () => {
|
||||
// For now, we just show an alert that edit mode is not supported.
|
||||
// This will be expanded in the future.
|
||||
showAlertDialog(this, {
|
||||
title: "Edit mode not available",
|
||||
text: "The Home panel does not support edit mode.",
|
||||
confirmText: this.hass.localize("ui.common.ok"),
|
||||
});
|
||||
};
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-panel-home": PanelHome;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} from "../../../mixins/conditional-listener-mixin";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createBadgeElement } from "../create-element/create-badge-element";
|
||||
import { createErrorBadgeConfig } from "../create-element/create-element-base";
|
||||
@@ -22,7 +19,9 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-badge")
|
||||
export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
ReactiveElement
|
||||
) {
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@property({ attribute: false }) public config?: LovelaceBadgeConfig;
|
||||
@@ -53,7 +52,7 @@ export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
this._updateVisibility();
|
||||
}
|
||||
|
||||
private _updateElement(config: LovelaceBadgeConfig) {
|
||||
protected _updateElement(config: LovelaceBadgeConfig) {
|
||||
if (!this._element) {
|
||||
return;
|
||||
}
|
||||
@@ -133,22 +132,7 @@ export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected setupConditionalListeners() {
|
||||
if (!this.config?.visibility || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
setupMediaQueryListeners(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
(conditionsMet) => {
|
||||
this._updateVisibility(conditionsMet);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _updateVisibility(ignoreConditions?: boolean) {
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
if (!this._element || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@@ -169,9 +153,9 @@ export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
}
|
||||
|
||||
const visible =
|
||||
ignoreConditions ||
|
||||
!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass);
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass));
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
CallbackDataParams,
|
||||
LineSeriesOption,
|
||||
TopLevelFormatterParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { LineDataItemOption } from "echarts/types/src/chart/line/LineSeries";
|
||||
import type { FrontendLocaleData } from "../../../../../data/translation";
|
||||
import { formatNumber } from "../../../../../common/number/format_number";
|
||||
import {
|
||||
@@ -170,11 +172,10 @@ function formatTooltip(
|
||||
compare
|
||||
? `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}: `
|
||||
: ""
|
||||
}${formatTime(date, locale, config)} – ${formatTime(
|
||||
addHours(date, 1),
|
||||
locale,
|
||||
config
|
||||
)}`;
|
||||
}${formatTime(date, locale, config)}`;
|
||||
if (params[0].componentSubType === "bar") {
|
||||
period += ` – ${formatTime(addHours(date, 1), locale, config)}`;
|
||||
}
|
||||
}
|
||||
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
|
||||
|
||||
@@ -281,6 +282,35 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export function fillLineGaps(datasets: LineSeriesOption[]) {
|
||||
const buckets = Array.from(
|
||||
new Set(
|
||||
datasets
|
||||
.map((dataset) =>
|
||||
dataset.data!.map((datapoint) => Number(datapoint![0]))
|
||||
)
|
||||
.flat()
|
||||
)
|
||||
).sort((a, b) => a - b);
|
||||
buckets.forEach((bucket, index) => {
|
||||
for (let i = datasets.length - 1; i >= 0; i--) {
|
||||
const dataPoint = datasets[i].data![index];
|
||||
const item: LineDataItemOption =
|
||||
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
|
||||
? dataPoint
|
||||
: ({ value: dataPoint } as LineDataItemOption);
|
||||
const x = item.value?.[0];
|
||||
if (x === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Number(x) !== bucket) {
|
||||
datasets[i].data?.splice(index, 0, [bucket, 0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
return datasets;
|
||||
}
|
||||
|
||||
export function getCompareTransform(start: Date, compareStart?: Date) {
|
||||
if (!compareStart) {
|
||||
return (ts: Date) => ts;
|
||||
|
||||
@@ -60,7 +60,7 @@ export class HuiEnergyDevicesGraphCard
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _chartType: "bar" | "pie" = "bar";
|
||||
private _chartType?: "bar" | "pie";
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
@@ -101,6 +101,14 @@ export class HuiEnergyDevicesGraphCard
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _getAllowedModes(): ("bar" | "pie")[] {
|
||||
// Empty array or undefined = allow all modes
|
||||
if (!this._config?.modes || this._config.modes.length === 0) {
|
||||
return ["bar", "pie"];
|
||||
}
|
||||
return this._config.modes;
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
return (
|
||||
hasConfigChanged(this, changedProps) ||
|
||||
@@ -109,8 +117,21 @@ export class HuiEnergyDevicesGraphCard
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (changedProps.has("_config") && this._config) {
|
||||
const allowedModes = this._getAllowedModes();
|
||||
|
||||
// If _chartType is not set or not in allowed modes, use first from config
|
||||
if (!this._chartType || !allowedModes.includes(this._chartType)) {
|
||||
this._chartType = allowedModes[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
if (!this.hass || !this._config || !this._chartType) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -118,13 +139,19 @@ export class HuiEnergyDevicesGraphCard
|
||||
<ha-card>
|
||||
<div class="card-header">
|
||||
<span>${this._config.title ? this._config.title : nothing}</span>
|
||||
<ha-icon-button
|
||||
.path=${this._chartType === "pie" ? mdiChartBar : mdiChartDonut}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_graph.change_chart_type"
|
||||
)}
|
||||
@click=${this._handleChartTypeChange}
|
||||
></ha-icon-button>
|
||||
${this._getAllowedModes().length > 1
|
||||
? html`
|
||||
<ha-icon-button
|
||||
.path=${this._chartType === "pie"
|
||||
? mdiChartBar
|
||||
: mdiChartDonut}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_graph.change_chart_type"
|
||||
)}
|
||||
@click=${this._handleChartTypeChange}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div
|
||||
class="content ${classMap({
|
||||
@@ -529,7 +556,13 @@ export class HuiEnergyDevicesGraphCard
|
||||
}
|
||||
|
||||
private _handleChartTypeChange(): void {
|
||||
this._chartType = this._chartType === "pie" ? "bar" : "pie";
|
||||
if (!this._chartType) {
|
||||
return;
|
||||
}
|
||||
const allowedModes = this._getAllowedModes();
|
||||
const currentIndex = allowedModes.indexOf(this._chartType);
|
||||
const nextIndex = (currentIndex + 1) % allowedModes.length;
|
||||
this._chartType = allowedModes[nextIndex];
|
||||
this._getStatistics(this._data!);
|
||||
}
|
||||
|
||||
|
||||
335
src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts
Normal file
335
src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { endOfToday, isToday, startOfToday } from "date-fns";
|
||||
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { LineSeriesOption } from "echarts/charts";
|
||||
import { graphic } from "echarts";
|
||||
import "../../../../components/chart/ha-chart-base";
|
||||
import "../../../../components/ha-card";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import { getEnergyDataCollection } from "../../../../data/energy";
|
||||
import type { StatisticValue } from "../../../../data/recorder";
|
||||
import type { FrontendLocaleData } from "../../../../data/translation";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LovelaceCard } from "../../types";
|
||||
import type { PowerSourcesGraphCardConfig } from "../types";
|
||||
import { hasConfigChanged } from "../../common/has-changed";
|
||||
import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options";
|
||||
import type { ECOption } from "../../../../resources/echarts/echarts";
|
||||
import { hex2rgb } from "../../../../common/color/convert-color";
|
||||
|
||||
@customElement("hui-power-sources-graph-card")
|
||||
export class HuiPowerSourcesGraphCard
|
||||
extends SubscribeMixin(LitElement)
|
||||
implements LovelaceCard
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _config?: PowerSourcesGraphCardConfig;
|
||||
|
||||
@state() private _chartData: LineSeriesOption[] = [];
|
||||
|
||||
@state() private _start = startOfToday();
|
||||
|
||||
@state() private _end = endOfToday();
|
||||
|
||||
@state() private _compareStart?: Date;
|
||||
|
||||
@state() private _compareEnd?: Date;
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
getEnergyDataCollection(this.hass, {
|
||||
key: this._config?.collection_key,
|
||||
}).subscribe((data) => this._getStatistics(data)),
|
||||
];
|
||||
}
|
||||
|
||||
public getCardSize(): Promise<number> | number {
|
||||
return 3;
|
||||
}
|
||||
|
||||
public setConfig(config: PowerSourcesGraphCardConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
return (
|
||||
hasConfigChanged(this, changedProps) ||
|
||||
changedProps.size > 1 ||
|
||||
!changedProps.has("hass")
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
${this._config.title
|
||||
? html`<h1 class="card-header">${this._config.title}</h1>`
|
||||
: ""}
|
||||
<div
|
||||
class="content ${classMap({
|
||||
"has-header": !!this._config.title,
|
||||
})}"
|
||||
>
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.data=${this._chartData}
|
||||
.options=${this._createOptions(
|
||||
this._start,
|
||||
this._end,
|
||||
this.hass.locale,
|
||||
this.hass.config,
|
||||
this._compareStart,
|
||||
this._compareEnd
|
||||
)}
|
||||
></ha-chart-base>
|
||||
${!this._chartData.some((dataset) => dataset.data!.length)
|
||||
? html`<div class="no-data">
|
||||
${isToday(this._start)
|
||||
? this.hass.localize("ui.panel.lovelace.cards.energy.no_data")
|
||||
: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.no_data_period"
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _createOptions = memoizeOne(
|
||||
(
|
||||
start: Date,
|
||||
end: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig,
|
||||
compareStart?: Date,
|
||||
compareEnd?: Date
|
||||
): ECOption =>
|
||||
getCommonOptions(
|
||||
start,
|
||||
end,
|
||||
locale,
|
||||
config,
|
||||
"kW",
|
||||
compareStart,
|
||||
compareEnd
|
||||
)
|
||||
);
|
||||
|
||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||
const datasets: LineSeriesOption[] = [];
|
||||
|
||||
const statIds = {
|
||||
solar: {
|
||||
stats: [] as string[],
|
||||
color: "--energy-solar-color",
|
||||
name: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.power_graph.solar"
|
||||
),
|
||||
},
|
||||
grid: {
|
||||
stats: [] as string[],
|
||||
color: "--energy-grid-consumption-color",
|
||||
name: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.power_graph.grid"
|
||||
),
|
||||
},
|
||||
battery: {
|
||||
stats: [] as string[],
|
||||
color: "--energy-battery-out-color",
|
||||
name: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.power_graph.battery"
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
|
||||
for (const source of energyData.prefs.energy_sources) {
|
||||
if (source.type === "solar") {
|
||||
if (source.stat_rate) {
|
||||
statIds.solar.stats.push(source.stat_rate);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.type === "battery") {
|
||||
if (source.stat_rate) {
|
||||
statIds.battery.stats.push(source.stat_rate);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.type === "grid" && source.power) {
|
||||
statIds.grid.stats.push(...source.power.map((p) => p.stat_rate));
|
||||
}
|
||||
}
|
||||
const commonSeriesOptions: LineSeriesOption = {
|
||||
type: "line",
|
||||
smooth: 0.4,
|
||||
smoothMonotone: "x",
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
},
|
||||
};
|
||||
|
||||
Object.keys(statIds).forEach((key, keyIndex) => {
|
||||
if (statIds[key].stats.length) {
|
||||
const colorHex = computedStyles.getPropertyValue(statIds[key].color);
|
||||
const rgb = hex2rgb(colorHex);
|
||||
// Echarts is supposed to handle that but it is bugged when you use it together with stacking.
|
||||
// The interpolation breaks the stacking, so this positive/negative is a workaround
|
||||
const { positive, negative } = this._processData(
|
||||
statIds[key].stats.map((id: string) => energyData.stats[id] ?? [])
|
||||
);
|
||||
datasets.push({
|
||||
...commonSeriesOptions,
|
||||
id: key,
|
||||
name: statIds[key].name,
|
||||
color: colorHex,
|
||||
stack: "positive",
|
||||
areaStyle: {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
|
||||
},
|
||||
]),
|
||||
},
|
||||
data: positive,
|
||||
z: 3 - keyIndex, // draw in reverse order so 0 value lines are overwritten
|
||||
});
|
||||
if (key !== "solar") {
|
||||
datasets.push({
|
||||
...commonSeriesOptions,
|
||||
id: `${key}-negative`,
|
||||
name: statIds[key].name,
|
||||
color: colorHex,
|
||||
stack: "negative",
|
||||
areaStyle: {
|
||||
color: new graphic.LinearGradient(0, 1, 0, 0, [
|
||||
{
|
||||
offset: 0,
|
||||
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.25)`,
|
||||
},
|
||||
]),
|
||||
},
|
||||
data: negative,
|
||||
z: 4 - keyIndex, // draw in reverse order but above positive series
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._start = energyData.start;
|
||||
this._end = energyData.end || endOfToday();
|
||||
|
||||
this._chartData = fillLineGaps(datasets);
|
||||
|
||||
const usageData: NonNullable<LineSeriesOption["data"]> = [];
|
||||
this._chartData[0]?.data!.forEach((item, i) => {
|
||||
// fillLineGaps ensures all datasets have the same x values
|
||||
const x =
|
||||
typeof item === "object" && "value" in item!
|
||||
? item.value![0]
|
||||
: item![0];
|
||||
usageData[i] = [x, 0];
|
||||
this._chartData.forEach((dataset) => {
|
||||
const y =
|
||||
typeof dataset.data![i] === "object" && "value" in dataset.data![i]!
|
||||
? dataset.data![i].value![1]
|
||||
: dataset.data![i]![1];
|
||||
usageData[i]![1] += y as number;
|
||||
});
|
||||
});
|
||||
this._chartData.push({
|
||||
...commonSeriesOptions,
|
||||
id: "usage",
|
||||
name: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.power_graph.usage"
|
||||
),
|
||||
color: computedStyles.getPropertyValue("--primary-color"),
|
||||
lineStyle: { width: 2 },
|
||||
data: usageData,
|
||||
z: 5,
|
||||
});
|
||||
}
|
||||
|
||||
private _processData(stats: StatisticValue[][]) {
|
||||
const data: Record<number, number[]> = {};
|
||||
stats.forEach((statSet) => {
|
||||
statSet.forEach((point) => {
|
||||
if (point.mean == null) {
|
||||
return;
|
||||
}
|
||||
const x = (point.start + point.end) / 2;
|
||||
data[x] = [...(data[x] ?? []), point.mean];
|
||||
});
|
||||
});
|
||||
const positive: [number, number][] = [];
|
||||
const negative: [number, number][] = [];
|
||||
Object.entries(data).forEach(([x, y]) => {
|
||||
const ts = Number(x);
|
||||
const meanY = y.reduce((a, b) => a + b, 0) / y.length;
|
||||
positive.push([ts, Math.max(0, meanY)]);
|
||||
negative.push([ts, Math.min(0, meanY)]);
|
||||
});
|
||||
return { positive, negative };
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
height: 100%;
|
||||
}
|
||||
.card-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.content {
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
.has-header {
|
||||
padding-top: 0;
|
||||
}
|
||||
.no-data {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20%;
|
||||
margin-left: var(--ha-space-8);
|
||||
margin-inline-start: var(--ha-space-8);
|
||||
margin-inline-end: initial;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-power-sources-graph-card": HuiPowerSourcesGraphCard;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} from "../../../mixins/conditional-listener-mixin";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
|
||||
import { computeCardSize } from "../common/compute-card-size";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
@@ -24,7 +21,9 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-card")
|
||||
export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
|
||||
export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||
ReactiveElement
|
||||
) {
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@property({ attribute: false }) public config?: LovelaceCardConfig;
|
||||
@@ -121,7 +120,7 @@ export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
|
||||
return {};
|
||||
}
|
||||
|
||||
private _updateElement(config: LovelaceCardConfig) {
|
||||
protected _updateElement(config: LovelaceCardConfig) {
|
||||
if (!this._element) {
|
||||
return;
|
||||
}
|
||||
@@ -247,22 +246,7 @@ export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected setupConditionalListeners() {
|
||||
if (!this.config?.visibility || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
setupMediaQueryListeners(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
(conditionsMet) => {
|
||||
this._updateVisibility(conditionsMet);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _updateVisibility(ignoreConditions?: boolean) {
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
if (!this._element || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@@ -283,9 +267,9 @@ export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
|
||||
}
|
||||
|
||||
const visible =
|
||||
ignoreConditions ||
|
||||
!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass);
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass));
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ export interface EnergyDevicesGraphCardConfig extends EnergyCardBaseConfig {
|
||||
title?: string;
|
||||
max_devices?: number;
|
||||
hide_compound_stats?: boolean;
|
||||
modes?: ("bar" | "pie")[];
|
||||
}
|
||||
|
||||
export interface EnergyDevicesDetailGraphCardConfig
|
||||
@@ -230,6 +231,11 @@ export interface EnergySankeyCardConfig extends EnergyCardBaseConfig {
|
||||
group_by_area?: boolean;
|
||||
}
|
||||
|
||||
export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
|
||||
type: "power-sources-graph";
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface EntityFilterCardConfig extends LovelaceCardConfig {
|
||||
type: "entity-filter";
|
||||
entities: (EntityFilterEntityConfig | string)[];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiAmpersand,
|
||||
mdiCalendarClock,
|
||||
mdiGateOr,
|
||||
mdiMapMarker,
|
||||
mdiNotEqualVariant,
|
||||
@@ -15,6 +16,7 @@ export const ICON_CONDITION: Record<Condition["condition"], string> = {
|
||||
numeric_state: mdiNumeric,
|
||||
state: mdiStateMachine,
|
||||
screen: mdiResponsive,
|
||||
time: mdiCalendarClock,
|
||||
user: mdiAccount,
|
||||
and: mdiAmpersand,
|
||||
not: mdiNotEqualVariant,
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { UNKNOWN } from "../../../data/entity";
|
||||
import { getUserPerson } from "../../../data/person";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import {
|
||||
checkTimeInRange,
|
||||
isValidTimeString,
|
||||
} from "../../../common/datetime/check_time";
|
||||
import {
|
||||
WEEKDAYS_SHORT,
|
||||
type WeekdayShort,
|
||||
} from "../../../common/datetime/weekday";
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
|
||||
export type Condition =
|
||||
| LocationCondition
|
||||
| NumericStateCondition
|
||||
| StateCondition
|
||||
| ScreenCondition
|
||||
| TimeCondition
|
||||
| UserCondition
|
||||
| OrCondition
|
||||
| AndCondition
|
||||
@@ -50,6 +58,13 @@ export interface ScreenCondition extends BaseCondition {
|
||||
media_query?: string;
|
||||
}
|
||||
|
||||
export interface TimeCondition extends BaseCondition {
|
||||
condition: "time";
|
||||
after?: string;
|
||||
before?: string;
|
||||
weekdays?: WeekdayShort[];
|
||||
}
|
||||
|
||||
export interface UserCondition extends BaseCondition {
|
||||
condition: "user";
|
||||
users?: string[];
|
||||
@@ -150,6 +165,13 @@ function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) {
|
||||
: false;
|
||||
}
|
||||
|
||||
function checkTimeCondition(
|
||||
condition: Omit<TimeCondition, "condition">,
|
||||
hass: HomeAssistant
|
||||
) {
|
||||
return checkTimeInRange(hass, condition);
|
||||
}
|
||||
|
||||
function checkLocationCondition(
|
||||
condition: LocationCondition,
|
||||
hass: HomeAssistant
|
||||
@@ -195,6 +217,8 @@ export function checkConditionsMet(
|
||||
return conditions.every((c) => {
|
||||
if ("condition" in c) {
|
||||
switch (c.condition) {
|
||||
case "time":
|
||||
return checkTimeCondition(c, hass);
|
||||
case "screen":
|
||||
return checkScreenCondition(c, hass);
|
||||
case "user":
|
||||
@@ -271,6 +295,35 @@ function validateScreenCondition(condition: ScreenCondition) {
|
||||
return condition.media_query != null;
|
||||
}
|
||||
|
||||
function validateTimeCondition(condition: TimeCondition) {
|
||||
// Check if time strings are present and non-empty
|
||||
const hasAfter = condition.after != null && condition.after !== "";
|
||||
const hasBefore = condition.before != null && condition.before !== "";
|
||||
const hasTime = hasAfter || hasBefore;
|
||||
|
||||
const hasWeekdays =
|
||||
condition.weekdays != null && condition.weekdays.length > 0;
|
||||
const weekdaysValid =
|
||||
!hasWeekdays ||
|
||||
condition.weekdays!.every((w: WeekdayShort) => WEEKDAYS_SHORT.includes(w));
|
||||
|
||||
// Validate time string formats if present
|
||||
const timeStringsValid =
|
||||
(!hasAfter || isValidTimeString(condition.after!)) &&
|
||||
(!hasBefore || isValidTimeString(condition.before!));
|
||||
|
||||
// Prevent after and before being identical (creates zero-length interval)
|
||||
const timeRangeValid =
|
||||
!hasAfter || !hasBefore || condition.after !== condition.before;
|
||||
|
||||
return (
|
||||
(hasTime || hasWeekdays) &&
|
||||
weekdaysValid &&
|
||||
timeStringsValid &&
|
||||
timeRangeValid
|
||||
);
|
||||
}
|
||||
|
||||
function validateUserCondition(condition: UserCondition) {
|
||||
return condition.users != null;
|
||||
}
|
||||
@@ -310,6 +363,8 @@ export function validateConditionalConfig(
|
||||
switch (c.condition) {
|
||||
case "screen":
|
||||
return validateScreenCondition(c);
|
||||
case "time":
|
||||
return validateTimeCondition(c);
|
||||
case "user":
|
||||
return validateUserCondition(c);
|
||||
case "location":
|
||||
|
||||
@@ -2,10 +2,7 @@ import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} from "../../../mixins/conditional-listener-mixin";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import type { HuiCard } from "../cards/hui-card";
|
||||
import type { ConditionalCardConfig } from "../cards/types";
|
||||
import type { Condition } from "../common/validate-condition";
|
||||
@@ -22,9 +19,9 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-conditional-base")
|
||||
export class HuiConditionalBase extends ConditionalListenerMixin(
|
||||
ReactiveElement
|
||||
) {
|
||||
export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
ConditionalCardConfig | ConditionalRowConfig
|
||||
>(ReactiveElement) {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
@@ -73,18 +70,13 @@ export class HuiConditionalBase extends ConditionalListenerMixin(
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to supported conditions (those with 'condition' property)
|
||||
const supportedConditions = this._config.conditions.filter(
|
||||
(c) => "condition" in c
|
||||
) as Condition[];
|
||||
|
||||
setupMediaQueryListeners(
|
||||
supportedConditions,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
(conditionsMet) => {
|
||||
this.setVisibility(conditionsMet);
|
||||
}
|
||||
);
|
||||
// Pass filtered conditions to parent implementation
|
||||
super.setupConditionalListeners(supportedConditions);
|
||||
}
|
||||
|
||||
protected update(changed: PropertyValues): void {
|
||||
@@ -102,17 +94,15 @@ export class HuiConditionalBase extends ConditionalListenerMixin(
|
||||
}
|
||||
}
|
||||
|
||||
private _updateVisibility() {
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
if (!this._element || !this.hass || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._element.preview = this.preview;
|
||||
|
||||
const conditionMet = checkConditionsMet(
|
||||
this._config!.conditions,
|
||||
this.hass!
|
||||
);
|
||||
const conditionMet =
|
||||
conditionsMet ?? checkConditionsMet(this._config.conditions, this.hass);
|
||||
|
||||
this.setVisibility(conditionMet);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ const LAZY_LOAD_TYPES = {
|
||||
"energy-usage-graph": () =>
|
||||
import("../cards/energy/hui-energy-usage-graph-card"),
|
||||
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
|
||||
"power-sources-graph": () =>
|
||||
import("../cards/energy/hui-power-sources-graph-card"),
|
||||
"entity-filter": () => import("../cards/hui-entity-filter-card"),
|
||||
error: () => import("../cards/hui-error-card"),
|
||||
"home-summary": () => import("../cards/hui-home-summary-card"),
|
||||
|
||||
@@ -25,6 +25,7 @@ import "./types/ha-card-condition-numeric_state";
|
||||
import "./types/ha-card-condition-or";
|
||||
import "./types/ha-card-condition-screen";
|
||||
import "./types/ha-card-condition-state";
|
||||
import "./types/ha-card-condition-time";
|
||||
import "./types/ha-card-condition-user";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
|
||||
@@ -33,6 +34,7 @@ const UI_CONDITION = [
|
||||
"numeric_state",
|
||||
"state",
|
||||
"screen",
|
||||
"time",
|
||||
"user",
|
||||
"and",
|
||||
"not",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import {
|
||||
literal,
|
||||
array,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
assert,
|
||||
enums,
|
||||
} from "superstruct";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import {
|
||||
WEEKDAY_SHORT_TO_LONG,
|
||||
WEEKDAYS_SHORT,
|
||||
} from "../../../../../common/datetime/weekday";
|
||||
import type { TimeCondition } from "../../../common/validate-condition";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../../components/ha-form/types";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
|
||||
const timeConditionStruct = object({
|
||||
condition: literal("time"),
|
||||
after: optional(string()),
|
||||
before: optional(string()),
|
||||
weekdays: optional(array(enums(WEEKDAYS_SHORT))),
|
||||
});
|
||||
|
||||
@customElement("ha-card-condition-time")
|
||||
export class HaCardConditionTime extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public condition!: TimeCondition;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
public static get defaultConfig(): TimeCondition {
|
||||
return { condition: "time", after: "08:00", before: "17:00" };
|
||||
}
|
||||
|
||||
protected static validateUIConfig(condition: TimeCondition) {
|
||||
return assert(condition, timeConditionStruct);
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{ name: "after", selector: { time: { no_second: true } } },
|
||||
{ name: "before", selector: { time: { no_second: true } } },
|
||||
{
|
||||
name: "weekdays",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "list",
|
||||
multiple: true,
|
||||
options: WEEKDAYS_SHORT.map((day) => ({
|
||||
value: day,
|
||||
label: localize(`ui.weekdays.${WEEKDAY_SHORT_TO_LONG[day]}`),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies HaFormSchema[]
|
||||
);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.condition}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.schema=${this._schema(this.hass.localize)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const data = ev.detail.value as TimeCondition;
|
||||
fireEvent(this, "value-changed", { value: data });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.lovelace.editor.condition-editor.condition.time.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-card-condition-time": HaCardConditionTime;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const NON_STANDARD_URLS = {
|
||||
"energy-devices-graph": "energy/#devices-energy-graph",
|
||||
"energy-devices-detail-graph": "energy/#detail-devices-energy-graph",
|
||||
"energy-sankey": "energy/#sankey-energy-graph",
|
||||
"power-sources-graph": "energy/#power-sources-graph",
|
||||
};
|
||||
|
||||
export const getCardDocumentationURL = (
|
||||
|
||||
@@ -4,10 +4,7 @@ import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} from "../../../mixins/conditional-listener-mixin";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
|
||||
import type { LovelaceHeadingBadge } from "../types";
|
||||
@@ -21,7 +18,9 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-heading-badge")
|
||||
export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBadgeConfig>(
|
||||
ReactiveElement
|
||||
) {
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@property({ attribute: false }) public config?: LovelaceHeadingBadgeConfig;
|
||||
@@ -52,7 +51,7 @@ export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
this._updateVisibility();
|
||||
}
|
||||
|
||||
private _updateElement(config: LovelaceHeadingBadgeConfig) {
|
||||
protected _updateElement(config: LovelaceHeadingBadgeConfig) {
|
||||
if (!this._element) {
|
||||
return;
|
||||
}
|
||||
@@ -133,22 +132,7 @@ export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected setupConditionalListeners() {
|
||||
if (!this.config?.visibility || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
setupMediaQueryListeners(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
(conditionsMet) => {
|
||||
this._updateVisibility(conditionsMet);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _updateVisibility(forceVisible?: boolean) {
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
if (!this._element || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@@ -158,11 +142,20 @@ export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.preview) {
|
||||
this._setElementVisibility(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config?.disabled) {
|
||||
this._setElementVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
forceVisible ||
|
||||
this.preview ||
|
||||
!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass);
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass));
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,7 @@ import type {
|
||||
} from "../../../data/lovelace/config/section";
|
||||
import { isStrategySection } from "../../../data/lovelace/config/section";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} from "../../../mixins/conditional-listener-mixin";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import "../cards/hui-card";
|
||||
import type { HuiCard } from "../cards/hui-card";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
@@ -37,7 +34,9 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-section")
|
||||
export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
|
||||
export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
ReactiveElement
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public config!: LovelaceSectionRawConfig;
|
||||
@@ -59,8 +58,6 @@ export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
|
||||
|
||||
private _layoutElement?: LovelaceSectionElement;
|
||||
|
||||
private _config: LovelaceSectionConfig | undefined;
|
||||
|
||||
@storage({
|
||||
key: "dashboardCardClipboard",
|
||||
state: false,
|
||||
@@ -116,7 +113,7 @@ export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._updateElement();
|
||||
this._updateVisibility();
|
||||
}
|
||||
|
||||
protected update(changedProperties) {
|
||||
@@ -147,26 +144,11 @@ export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
|
||||
this._layoutElement.cards = this._cards;
|
||||
}
|
||||
if (changedProperties.has("hass") || changedProperties.has("preview")) {
|
||||
this._updateElement();
|
||||
this._updateVisibility();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected setupConditionalListeners() {
|
||||
if (!this._config?.visibility || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
setupMediaQueryListeners(
|
||||
this._config.visibility,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
(conditionsMet) => {
|
||||
this._updateElement(conditionsMet);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async _initializeConfig() {
|
||||
let sectionConfig = { ...this.config };
|
||||
let isStrategy = false;
|
||||
@@ -208,11 +190,11 @@ export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
|
||||
while (this.lastChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
this._updateElement();
|
||||
this._updateVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
private _updateElement(ignoreConditions?: boolean) {
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
if (!this._layoutElement || !this._config) {
|
||||
return;
|
||||
}
|
||||
@@ -228,9 +210,9 @@ export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
|
||||
}
|
||||
|
||||
const visible =
|
||||
ignoreConditions ||
|
||||
!this._config.visibility ||
|
||||
checkConditionsMet(this._config.visibility, this.hass);
|
||||
conditionsMet ??
|
||||
(!this._config.visibility ||
|
||||
checkConditionsMet(this._config.visibility, this.hass));
|
||||
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
@@ -168,6 +168,27 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
|
||||
const summaryEntities = Object.values(entitiesBySummary).flat();
|
||||
|
||||
// Scenes section
|
||||
const sceneFilter = generateEntityFilter(hass, {
|
||||
domain: "scene",
|
||||
entity_category: "none",
|
||||
});
|
||||
const scenes = areaEntities.filter(sceneFilter);
|
||||
|
||||
if (scenes.length > 0) {
|
||||
sections.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
computeHeadingCard(
|
||||
hass.localize("ui.panel.lovelace.strategy.home.scenes"),
|
||||
"mdi:palette",
|
||||
"/config/scene/dashboard"
|
||||
),
|
||||
...scenes.map(computeTileCard),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Automations section
|
||||
const automationFilter = generateEntityFilter(hass, {
|
||||
domain: "automation",
|
||||
@@ -178,7 +199,9 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
// Rest of entities grouped by device
|
||||
const otherEntities = areaEntities.filter(
|
||||
(entityId) =>
|
||||
!summaryEntities.includes(entityId) && !automations.includes(entityId)
|
||||
!summaryEntities.includes(entityId) &&
|
||||
!scenes.includes(entityId) &&
|
||||
!automations.includes(entityId)
|
||||
);
|
||||
|
||||
const entitiesByDevice: Record<string, string[]> = {};
|
||||
|
||||
@@ -109,6 +109,7 @@ export class HUIViewBackground extends LitElement {
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
super.willUpdate(changedProperties);
|
||||
let applyTheme = false;
|
||||
if (changedProperties.has("hass") && this.hass) {
|
||||
const oldHass = changedProperties.get("hass");
|
||||
if (
|
||||
@@ -116,16 +117,18 @@ export class HUIViewBackground extends LitElement {
|
||||
this.hass.themes !== oldHass.themes ||
|
||||
this.hass.selectedTheme !== oldHass.selectedTheme
|
||||
) {
|
||||
this._applyTheme();
|
||||
return;
|
||||
applyTheme = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has("background")) {
|
||||
this._applyTheme();
|
||||
applyTheme = true;
|
||||
this._fetchMedia();
|
||||
}
|
||||
if (changedProperties.has("resolvedImage")) {
|
||||
applyTheme = true;
|
||||
}
|
||||
if (applyTheme) {
|
||||
this._applyTheme();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ import memoizeOne from "memoize-one";
|
||||
import { relativeTime } from "../../common/datetime/relative_time";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-md-button-menu";
|
||||
import "../../components/ha-md-menu-item";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-dropdown";
|
||||
import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-label";
|
||||
import "../../components/ha-list-item";
|
||||
import "../../components/ha-md-button-menu";
|
||||
import "../../components/ha-md-menu-item";
|
||||
import "../../components/ha-settings-row";
|
||||
import { deleteAllRefreshTokens } from "../../data/auth";
|
||||
import type { RefreshToken } from "../../data/refresh_token";
|
||||
@@ -146,19 +148,18 @@ class HaRefreshTokens extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<ha-md-button-menu positioning="popover">
|
||||
<ha-dropdown @wa-select=${this._handleDropdownSelect}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-md-menu-item
|
||||
graphic="icon"
|
||||
@click=${this._toggleTokenExpiration}
|
||||
<ha-dropdown-item
|
||||
.token=${token}
|
||||
.action=${"toggle_expiration"}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
slot="icon"
|
||||
.path=${token.expire_at
|
||||
? mdiClockRemoveOutline
|
||||
: mdiClockCheckOutline}
|
||||
@@ -170,24 +171,20 @@ class HaRefreshTokens extends LitElement {
|
||||
: this.hass.localize(
|
||||
"ui.panel.profile.refresh_tokens.enable_token_expiration"
|
||||
)}
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item
|
||||
graphic="icon"
|
||||
class="warning"
|
||||
.disabled=${token.is_current}
|
||||
@click=${this._deleteToken}
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item
|
||||
.token=${token}
|
||||
.action=${"delete_token"}
|
||||
variant="danger"
|
||||
.disabled=${token.is_current}
|
||||
>
|
||||
<ha-svg-icon
|
||||
class="warning"
|
||||
slot="start"
|
||||
slot="icon"
|
||||
.path=${mdiDelete}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.common.delete")}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
</ha-md-button-menu>
|
||||
${this.hass.localize("ui.common.delete")}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
</div>
|
||||
</ha-settings-row>
|
||||
`
|
||||
@@ -210,8 +207,17 @@ class HaRefreshTokens extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _toggleTokenExpiration(ev): Promise<void> {
|
||||
const token = (ev.currentTarget as any).token as RefreshToken;
|
||||
private _handleDropdownSelect(
|
||||
ev: CustomEvent<{ item: { action: string; token: RefreshToken } }>
|
||||
) {
|
||||
if (ev.detail.item.action === "toggle_expiration") {
|
||||
this._toggleTokenExpiration(ev.detail.item.token);
|
||||
} else if (ev.detail.item.action === "delete_token") {
|
||||
this._deleteToken(ev.detail.item.token);
|
||||
}
|
||||
}
|
||||
|
||||
private async _toggleTokenExpiration(token: RefreshToken): Promise<void> {
|
||||
const enable = !token.expire_at;
|
||||
if (!enable) {
|
||||
if (
|
||||
@@ -252,8 +258,7 @@ class HaRefreshTokens extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteToken(ev): Promise<void> {
|
||||
const token = (ev.currentTarget as any).token as RefreshToken;
|
||||
private async _deleteToken(token: RefreshToken): Promise<void> {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -26,6 +26,7 @@ import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { TodoItemEditDialogParams } from "./show-dialog-todo-item-editor";
|
||||
import { supportsMarkdownHelper } from "../../common/translations/markdown_support";
|
||||
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
|
||||
|
||||
@customElement("dialog-todo-item-editor")
|
||||
class DialogTodoItemEditor extends LitElement {
|
||||
@@ -41,6 +42,8 @@ class DialogTodoItemEditor extends LitElement {
|
||||
|
||||
@state() private _due?: Date;
|
||||
|
||||
@state() private _completedTime?: Date;
|
||||
|
||||
@state() private _checked = false;
|
||||
|
||||
@state() private _hasTime = false;
|
||||
@@ -65,6 +68,9 @@ class DialogTodoItemEditor extends LitElement {
|
||||
this._checked = entry.status === TodoItemStatus.Completed;
|
||||
this._summary = entry.summary;
|
||||
this._description = entry.description || "";
|
||||
this._completedTime = entry.completed
|
||||
? new Date(entry.completed)
|
||||
: undefined;
|
||||
this._hasTime = entry.due?.includes("T") || false;
|
||||
this._due = entry.due
|
||||
? new Date(this._hasTime ? entry.due : `${entry.due}T00:00:00`)
|
||||
@@ -138,6 +144,17 @@ class DialogTodoItemEditor extends LitElement {
|
||||
.disabled=${!canUpdate}
|
||||
></ha-textfield>
|
||||
</div>
|
||||
${this._completedTime
|
||||
? html`<div class="italic">
|
||||
${this.hass.localize("ui.components.todo.item.completed_time", {
|
||||
datetime: formatShortDateTimeWithConditionalYear(
|
||||
this._completedTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
})}
|
||||
</div>`
|
||||
: nothing}
|
||||
${this._todoListSupportsFeature(
|
||||
TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
|
||||
)
|
||||
@@ -455,6 +472,9 @@ class DialogTodoItemEditor extends LitElement {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
30
src/resources/theme/animations.globals.ts
Normal file
30
src/resources/theme/animations.globals.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const animationStyles = css`
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale {
|
||||
from {
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -74,6 +74,10 @@ export const coreColorStyles = css`
|
||||
--ha-color-green-80: #93da98;
|
||||
--ha-color-green-90: #c2f2c1;
|
||||
--ha-color-green-95: #e3f9e3;
|
||||
|
||||
/* shadow */
|
||||
--ha-color-shadow-light: #00000014;
|
||||
--ha-color-shadow-dark: #00000046;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ export const semanticColorStyles = css`
|
||||
|
||||
/* Surfaces */
|
||||
--ha-color-surface-default: var(--ha-color-neutral-95);
|
||||
--ha-color-on-surface-default: var(--ha-color-neutral-05);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -286,5 +287,6 @@ export const darkSemanticColorStyles = css`
|
||||
|
||||
/* Surfaces */
|
||||
--ha-color-surface-default: var(--ha-color-neutral-10);
|
||||
--ha-color-on-surface-default: var(--ha-color-neutral-95);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -52,7 +52,9 @@ export const waColorStyles = css`
|
||||
--wa-color-danger-on-normal: var(--ha-color-on-danger-normal);
|
||||
--wa-color-danger-on-quiet: var(--ha-color-on-danger-quiet);
|
||||
|
||||
--wa-color-text-normal: var(--ha-color-text-primary);
|
||||
--wa-color-surface-default: var(--card-background-color);
|
||||
--wa-color-surface-raised: var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff));
|
||||
--wa-panel-border-radius: var(--ha-border-radius-3xl);
|
||||
--wa-panel-border-style: solid;
|
||||
--wa-panel-border-width: 1px;
|
||||
|
||||
@@ -42,6 +42,27 @@ export const coreStyles = css`
|
||||
--ha-space-18: 72px;
|
||||
--ha-space-19: 76px;
|
||||
--ha-space-20: 80px;
|
||||
|
||||
--ha-shadow-offset-x-sm: 0;
|
||||
--ha-shadow-offset-x-md: 0;
|
||||
--ha-shadow-offset-x-lg: 0;
|
||||
--ha-shadow-offset-y-sm: 2px;
|
||||
--ha-shadow-offset-y-md: 4px;
|
||||
--ha-shadow-offset-y-lg: 8px;
|
||||
--ha-shadow-blur-sm: 4px;
|
||||
--ha-shadow-blur-md: 8px;
|
||||
--ha-shadow-blur-lg: 12px;
|
||||
--ha-shadow-spread-sm: 0;
|
||||
--ha-shadow-spread-md: 0;
|
||||
--ha-shadow-spread-lg: 0;
|
||||
|
||||
--ha-animation-base-duration: 350ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
--ha-animation-base-duration: 0ms;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
24
src/resources/theme/semantic.globals.ts
Normal file
24
src/resources/theme/semantic.globals.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { css } from "lit";
|
||||
import { extractVars } from "../../common/style/derived-css-vars";
|
||||
|
||||
/**
|
||||
* Semantic styles use core styles to define higher level variables like box shadows.
|
||||
* Here we define all styles except colors
|
||||
*/
|
||||
export const semanticStyles = css`
|
||||
html {
|
||||
--ha-box-shadow-s: var(--ha-shadow-offset-x-sm) var(--ha-shadow-offset-y-sm) var(--ha-shadow-blur-sm) var(--ha-shadow-spread-sm) var(--ha-color-shadow-light);
|
||||
--ha-box-shadow-m: var(--ha-shadow-offset-x-md) var(--ha-shadow-offset-y-md) var(--ha-shadow-blur-md) var(--ha-shadow-spread-md) var(--ha-color-shadow-light);
|
||||
--ha-box-shadow-l: var(--ha-shadow-offset-x-lg) var(--ha-shadow-offset-y-lg) var(--ha-shadow-blur-lg) var(--ha-shadow-spread-lg) var(--ha-color-shadow-light);
|
||||
}
|
||||
`;
|
||||
|
||||
export const darkSemanticStyles = css`
|
||||
html {
|
||||
--ha-box-shadow-s: var(--ha-shadow-offset-x-sm) var(--ha-shadow-offset-y-sm) var(--ha-shadow-blur-sm) var(--ha-shadow-spread-sm) var(--ha-color-shadow-dark);
|
||||
--ha-box-shadow-m: var(--ha-shadow-offset-x-md) var(--ha-shadow-offset-y-md) var(--ha-shadow-blur-md) var(--ha-shadow-spread-md) var(--ha-color-shadow-dark);
|
||||
--ha-box-shadow-l: var(--ha-shadow-offset-x-lg) var(--ha-shadow-offset-y-lg) var(--ha-shadow-blur-lg) var(--ha-shadow-spread-lg) var(--ha-color-shadow-dark);
|
||||
}
|
||||
`;
|
||||
|
||||
export const darkSemanticVariables = extractVars(darkSemanticStyles);
|
||||
@@ -1,7 +1,9 @@
|
||||
import { fontStyles } from "../roboto";
|
||||
import { animationStyles } from "./animations.globals";
|
||||
import { colorDerivedVariables, colorStylesCollection } from "./color";
|
||||
import { coreDerivedVariables, coreStyles } from "./core.globals";
|
||||
import { mainDerivedVariables, mainStyles } from "./main.globals";
|
||||
import { semanticStyles } from "./semantic.globals";
|
||||
import {
|
||||
typographyDerivedVariables,
|
||||
typographyStyles,
|
||||
@@ -12,9 +14,11 @@ export const themeStyles = [
|
||||
coreStyles.toString(),
|
||||
mainStyles.toString(),
|
||||
typographyStyles.toString(),
|
||||
semanticStyles.toString(),
|
||||
...colorStylesCollection,
|
||||
fontStyles.toString(),
|
||||
waMainStyles.toString(),
|
||||
animationStyles.toString(),
|
||||
].join("");
|
||||
|
||||
export const derivedStyles = {
|
||||
|
||||
@@ -9,15 +9,28 @@ export const waMainStyles = css`
|
||||
--wa-focus-ring-offset: 2px;
|
||||
--wa-focus-ring: var(--wa-focus-ring-style) var(--wa-focus-ring-width) var(--wa-focus-ring-color);
|
||||
|
||||
--wa-space-l: 24px;
|
||||
--wa-shadow-l: 0 8px 8px -4px rgba(0, 0, 0, 0.2);
|
||||
--wa-space-l: var(--ha-space-6);
|
||||
--wa-space-xl: var(--ha-space-8);
|
||||
|
||||
--wa-form-control-padding-block: 0.75em;
|
||||
|
||||
--wa-font-weight-action: var(--ha-font-weight-medium);
|
||||
--wa-transition-fast: 75ms;
|
||||
--wa-transition-easing: ease;
|
||||
--wa-border-width-l: var(--ha-border-radius-lg);
|
||||
--wa-space-xl: 32px;
|
||||
|
||||
--wa-border-style: solid;
|
||||
--wa-border-width-s: var(--ha-border-width-sm);
|
||||
--wa-border-width-m: var(--ha-border-width-md);
|
||||
--wa-border-width-l: var(--ha-border-width-lg);
|
||||
--wa-border-radius-s: var(--ha-border-radius-sm);
|
||||
--wa-border-radius-m: var(--ha-border-radius-md);
|
||||
--wa-border-radius-l: var(--ha-border-radius-lg);
|
||||
|
||||
--wa-line-height-condensed: var(--ha-line-height-condensed);
|
||||
|
||||
--wa-shadow-s: var(--ha-box-shadow-s);
|
||||
--wa-shadow-m: var(--ha-box-shadow-m);
|
||||
--wa-shadow-l: var(--ha-box-shadow-l);
|
||||
}
|
||||
|
||||
${scrollLockStyles}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"profile": "Profile",
|
||||
"light": "Lights",
|
||||
"security": "Security",
|
||||
"climate": "Climate"
|
||||
"climate": "Climate",
|
||||
"home": "Home"
|
||||
},
|
||||
"state": {
|
||||
"default": {
|
||||
@@ -1129,6 +1130,7 @@
|
||||
"edit": "Edit item",
|
||||
"save": "Save item",
|
||||
"due": "Due date",
|
||||
"completed_time": "Completed { datetime }",
|
||||
"not_all_required_fields": "Not all required fields are filled in",
|
||||
"confirm_delete": {
|
||||
"delete": "Delete item",
|
||||
@@ -3076,6 +3078,15 @@
|
||||
"grid_carbon_footprint": "Grid carbon footprint",
|
||||
"remove_co2_signal": "Remove Electricity Maps integration",
|
||||
"add_co2_signal": "Add Electricity Maps integration",
|
||||
"grid_power": "Grid power",
|
||||
"add_power": "Add power sensor",
|
||||
"edit_power": "Edit power sensor",
|
||||
"delete_power": "Delete power sensor",
|
||||
"power_dialog": {
|
||||
"header": "Configure grid power",
|
||||
"power_stat": "Power sensor",
|
||||
"power_helper": "Pick a sensor which measures grid power in either of {unit}. Positive values indicate importing electricity from the grid, negative values indicate exporting electricity to the grid."
|
||||
},
|
||||
"flow_dialog": {
|
||||
"cost_entity_helper": "Any sensor with a unit of `{currency}/(valid energy unit)` (e.g. `{currency}/Wh` or `{currency}/kWh`) may be used and will be automatically converted.",
|
||||
"from": {
|
||||
@@ -3123,6 +3134,7 @@
|
||||
"header": "Configure solar panels",
|
||||
"entity_para": "Pick a sensor which measures solar energy production in either of {unit}.",
|
||||
"solar_production_energy": "Solar production energy",
|
||||
"solar_production_power": "Solar production power",
|
||||
"solar_production_forecast": "Solar production forecast",
|
||||
"solar_production_forecast_description": "Adding solar production forecast information will allow you to quickly see your expected production for today.",
|
||||
"dont_forecast_production": "Don't forecast production",
|
||||
@@ -3140,9 +3152,12 @@
|
||||
"add_battery_system": "Add battery system",
|
||||
"dialog": {
|
||||
"header": "Configure battery system",
|
||||
"entity_para": "Pick sensors which measure energy going into and coming out of the battery in either of {unit}.",
|
||||
"energy_into_battery": "Energy going into the battery",
|
||||
"energy_out_of_battery": "Energy coming out of the battery"
|
||||
"energy_helper_into": "Pick a sensor that measures the electricity flowing into the battery in either of {unit}.",
|
||||
"energy_helper_out": "Pick a sensor that measures the electricity flowing out of the battery in either of {unit}.",
|
||||
"energy_into_battery": "Energy charged into the battery",
|
||||
"energy_out_of_battery": "Energy discharged from the battery",
|
||||
"power": "Battery power",
|
||||
"power_helper": "Pick a sensor which measures the electricity flowing into and out of the battery in either of {unit}. Positive values indicate discharging the battery, negative values indicate charging the battery."
|
||||
}
|
||||
},
|
||||
"gas": {
|
||||
@@ -3204,7 +3219,8 @@
|
||||
"header": "Add a device",
|
||||
"display_name": "Display name",
|
||||
"device_consumption_energy": "Device energy consumption",
|
||||
"selected_stat_intro": "Select the energy sensor that measures the device's energy usage in either of {unit}.",
|
||||
"device_consumption_power": "Device power consumption",
|
||||
"selected_stat_intro": "Select the sensor that measures the device's electricity usage in either of {unit}.",
|
||||
"included_in_device": "Upstream device",
|
||||
"included_in_device_helper": "If this device is already counted by another device (such as a smart switch measured by a smart breaker), selecting the upstream device prevents duplicate energy tracking.",
|
||||
"no_upstream_devices": "No eligible upstream devices"
|
||||
@@ -6744,6 +6760,7 @@
|
||||
},
|
||||
"analytics": {
|
||||
"caption": "Analytics",
|
||||
"header": "Home Assistant analytics",
|
||||
"description": "Learn how to share data to improve Home Assistant",
|
||||
"preferences": {
|
||||
"base": {
|
||||
@@ -6761,10 +6778,17 @@
|
||||
"diagnostics": {
|
||||
"title": "Diagnostics",
|
||||
"description": "Share crash reports when unexpected errors occur."
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Devices",
|
||||
"description": "Generic information about your devices.",
|
||||
"header": "Device analytics",
|
||||
"info": "Anonymously share data about your devices to help build the Open Home Foundation’s device database. This free, open source resource helps users find useful information about smart home devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).",
|
||||
"learn_more": "Learn more about the device database and how we process your data"
|
||||
}
|
||||
},
|
||||
"need_base_enabled": "You need to enable basic analytics for this option to be available",
|
||||
"learn_more": "How we process your data",
|
||||
"learn_more": "Learn how we process your data",
|
||||
"intro": "Share anonymized information from your installation to help make Home Assistant better and help us convince manufacturers to add local control and privacy-focused features.",
|
||||
"download_device_info": "Preview device analytics"
|
||||
},
|
||||
@@ -6983,6 +7007,7 @@
|
||||
"other_areas": "Other areas",
|
||||
"unamed_device": "Unnamed device",
|
||||
"others": "Others",
|
||||
"scenes": "Scenes",
|
||||
"automations": "Automations"
|
||||
},
|
||||
"common_controls": {
|
||||
@@ -7145,6 +7170,12 @@
|
||||
"low_carbon_energy_consumed": "Low-carbon electricity consumed",
|
||||
"low_carbon_energy_not_calculated": "Consumed low-carbon electricity couldn't be calculated"
|
||||
},
|
||||
"power_graph": {
|
||||
"grid": "Grid",
|
||||
"solar": "Solar",
|
||||
"battery": "Battery",
|
||||
"usage": "Used"
|
||||
},
|
||||
"energy_compare": {
|
||||
"info": "You are comparing the period {start} with the period {end}",
|
||||
"compare_previous_year": "Compare previous year",
|
||||
@@ -7562,6 +7593,12 @@
|
||||
"state_equal": "State is equal to",
|
||||
"state_not_equal": "State is not equal to"
|
||||
},
|
||||
"time": {
|
||||
"label": "Time",
|
||||
"after": "After",
|
||||
"before": "Before",
|
||||
"weekdays": "Weekdays"
|
||||
},
|
||||
"location": {
|
||||
"label": "Location",
|
||||
"locations": "Locations",
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { render } from "lit";
|
||||
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
|
||||
import { withViewTransition } from "../common/util/view-transition";
|
||||
|
||||
export const removeLaunchScreen = () => {
|
||||
const launchScreenElement = document.getElementById("ha-launch-screen");
|
||||
if (launchScreenElement) {
|
||||
launchScreenElement.parentElement!.removeChild(launchScreenElement);
|
||||
if (!launchScreenElement?.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
withViewTransition((viewTransitionAvailable: boolean) => {
|
||||
if (!viewTransitionAvailable) {
|
||||
launchScreenElement.parentElement?.removeChild(launchScreenElement);
|
||||
return;
|
||||
}
|
||||
|
||||
launchScreenElement.classList.add("removing");
|
||||
|
||||
const durationFromCss = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--ha-animation-base-duration")
|
||||
.trim();
|
||||
|
||||
setTimeout(() => {
|
||||
launchScreenElement.parentElement?.removeChild(launchScreenElement);
|
||||
}, parseAnimationDuration(durationFromCss));
|
||||
});
|
||||
};
|
||||
|
||||
export const renderLaunchScreenInfoBox = (content: TemplateResult) => {
|
||||
|
||||
437
test/common/condition/extract.test.ts
Normal file
437
test/common/condition/extract.test.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractMediaQueries,
|
||||
extractTimeConditions,
|
||||
} from "../../../src/common/condition/extract";
|
||||
import type {
|
||||
Condition,
|
||||
TimeCondition,
|
||||
ScreenCondition,
|
||||
OrCondition,
|
||||
AndCondition,
|
||||
NotCondition,
|
||||
} from "../../../src/panels/lovelace/common/validate-condition";
|
||||
|
||||
describe("extractMediaQueries", () => {
|
||||
it("should extract single media query", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
const result = extractMediaQueries(conditions);
|
||||
|
||||
expect(result).toEqual(["(max-width: 600px)"]);
|
||||
});
|
||||
|
||||
it("should extract multiple media queries", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(min-width: 1200px)",
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
const result = extractMediaQueries(conditions);
|
||||
|
||||
expect(result).toEqual(["(max-width: 600px)", "(min-width: 1200px)"]);
|
||||
});
|
||||
|
||||
it("should return empty array when no screen conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
{
|
||||
condition: "state",
|
||||
entity: "light.living_room",
|
||||
state: "on",
|
||||
},
|
||||
];
|
||||
|
||||
const result = extractMediaQueries(conditions);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should ignore screen conditions without media_query", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
const result = extractMediaQueries(conditions);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should extract from nested or conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "state",
|
||||
entity: "light.living_room",
|
||||
state: "on",
|
||||
},
|
||||
],
|
||||
} as OrCondition,
|
||||
];
|
||||
|
||||
const result = extractMediaQueries(conditions);
|
||||
|
||||
expect(result).toEqual(["(max-width: 600px)"]);
|
||||
});
|
||||
|
||||
it("should extract from nested and conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(orientation: portrait)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
],
|
||||
} as AndCondition,
|
||||
];
|
||||
|
||||
const result = extractMediaQueries(conditions);
|
||||
|
||||
expect(result).toEqual(["(orientation: portrait)"]);
|
||||
});
|
||||
|
||||
it("should extract from nested not conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(prefers-color-scheme: dark)",
|
||||
} as ScreenCondition,
|
||||
],
|
||||
} as NotCondition,
|
||||
];
|
||||
|
||||
const result = extractMediaQueries(conditions);
|
||||
|
||||
expect(result).toEqual(["(prefers-color-scheme: dark)"]);
|
||||
});
|
||||
|
||||
it("should extract from deeply nested conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(orientation: landscape)",
|
||||
} as ScreenCondition,
|
||||
],
|
||||
} as NotCondition,
|
||||
],
|
||||
} as AndCondition,
|
||||
],
|
||||
} as OrCondition,
|
||||
];
|
||||
|
||||
const result = extractMediaQueries(conditions);
|
||||
|
||||
expect(result).toEqual(["(max-width: 600px)", "(orientation: landscape)"]);
|
||||
});
|
||||
|
||||
it("should handle empty conditions array", () => {
|
||||
const result = extractMediaQueries([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle mixed conditions with nesting", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(min-width: 1200px)",
|
||||
} as ScreenCondition,
|
||||
],
|
||||
} as OrCondition,
|
||||
];
|
||||
|
||||
const result = extractMediaQueries(conditions);
|
||||
|
||||
expect(result).toEqual(["(max-width: 600px)", "(min-width: 1200px)"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTimeConditions", () => {
|
||||
it("should extract single time condition", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
} as TimeCondition,
|
||||
];
|
||||
|
||||
const result = extractTimeConditions(conditions);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract multiple time conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
{
|
||||
condition: "time",
|
||||
before: "17:00",
|
||||
weekdays: ["mon", "tue", "wed", "thu", "fri"],
|
||||
} as TimeCondition,
|
||||
];
|
||||
|
||||
const result = extractTimeConditions(conditions);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({ condition: "time", after: "08:00" });
|
||||
expect(result[1]).toMatchObject({
|
||||
condition: "time",
|
||||
before: "17:00",
|
||||
weekdays: ["mon", "tue", "wed", "thu", "fri"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array when no time conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "state",
|
||||
entity: "light.living_room",
|
||||
state: "on",
|
||||
},
|
||||
];
|
||||
|
||||
const result = extractTimeConditions(conditions);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should extract from nested or conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
{
|
||||
condition: "state",
|
||||
entity: "light.living_room",
|
||||
state: "on",
|
||||
},
|
||||
],
|
||||
} as OrCondition,
|
||||
];
|
||||
|
||||
const result = extractTimeConditions(conditions);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract from nested and conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "time",
|
||||
weekdays: ["sat", "sun"],
|
||||
} as TimeCondition,
|
||||
],
|
||||
} as AndCondition,
|
||||
];
|
||||
|
||||
const result = extractTimeConditions(conditions);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
condition: "time",
|
||||
weekdays: ["sat", "sun"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract from nested not conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{
|
||||
condition: "time",
|
||||
after: "22:00",
|
||||
before: "06:00",
|
||||
} as TimeCondition,
|
||||
],
|
||||
} as NotCondition,
|
||||
];
|
||||
|
||||
const result = extractTimeConditions(conditions);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
condition: "time",
|
||||
after: "22:00",
|
||||
before: "06:00",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract from deeply nested conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{
|
||||
condition: "time",
|
||||
weekdays: ["sat", "sun"],
|
||||
} as TimeCondition,
|
||||
],
|
||||
} as NotCondition,
|
||||
],
|
||||
} as AndCondition,
|
||||
],
|
||||
} as OrCondition,
|
||||
];
|
||||
|
||||
const result = extractTimeConditions(conditions);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({ condition: "time", after: "08:00" });
|
||||
expect(result[1]).toMatchObject({
|
||||
condition: "time",
|
||||
weekdays: ["sat", "sun"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty conditions array", () => {
|
||||
const result = extractTimeConditions([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle mixed conditions with nesting", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{
|
||||
condition: "time",
|
||||
before: "22:00",
|
||||
} as TimeCondition,
|
||||
],
|
||||
} as OrCondition,
|
||||
];
|
||||
|
||||
const result = extractTimeConditions(conditions);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({ condition: "time", after: "08:00" });
|
||||
expect(result[1]).toMatchObject({ condition: "time", before: "22:00" });
|
||||
});
|
||||
|
||||
it("should preserve all time condition properties", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
weekdays: ["mon", "tue", "wed", "thu", "fri"],
|
||||
} as TimeCondition,
|
||||
];
|
||||
|
||||
const result = extractTimeConditions(conditions);
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
weekdays: ["mon", "tue", "wed", "thu", "fri"],
|
||||
});
|
||||
});
|
||||
});
|
||||
519
test/common/condition/listeners.test.ts
Normal file
519
test/common/condition/listeners.test.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
setupTimeListeners,
|
||||
setupMediaQueryListeners,
|
||||
} from "../../../src/common/condition/listeners";
|
||||
import * as timeCalculator from "../../../src/common/condition/time-calculator";
|
||||
import type {
|
||||
TimeCondition,
|
||||
ScreenCondition,
|
||||
Condition,
|
||||
} from "../../../src/panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import * as mediaQuery from "../../../src/common/dom/media_query";
|
||||
|
||||
// Maximum delay for setTimeout (2^31 - 1 milliseconds, ~24.8 days)
|
||||
const MAX_TIMEOUT_DELAY = 2147483647;
|
||||
|
||||
describe("setupTimeListeners", () => {
|
||||
let hass: HomeAssistant;
|
||||
let listeners: (() => void)[];
|
||||
let onUpdateCallback: (conditionsMet: boolean) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
listeners = [];
|
||||
onUpdateCallback = vi.fn();
|
||||
|
||||
hass = {
|
||||
locale: {
|
||||
time_zone: "local",
|
||||
},
|
||||
config: {
|
||||
time_zone: "America/New_York",
|
||||
},
|
||||
} as HomeAssistant;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listeners.forEach((unsub) => unsub());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("setTimeout overflow protection", () => {
|
||||
it("should cap delay at MAX_TIMEOUT_DELAY", () => {
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
|
||||
// Mock calculateNextTimeUpdate to return a delay exceeding the max
|
||||
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
|
||||
MAX_TIMEOUT_DELAY + 1000000
|
||||
);
|
||||
|
||||
const conditions: TimeCondition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// Verify setTimeout was called with the capped delay
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
MAX_TIMEOUT_DELAY
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call onUpdate when hitting the cap", () => {
|
||||
// Mock calculateNextTimeUpdate to return delays that decrease over time
|
||||
// Both first and second delays exceed the cap
|
||||
const delays = [
|
||||
MAX_TIMEOUT_DELAY + 1000000,
|
||||
MAX_TIMEOUT_DELAY + 500000,
|
||||
1000,
|
||||
];
|
||||
let callCount = 0;
|
||||
|
||||
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockImplementation(
|
||||
() => delays[callCount++]
|
||||
);
|
||||
|
||||
const conditions: TimeCondition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// Fast-forward to when the first timeout fires (at the cap)
|
||||
vi.advanceTimersByTime(MAX_TIMEOUT_DELAY);
|
||||
|
||||
// onUpdate should NOT have been called because we hit the cap
|
||||
expect(onUpdateCallback).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward to the second timeout (still exceeds cap)
|
||||
vi.advanceTimersByTime(MAX_TIMEOUT_DELAY);
|
||||
|
||||
// Still should not have been called
|
||||
expect(onUpdateCallback).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward to the third timeout (within cap)
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// NOW onUpdate should have been called
|
||||
expect(onUpdateCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onUpdate normally when delay is within cap", () => {
|
||||
const normalDelay = 5000; // 5 seconds
|
||||
|
||||
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
|
||||
normalDelay
|
||||
);
|
||||
|
||||
const conditions: TimeCondition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// Fast-forward by the normal delay
|
||||
vi.advanceTimersByTime(normalDelay);
|
||||
|
||||
// onUpdate should have been called
|
||||
expect(onUpdateCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should reschedule after hitting the cap", () => {
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
|
||||
// First delay exceeds cap, second delay is normal
|
||||
const delays = [MAX_TIMEOUT_DELAY + 1000000, 5000];
|
||||
let callCount = 0;
|
||||
|
||||
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockImplementation(
|
||||
() => delays[callCount++]
|
||||
);
|
||||
|
||||
const conditions: TimeCondition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// First setTimeout call should use the capped delay
|
||||
expect(setTimeoutSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.any(Function),
|
||||
MAX_TIMEOUT_DELAY
|
||||
);
|
||||
|
||||
// Fast-forward to when the first timeout fires
|
||||
vi.advanceTimersByTime(MAX_TIMEOUT_DELAY);
|
||||
|
||||
// Second setTimeout call should use the normal delay
|
||||
expect(setTimeoutSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(Function),
|
||||
5000
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listener cleanup", () => {
|
||||
it("should register cleanup function for each time condition", () => {
|
||||
const normalDelay = 5000;
|
||||
|
||||
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
|
||||
normalDelay
|
||||
);
|
||||
|
||||
const conditions: TimeCondition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
},
|
||||
{
|
||||
condition: "time",
|
||||
before: "17:00",
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// Should have registered 2 cleanup functions (one per time condition)
|
||||
expect(listeners).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should clear timeout when cleanup is called", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
const normalDelay = 5000;
|
||||
|
||||
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
|
||||
normalDelay
|
||||
);
|
||||
|
||||
const conditions: TimeCondition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// Call cleanup
|
||||
listeners[0]();
|
||||
|
||||
// Should have cleared the timeout
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no time conditions", () => {
|
||||
it("should not setup listeners when no time conditions exist", () => {
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
|
||||
setupTimeListeners(
|
||||
[],
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// Should not have called setTimeout
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
expect(listeners).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undefined delay handling", () => {
|
||||
it("should not setup timeout when calculateNextTimeUpdate returns undefined", () => {
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
|
||||
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
|
||||
undefined
|
||||
);
|
||||
|
||||
const conditions: TimeCondition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
weekdays: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// Should not have called setTimeout
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupMediaQueryListeners", () => {
|
||||
let hass: HomeAssistant;
|
||||
let listeners: (() => void)[];
|
||||
let onUpdateCallback: (conditionsMet: boolean) => void;
|
||||
let listenMediaQuerySpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
listeners = [];
|
||||
onUpdateCallback = vi.fn();
|
||||
|
||||
hass = {
|
||||
locale: {
|
||||
time_zone: "local",
|
||||
},
|
||||
config: {
|
||||
time_zone: "America/New_York",
|
||||
},
|
||||
} as HomeAssistant;
|
||||
|
||||
// Mock matchMedia for screen condition checks
|
||||
global.matchMedia = vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock listenMediaQuery to capture the callback
|
||||
listenMediaQuerySpy = vi
|
||||
.spyOn(mediaQuery, "listenMediaQuery")
|
||||
.mockImplementation((_query, _callback) => vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listeners.forEach((unsub) => unsub());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("single media query", () => {
|
||||
it("should setup listener for single screen condition", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
setupMediaQueryListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
expect(listenMediaQuerySpy).toHaveBeenCalledWith(
|
||||
"(max-width: 600px)",
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(listeners).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should call onUpdate with matches value for single screen condition", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
let capturedCallback: ((matches: boolean) => void) | undefined;
|
||||
|
||||
listenMediaQuerySpy.mockImplementation((_query, callback) => {
|
||||
capturedCallback = callback;
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
setupMediaQueryListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// Simulate media query match
|
||||
capturedCallback?.(true);
|
||||
|
||||
// Should call onUpdate directly with the matches value
|
||||
expect(onUpdateCallback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple media queries", () => {
|
||||
it("should setup listeners for multiple screen conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(orientation: portrait)",
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
setupMediaQueryListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
expect(listenMediaQuerySpy).toHaveBeenCalledWith(
|
||||
"(max-width: 600px)",
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(listenMediaQuerySpy).toHaveBeenCalledWith(
|
||||
"(orientation: portrait)",
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(listeners).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should call onUpdate when media query changes with mixed conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
];
|
||||
|
||||
let capturedCallback: ((matches: boolean) => void) | undefined;
|
||||
|
||||
listenMediaQuerySpy.mockImplementation((_query, callback) => {
|
||||
capturedCallback = callback;
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
setupMediaQueryListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
// Simulate media query change
|
||||
capturedCallback?.(true);
|
||||
|
||||
// Should call onUpdate (would check all conditions)
|
||||
expect(onUpdateCallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no screen conditions", () => {
|
||||
it("should not setup listeners when no screen conditions exist", () => {
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
];
|
||||
|
||||
setupMediaQueryListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
expect(listenMediaQuerySpy).not.toHaveBeenCalled();
|
||||
expect(listeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle empty conditions array", () => {
|
||||
setupMediaQueryListeners(
|
||||
[],
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
expect(listenMediaQuerySpy).not.toHaveBeenCalled();
|
||||
expect(listeners).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listener cleanup", () => {
|
||||
it("should register cleanup functions", () => {
|
||||
const unsubFn = vi.fn();
|
||||
|
||||
listenMediaQuerySpy.mockReturnValue(unsubFn);
|
||||
|
||||
const conditions: Condition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
setupMediaQueryListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
);
|
||||
|
||||
expect(listeners).toHaveLength(1);
|
||||
|
||||
// Call cleanup
|
||||
listeners[0]();
|
||||
|
||||
// Should have called the unsubscribe function
|
||||
expect(unsubFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
320
test/common/condition/time-calculator.test.ts
Normal file
320
test/common/condition/time-calculator.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { calculateNextTimeUpdate } from "../../../src/common/condition/time-calculator";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
FirstWeekday,
|
||||
DateFormat,
|
||||
TimeZone,
|
||||
} from "../../../src/data/translation";
|
||||
|
||||
describe("calculateNextTimeUpdate", () => {
|
||||
let mockHass: HomeAssistant;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHass = {
|
||||
locale: {
|
||||
language: "en-US",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
config: {
|
||||
time_zone: "America/Los_Angeles",
|
||||
},
|
||||
} as HomeAssistant;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("after time calculation", () => {
|
||||
it("should calculate time until after time today when it hasn't passed", () => {
|
||||
// Set time to 7:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
|
||||
|
||||
// Should be ~1 hour + 1 minute buffer = 61 minutes
|
||||
expect(result).toBeGreaterThan(60 * 60 * 1000); // > 60 minutes
|
||||
expect(result).toBeLessThan(62 * 60 * 1000); // < 62 minutes
|
||||
});
|
||||
|
||||
it("should calculate time until after time tomorrow when it has passed", () => {
|
||||
// Set time to 9:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 9, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
|
||||
|
||||
// Should be ~23 hours + 1 minute buffer
|
||||
expect(result).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
expect(result).toBeLessThan(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("should handle after time exactly at current time", () => {
|
||||
// Set time to 8:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 8, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
|
||||
|
||||
// Should be scheduled for tomorrow
|
||||
expect(result).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("should handle after time with seconds", () => {
|
||||
// Set time to 7:59:30 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 7, 59, 30));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "08:00:00" });
|
||||
|
||||
// Should be ~30 seconds + 1 minute buffer
|
||||
expect(result).toBeGreaterThan(30 * 1000);
|
||||
expect(result).toBeLessThan(2 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("before time calculation", () => {
|
||||
it("should calculate time until before time today when it hasn't passed", () => {
|
||||
// Set time to 4:00 PM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 16, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { before: "17:00" });
|
||||
|
||||
// Should be ~1 hour + 1 minute buffer
|
||||
expect(result).toBeGreaterThan(60 * 60 * 1000);
|
||||
expect(result).toBeLessThan(62 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("should calculate time until before time tomorrow when it has passed", () => {
|
||||
// Set time to 6:00 PM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 18, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { before: "17:00" });
|
||||
|
||||
// Should be ~23 hours + 1 minute buffer
|
||||
expect(result).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
expect(result).toBeLessThan(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined after and before", () => {
|
||||
it("should return the soonest boundary when both are in the future", () => {
|
||||
// Set time to 7:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
});
|
||||
|
||||
// Should return time until 8:00 AM (soonest)
|
||||
expect(result).toBeGreaterThan(60 * 60 * 1000); // > 60 minutes
|
||||
expect(result).toBeLessThan(62 * 60 * 1000); // < 62 minutes
|
||||
});
|
||||
|
||||
it("should return the soonest boundary when within the range", () => {
|
||||
// Set time to 10:00 AM (within 08:00-17:00 range)
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
});
|
||||
|
||||
// Should return time until 5:00 PM (next boundary)
|
||||
expect(result).toBeGreaterThan(7 * 60 * 60 * 1000); // > 7 hours
|
||||
expect(result).toBeLessThan(8 * 60 * 60 * 1000); // < 8 hours
|
||||
});
|
||||
|
||||
it("should handle midnight crossing range", () => {
|
||||
// Set time to 11:00 PM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {
|
||||
after: "22:00",
|
||||
before: "06:00",
|
||||
});
|
||||
|
||||
// Should return time until 6:00 AM (next boundary)
|
||||
expect(result).toBeGreaterThan(7 * 60 * 60 * 1000); // > 7 hours
|
||||
expect(result).toBeLessThan(8 * 60 * 60 * 1000); // < 8 hours
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekday boundaries", () => {
|
||||
it("should schedule for midnight when weekdays are specified (not all 7)", () => {
|
||||
// Set time to Monday 10:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
});
|
||||
|
||||
// Should be scheduled for midnight (Tuesday)
|
||||
expect(result).toBeGreaterThan(14 * 60 * 60 * 1000); // > 14 hours
|
||||
expect(result).toBeLessThan(15 * 60 * 60 * 1000); // < 15 hours
|
||||
});
|
||||
|
||||
it("should not schedule midnight when all 7 weekdays specified", () => {
|
||||
// Set time to Monday 10:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {
|
||||
weekdays: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
|
||||
});
|
||||
|
||||
// Should return undefined (no boundaries)
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should combine weekday midnight with after time", () => {
|
||||
// Set time to Monday 7:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {
|
||||
after: "08:00",
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
});
|
||||
|
||||
// Should return the soonest (8:00 AM is sooner than midnight)
|
||||
expect(result).toBeGreaterThan(60 * 60 * 1000); // > 60 minutes
|
||||
expect(result).toBeLessThan(62 * 60 * 1000); // < 62 minutes
|
||||
});
|
||||
|
||||
it("should prefer midnight over later time boundary", () => {
|
||||
// Set time to Monday 11:00 PM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {
|
||||
after: "08:00",
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
});
|
||||
|
||||
// Should return midnight (sooner than 8:00 AM)
|
||||
expect(result).toBeGreaterThan(60 * 60 * 1000); // > 1 hour
|
||||
expect(result).toBeLessThan(2 * 60 * 60 * 1000); // < 2 hours
|
||||
});
|
||||
});
|
||||
|
||||
describe("no boundaries", () => {
|
||||
it("should return undefined when no conditions specified", () => {
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when only all weekdays specified", () => {
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {
|
||||
weekdays: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when empty weekdays array", () => {
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, {
|
||||
weekdays: [],
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buffer addition", () => {
|
||||
it("should add 1 minute buffer to next update time", () => {
|
||||
// Set time to 7:59 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 7, 59, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
|
||||
|
||||
// Should be ~1 minute for the boundary + 1 minute buffer = ~2 minutes
|
||||
expect(result).toBeGreaterThan(60 * 1000); // > 1 minute
|
||||
expect(result).toBeLessThan(3 * 60 * 1000); // < 3 minutes
|
||||
});
|
||||
});
|
||||
|
||||
describe("timezone handling", () => {
|
||||
it("should use server timezone when configured", () => {
|
||||
mockHass.locale.time_zone = TimeZone.server;
|
||||
mockHass.config.time_zone = "America/New_York";
|
||||
|
||||
// Set time to 7:00 AM local time
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
|
||||
|
||||
// Should calculate based on server timezone
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should use local timezone when configured", () => {
|
||||
mockHass.locale.time_zone = TimeZone.local;
|
||||
|
||||
// Set time to 7:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "08:00" });
|
||||
|
||||
// Should calculate based on local timezone
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle midnight (00:00) as after time", () => {
|
||||
// Set time to 11:00 PM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "00:00" });
|
||||
|
||||
// Should be ~1 hour + 1 minute buffer until midnight
|
||||
expect(result).toBeGreaterThan(60 * 60 * 1000);
|
||||
expect(result).toBeLessThan(62 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("should handle 23:59 as before time", () => {
|
||||
// Set time to 11:00 PM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { before: "23:59" });
|
||||
|
||||
// Should be ~59 minutes + 1 minute buffer
|
||||
expect(result).toBeGreaterThan(59 * 60 * 1000);
|
||||
expect(result).toBeLessThan(61 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("should handle very close boundary (seconds away)", () => {
|
||||
// Set time to 7:59:50 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 7, 59, 50));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "08:00:00" });
|
||||
|
||||
// Should be ~10 seconds + 1 minute buffer
|
||||
expect(result).toBeGreaterThan(10 * 1000);
|
||||
expect(result).toBeLessThan(2 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("should handle DST transition correctly", () => {
|
||||
// March 10, 2024 at 1:00 AM PST - before spring forward
|
||||
vi.setSystemTime(new Date(2024, 2, 10, 1, 0, 0));
|
||||
|
||||
const result = calculateNextTimeUpdate(mockHass, { after: "03:00" });
|
||||
|
||||
// Should handle the transition where 2:00 AM doesn't exist
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
307
test/common/datetime/check_time.test.ts
Normal file
307
test/common/datetime/check_time.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
checkTimeInRange,
|
||||
isValidTimeString,
|
||||
} from "../../../src/common/datetime/check_time";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
FirstWeekday,
|
||||
DateFormat,
|
||||
TimeZone,
|
||||
} from "../../../src/data/translation";
|
||||
|
||||
describe("isValidTimeString", () => {
|
||||
it("should accept valid HH:MM format", () => {
|
||||
expect(isValidTimeString("08:00")).toBe(true);
|
||||
expect(isValidTimeString("23:59")).toBe(true);
|
||||
expect(isValidTimeString("00:00")).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid HH:MM:SS format", () => {
|
||||
expect(isValidTimeString("08:00:30")).toBe(true);
|
||||
expect(isValidTimeString("23:59:59")).toBe(true);
|
||||
expect(isValidTimeString("00:00:00")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid formats", () => {
|
||||
expect(isValidTimeString("")).toBe(false);
|
||||
expect(isValidTimeString("8")).toBe(false);
|
||||
expect(isValidTimeString("8:00 AM")).toBe(false);
|
||||
expect(isValidTimeString("08:00:00:00")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid hour values", () => {
|
||||
expect(isValidTimeString("24:00")).toBe(false);
|
||||
expect(isValidTimeString("-01:00")).toBe(false);
|
||||
expect(isValidTimeString("25:00")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid minute values", () => {
|
||||
expect(isValidTimeString("08:60")).toBe(false);
|
||||
expect(isValidTimeString("08:-01")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid second values", () => {
|
||||
expect(isValidTimeString("08:00:60")).toBe(false);
|
||||
expect(isValidTimeString("08:00:-01")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkTimeInRange", () => {
|
||||
let mockHass: HomeAssistant;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHass = {
|
||||
locale: {
|
||||
language: "en-US",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
config: {
|
||||
time_zone: "America/Los_Angeles",
|
||||
},
|
||||
} as HomeAssistant;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("time ranges within same day", () => {
|
||||
it("should return true when current time is within range", () => {
|
||||
// Set time to 10:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when current time is before range", () => {
|
||||
// Set time to 7:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 7, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when current time is after range", () => {
|
||||
// Set time to 6:00 PM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 18, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("time ranges crossing midnight", () => {
|
||||
it("should return true when current time is before midnight", () => {
|
||||
// Set time to 11:00 PM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 23, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true exactly at the after boundary", () => {
|
||||
// Set time to 10:00 PM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 22, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when current time is after midnight", () => {
|
||||
// Set time to 3:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 3, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true exactly at the before boundary", () => {
|
||||
// Set time to 6:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 6, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when outside the range", () => {
|
||||
// Set time to 10:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "22:00", before: "06:00" })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("only 'after' condition", () => {
|
||||
it("should return true when after specified time", () => {
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
expect(checkTimeInRange(mockHass, { after: "08:00" })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when before specified time", () => {
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 6, 0, 0));
|
||||
expect(checkTimeInRange(mockHass, { after: "08:00" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("only 'before' condition", () => {
|
||||
it("should return true when before specified time", () => {
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
expect(checkTimeInRange(mockHass, { before: "17:00" })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when after specified time", () => {
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 18, 0, 0));
|
||||
expect(checkTimeInRange(mockHass, { before: "17:00" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekday filtering", () => {
|
||||
it("should return true on matching weekday", () => {
|
||||
// January 15, 2024 is a Monday
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
expect(checkTimeInRange(mockHass, { weekdays: ["mon"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false on non-matching weekday", () => {
|
||||
// January 15, 2024 is a Monday
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
expect(checkTimeInRange(mockHass, { weekdays: ["tue"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("should work with multiple weekdays", () => {
|
||||
// January 15, 2024 is a Monday
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { weekdays: ["mon", "wed", "fri"] })
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined time and weekday conditions", () => {
|
||||
it("should return true when both match", () => {
|
||||
// January 15, 2024 is a Monday at 10:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, {
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
weekdays: ["mon"],
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when time matches but weekday doesn't", () => {
|
||||
// January 15, 2024 is a Monday at 10:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, {
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
weekdays: ["tue"],
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when weekday matches but time doesn't", () => {
|
||||
// January 15, 2024 is a Monday at 6:00 AM
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 6, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, {
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
weekdays: ["mon"],
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no conditions", () => {
|
||||
it("should return true when no conditions specified", () => {
|
||||
vi.setSystemTime(new Date(2024, 0, 15, 10, 0, 0));
|
||||
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DST transitions", () => {
|
||||
it("should handle spring forward transition (losing an hour)", () => {
|
||||
// March 10, 2024 at 1:30 AM PST - before spring forward
|
||||
// At 2:00 AM, clocks jump to 3:00 AM PDT
|
||||
vi.setSystemTime(new Date(2024, 2, 10, 1, 30, 0));
|
||||
|
||||
// Should be within range that crosses the transition
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "01:00", before: "04:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle spring forward transition after the jump", () => {
|
||||
// March 10, 2024 at 3:30 AM PDT - after spring forward
|
||||
vi.setSystemTime(new Date(2024, 2, 10, 3, 30, 0));
|
||||
|
||||
// Should still be within range
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "01:00", before: "04:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle fall back transition (gaining an hour)", () => {
|
||||
// November 3, 2024 at 1:30 AM PDT - before fall back
|
||||
// At 2:00 AM PDT, clocks fall back to 1:00 AM PST
|
||||
vi.setSystemTime(new Date(2024, 10, 3, 1, 30, 0));
|
||||
|
||||
// Should be within range that crosses the transition
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "01:00", before: "03:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle midnight crossing during DST transition", () => {
|
||||
// March 10, 2024 at 1:00 AM - during spring forward night
|
||||
vi.setSystemTime(new Date(2024, 2, 10, 1, 0, 0));
|
||||
|
||||
// Range that crosses midnight and DST transition
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "22:00", before: "04:00" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should correctly compare times on DST transition day", () => {
|
||||
// November 3, 2024 at 10:00 AM - after fall back completed
|
||||
vi.setSystemTime(new Date(2024, 10, 3, 10, 0, 0));
|
||||
|
||||
// Normal business hours should work correctly
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "08:00", before: "17:00" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkTimeInRange(mockHass, { after: "12:00", before: "17:00" })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
test/common/util/parse-animation-duration.test.ts
Normal file
56
test/common/util/parse-animation-duration.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import { parseAnimationDuration } from "../../../src/common/util/parse-animation-duration";
|
||||
|
||||
describe("parseAnimationDuration", () => {
|
||||
it("Parses milliseconds with unit", () => {
|
||||
assert.equal(parseAnimationDuration("300ms"), 300);
|
||||
});
|
||||
|
||||
it("Parses seconds with unit", () => {
|
||||
assert.equal(parseAnimationDuration("3s"), 3000);
|
||||
});
|
||||
|
||||
it("Parses decimal seconds", () => {
|
||||
assert.equal(parseAnimationDuration("0.5s"), 500);
|
||||
});
|
||||
|
||||
it("Parses decimal milliseconds", () => {
|
||||
assert.equal(parseAnimationDuration("250.5ms"), 250.5);
|
||||
});
|
||||
|
||||
it("Handles whitespace", () => {
|
||||
assert.equal(parseAnimationDuration(" 300ms "), 300);
|
||||
assert.equal(parseAnimationDuration(" 3s "), 3000);
|
||||
});
|
||||
|
||||
it("Handles number without unit as milliseconds", () => {
|
||||
assert.equal(parseAnimationDuration("300"), 300);
|
||||
});
|
||||
|
||||
it("Returns 0 for invalid input", () => {
|
||||
assert.equal(parseAnimationDuration("invalid"), 0);
|
||||
});
|
||||
|
||||
it("Returns 0 for empty string", () => {
|
||||
assert.equal(parseAnimationDuration(""), 0);
|
||||
});
|
||||
|
||||
it("Returns 0 for negative values", () => {
|
||||
assert.equal(parseAnimationDuration("-300ms"), 0);
|
||||
assert.equal(parseAnimationDuration("-3s"), 0);
|
||||
});
|
||||
|
||||
it("Returns 0 for NaN", () => {
|
||||
assert.equal(parseAnimationDuration("NaN"), 0);
|
||||
});
|
||||
|
||||
it("Returns 0 for Infinity", () => {
|
||||
assert.equal(parseAnimationDuration("Infinity"), 0);
|
||||
});
|
||||
|
||||
it("Handles zero values", () => {
|
||||
assert.equal(parseAnimationDuration("0ms"), 0);
|
||||
assert.equal(parseAnimationDuration("0s"), 0);
|
||||
});
|
||||
});
|
||||
248
test/panels/lovelace/common/validate-time-condition.test.ts
Normal file
248
test/panels/lovelace/common/validate-time-condition.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateConditionalConfig } from "../../../../src/panels/lovelace/common/validate-condition";
|
||||
import type { TimeCondition } from "../../../../src/panels/lovelace/common/validate-condition";
|
||||
|
||||
describe("validateConditionalConfig - TimeCondition", () => {
|
||||
describe("valid configurations", () => {
|
||||
it("should accept valid after time", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid before time", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
before: "17:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid after and before times", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept time with seconds", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:00:30",
|
||||
before: "17:30:45",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept only weekdays", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept time and weekdays combined", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
before: "17:00",
|
||||
weekdays: ["mon", "tue", "wed", "thu", "fri"],
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept midnight times", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "00:00",
|
||||
before: "23:59",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept midnight crossing ranges", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "22:00",
|
||||
before: "06:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid time formats", () => {
|
||||
it("should reject invalid hour (> 23)", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "25:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid hour (< 0)", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "-01:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid minute (> 59)", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:60",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid minute (< 0)", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:-01",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid second (> 59)", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:00:60",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid second (< 0)", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:00:-01",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject non-numeric values", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:XX",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject 12-hour format", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "8:00 AM",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject single number", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "8",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject too many parts", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:00:00:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty string", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid configurations", () => {
|
||||
it("should reject when after and before are identical", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "09:00",
|
||||
before: "09:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject when no conditions specified", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid weekday", () => {
|
||||
const condition = {
|
||||
condition: "time",
|
||||
weekdays: ["monday"], // Should be "mon" not "monday"
|
||||
} as any;
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty weekdays array", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
weekdays: [],
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should accept single-digit hours with leading zero", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
before: "09:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject single-digit hours without leading zero", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "8:00",
|
||||
};
|
||||
// This should be rejected as hours should have 2 digits in HH:MM format
|
||||
// However, parseInt will parse it successfully, so this will pass
|
||||
// This is acceptable for flexibility
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept 00:00:00", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "00:00:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept 23:59:59", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
before: "23:59:59",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject both invalid times even if one is valid", () => {
|
||||
const condition: TimeCondition = {
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
before: "25:00",
|
||||
};
|
||||
expect(validateConditionalConfig([condition])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
yarn.lock
20
yarn.lock
@@ -1940,9 +1940,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@home-assistant/webawesome@npm:3.0.0-beta.6.ha.7":
|
||||
version: 3.0.0-beta.6.ha.7
|
||||
resolution: "@home-assistant/webawesome@npm:3.0.0-beta.6.ha.7"
|
||||
"@home-assistant/webawesome@npm:3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@home-assistant/webawesome@npm:3.0.0"
|
||||
dependencies:
|
||||
"@ctrl/tinycolor": "npm:4.1.0"
|
||||
"@floating-ui/dom": "npm:^1.6.13"
|
||||
@@ -1953,7 +1953,7 @@ __metadata:
|
||||
lit: "npm:^3.2.1"
|
||||
nanoid: "npm:^5.1.5"
|
||||
qr-creator: "npm:^1.0.0"
|
||||
checksum: 10/c20e5b60920a3cd5bbabb38e73d0a446c54074dcbb843272404b15b6a7e584b8a328393c1e845a2a400588fe15bdcd28d2c18aa2ce44b806f72a3b9343a3310f
|
||||
checksum: 10/03400894cfee8548fd5b1f5c56d31d253830e704b18ba69d36ce6b761d8b1bef2fb52cffba8d9b033033bb582f2f51a2d6444d82622f66d70150e2104fcb49e2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9226,7 +9226,7 @@ __metadata:
|
||||
"@fullcalendar/list": "npm:6.1.19"
|
||||
"@fullcalendar/luxon3": "npm:6.1.19"
|
||||
"@fullcalendar/timegrid": "npm:6.1.19"
|
||||
"@home-assistant/webawesome": "npm:3.0.0-beta.6.ha.7"
|
||||
"@home-assistant/webawesome": "npm:3.0.0"
|
||||
"@lezer/highlight": "npm:1.2.3"
|
||||
"@lit-labs/motion": "npm:1.0.9"
|
||||
"@lit-labs/observers": "npm:2.0.6"
|
||||
@@ -9351,7 +9351,7 @@ __metadata:
|
||||
lodash.template: "npm:4.5.0"
|
||||
luxon: "npm:3.7.2"
|
||||
map-stream: "npm:0.0.7"
|
||||
marked: "npm:16.4.2"
|
||||
marked: "npm:17.0.0"
|
||||
memoize-one: "npm:6.0.0"
|
||||
node-vibrant: "npm:4.0.3"
|
||||
object-hash: "npm:3.0.0"
|
||||
@@ -10984,12 +10984,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:16.4.2":
|
||||
version: 16.4.2
|
||||
resolution: "marked@npm:16.4.2"
|
||||
"marked@npm:17.0.0":
|
||||
version: 17.0.0
|
||||
resolution: "marked@npm:17.0.0"
|
||||
bin:
|
||||
marked: bin/marked.js
|
||||
checksum: 10/6e40e40661dce97e271198daa2054fc31e6445892a735e416c248fba046bdfa4573cafa08dc254529f105e7178a34485eb7f82573979cfb377a4530f66e79187
|
||||
checksum: 10/5544d27547851986c4e994a3f5739ea30bfe2616a9e1d5d5c8ced0fd561b5e971b3c7ee62b4fea1ea530e9886b89102d5c3b3bf962756494ced021f1accd6854
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user