mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-18 15:30:24 +00:00
Compare commits
16 Commits
add-automa
...
calendar-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882682d0ae | ||
|
|
ec22937e71 | ||
|
|
53a87278dd | ||
|
|
31383c114b | ||
|
|
7e01777eaa | ||
|
|
9c64f5ac8b | ||
|
|
912e636207 | ||
|
|
43367350b7 | ||
|
|
64a25cf7f9 | ||
|
|
6de8f47e24 | ||
|
|
0427c17a76 | ||
|
|
0052f14521 | ||
|
|
792274a82a | ||
|
|
7b42b16de8 | ||
|
|
fe98c0bdc0 | ||
|
|
658955a1b9 |
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -36,14 +36,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -57,4 +57,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
|
||||
@@ -260,6 +260,7 @@ 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 { LegacyTrigger } from "../../../../src/data/automation";
|
||||
import type { Trigger } 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: LegacyTrigger = {
|
||||
const initialTrigger: Trigger = {
|
||||
trigger: "state",
|
||||
entity_id: "light.kitchen",
|
||||
};
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,133 +0,0 @@
|
||||
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",
|
||||
"@home-assistant/webawesome": "3.0.0-beta.6.ha.7",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.0.9",
|
||||
"@lit-labs/observers": "2.0.6",
|
||||
@@ -115,14 +115,14 @@
|
||||
"home-assistant-js-websocket": "9.5.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "10.7.18",
|
||||
"js-yaml": "4.1.1",
|
||||
"js-yaml": "4.1.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.1",
|
||||
"lit-html": "3.3.1",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "17.0.0",
|
||||
"marked": "16.4.2",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.3",
|
||||
"object-hash": "3.0.0",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
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,7 +1,18 @@
|
||||
import { getWeekStartByLocale } from "weekstart";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { FirstWeekday } from "../../data/translation";
|
||||
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
|
||||
|
||||
export const weekdays = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
] as const;
|
||||
|
||||
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
|
||||
if (locale.first_weekday === FirstWeekday.language) {
|
||||
@@ -12,12 +23,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
|
||||
}
|
||||
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
|
||||
}
|
||||
return WEEKDAYS_LONG.includes(locale.first_weekday)
|
||||
? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex)
|
||||
return weekdays.includes(locale.first_weekday)
|
||||
? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex)
|
||||
: 1;
|
||||
};
|
||||
|
||||
export const firstWeekday = (locale: FrontendLocaleData) => {
|
||||
const index = firstWeekdayIndex(locale);
|
||||
return WEEKDAYS_LONG[index];
|
||||
return weekdays[index];
|
||||
};
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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,6 +1,5 @@
|
||||
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 {
|
||||
@@ -53,7 +52,7 @@ export const applyThemesOnElement = (
|
||||
|
||||
if (themeToApply && darkMode) {
|
||||
cacheKey = `${cacheKey}__dark`;
|
||||
themeRules = { ...darkSemanticVariables, ...darkColorVariables };
|
||||
themeRules = { ...darkColorVariables };
|
||||
}
|
||||
|
||||
if (themeToApply === "default") {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
@@ -30,7 +30,6 @@ export class HaFilterChip extends FilterChip {
|
||||
var(--rgb-primary-text-color),
|
||||
0.15
|
||||
);
|
||||
--_label-text-font: var(--ha-font-family-body);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -21,6 +21,7 @@ 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,
|
||||
@@ -474,7 +475,6 @@ export class HaStatisticPicker extends LitElement {
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.helper=${this.helper}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -154,10 +154,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
return this._getLabelsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.hass,
|
||||
this._labels,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-section-title")
|
||||
class HaSectionTitle extends LitElement {
|
||||
protected render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
color: var(--secondary-text-color);
|
||||
min-height: var(--ha-space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-section-title": HaSectionTitle;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } 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";
|
||||
@@ -27,29 +25,15 @@ 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._entityIds}
|
||||
.entityId=${this.selector.state?.entity_id ||
|
||||
this.context?.filter_entity}
|
||||
.attribute=${this.selector.state?.attribute ||
|
||||
this.context?.filter_attribute}
|
||||
.extraOptions=${this.selector.state?.extra_options}
|
||||
@@ -66,7 +50,8 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
|
||||
return html`
|
||||
<ha-entity-state-picker
|
||||
.hass=${this.hass}
|
||||
.entityId=${this._entityIds}
|
||||
.entityId=${this.selector.state?.entity_id ||
|
||||
this.context?.filter_entity}
|
||||
.attribute=${this.selector.state?.attribute ||
|
||||
this.context?.filter_attribute}
|
||||
.extraOptions=${this.selector.state?.extra_options}
|
||||
@@ -80,24 +65,6 @@ 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,7 +712,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._selectedSection = section as TargetTypeFloorless | undefined;
|
||||
|
||||
return this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this.entityFilter,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
@@ -726,7 +725,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _getItemsMemoized = memoizeOne(
|
||||
(
|
||||
localize: HomeAssistant["localize"],
|
||||
entityFilter: this["entityFilter"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
includeDomains: this["includeDomains"],
|
||||
@@ -744,7 +742,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
)[] = [];
|
||||
|
||||
if (!filterType || filterType === "entity") {
|
||||
let entityItems = this._getEntitiesMemoized(
|
||||
let entities = this._getEntitiesMemoized(
|
||||
this.hass,
|
||||
includeDomains,
|
||||
undefined,
|
||||
@@ -760,25 +758,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
if (searchTerm) {
|
||||
entityItems = this._filterGroup(
|
||||
entities = this._filterGroup(
|
||||
"entity",
|
||||
entityItems,
|
||||
entities,
|
||||
searchTerm,
|
||||
(item: EntityComboBoxItem) =>
|
||||
item.stateObj?.entity_id === searchTerm
|
||||
) as EntityComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!filterType && entityItems.length) {
|
||||
if (!filterType && entities.length) {
|
||||
// show group title
|
||||
items.push(localize("ui.components.target-picker.type.entities"));
|
||||
items.push(
|
||||
this.hass.localize("ui.components.target-picker.type.entities")
|
||||
);
|
||||
}
|
||||
|
||||
items.push(...entityItems);
|
||||
items.push(...entities);
|
||||
}
|
||||
|
||||
if (!filterType || filterType === "device") {
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
let devices = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
includeDomains,
|
||||
@@ -794,15 +794,17 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
if (searchTerm) {
|
||||
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
|
||||
devices = this._filterGroup("device", devices, searchTerm);
|
||||
}
|
||||
|
||||
if (!filterType && deviceItems.length) {
|
||||
if (!filterType && devices.length) {
|
||||
// show group title
|
||||
items.push(localize("ui.components.target-picker.type.devices"));
|
||||
items.push(
|
||||
this.hass.localize("ui.components.target-picker.type.devices")
|
||||
);
|
||||
}
|
||||
|
||||
items.push(...deviceItems);
|
||||
items.push(...devices);
|
||||
}
|
||||
|
||||
if (!filterType || filterType === "area") {
|
||||
@@ -834,7 +836,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType && areasAndFloors.length) {
|
||||
// show group title
|
||||
items.push(localize("ui.components.target-picker.type.areas"));
|
||||
items.push(
|
||||
this.hass.localize("ui.components.target-picker.type.areas")
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
@@ -858,10 +862,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType || filterType === "label") {
|
||||
let labels = this._getLabelsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.hass,
|
||||
this._labelRegistry,
|
||||
includeDomains,
|
||||
undefined,
|
||||
@@ -878,7 +879,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType && labels.length) {
|
||||
// show group title
|
||||
items.push(localize("ui.components.target-picker.type.labels"));
|
||||
items.push(
|
||||
this.hass.localize("ui.components.target-picker.type.labels")
|
||||
);
|
||||
}
|
||||
|
||||
items.push(...labels);
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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: {},
|
||||
dynamicGroups: {},
|
||||
serviceGroups: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -117,6 +117,14 @@ 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",
|
||||
|
||||
@@ -24,54 +24,11 @@ export interface FloorComboBoxItem extends PickerComboBoxItem {
|
||||
area?: AreaRegistryEntry;
|
||||
}
|
||||
|
||||
export interface FloorNestedComboBoxItem extends PickerComboBoxItem {
|
||||
floor?: FloorRegistryEntry;
|
||||
areas: FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
export interface UnassignedAreasFloorComboBoxItem extends PickerComboBoxItem {
|
||||
areas: FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
export interface AreaFloorValue {
|
||||
id: string;
|
||||
type: "floor" | "area";
|
||||
}
|
||||
|
||||
export const getAreasNestedInFloors = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
formatId: (value: AreaFloorValue) => string,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[],
|
||||
includeEmptyFloors = false
|
||||
) =>
|
||||
getAreasAndFloorsItems(
|
||||
states,
|
||||
haFloors,
|
||||
haAreas,
|
||||
haDevices,
|
||||
haEntities,
|
||||
formatId,
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeAreas,
|
||||
excludeFloors,
|
||||
includeEmptyFloors,
|
||||
true
|
||||
) as (FloorNestedComboBoxItem | UnassignedAreasFloorComboBoxItem)[];
|
||||
|
||||
export const getAreasAndFloors = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
@@ -85,47 +42,8 @@ export const getAreasAndFloors = (
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[],
|
||||
includeEmptyFloors = false
|
||||
) =>
|
||||
getAreasAndFloorsItems(
|
||||
states,
|
||||
haFloors,
|
||||
haAreas,
|
||||
haDevices,
|
||||
haEntities,
|
||||
formatId,
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeAreas,
|
||||
excludeFloors,
|
||||
includeEmptyFloors
|
||||
) as FloorComboBoxItem[];
|
||||
|
||||
const getAreasAndFloorsItems = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
formatId: (value: AreaFloorValue) => string,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[],
|
||||
includeEmptyFloors = false,
|
||||
nested = false
|
||||
): (
|
||||
| FloorComboBoxItem
|
||||
| FloorNestedComboBoxItem
|
||||
| UnassignedAreasFloorComboBoxItem
|
||||
)[] => {
|
||||
excludeFloors?: string[]
|
||||
): FloorComboBoxItem[] => {
|
||||
const floors = Object.values(haFloors);
|
||||
const areas = Object.values(haAreas);
|
||||
const devices = Object.values(haDevices);
|
||||
@@ -271,14 +189,6 @@ const getAreasAndFloorsItems = (
|
||||
|
||||
const compare = floorCompare(haFloors);
|
||||
|
||||
if (includeEmptyFloors) {
|
||||
Object.values(haFloors).forEach((floor) => {
|
||||
if (!floorAreaLookup[floor.floor_id]) {
|
||||
floorAreaLookup[floor.floor_id] = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const floorAreaEntries: [
|
||||
FloorRegistryEntry | undefined,
|
||||
@@ -290,15 +200,9 @@ const getAreasAndFloorsItems = (
|
||||
})
|
||||
.sort(([floorA], [floorB]) => compare(floorA.floor_id, floorB.floor_id));
|
||||
|
||||
const items: (
|
||||
| FloorComboBoxItem
|
||||
| FloorNestedComboBoxItem
|
||||
| UnassignedAreasFloorComboBoxItem
|
||||
)[] = [];
|
||||
const items: FloorComboBoxItem[] = [];
|
||||
|
||||
floorAreaEntries.forEach(([floor, floorAreas]) => {
|
||||
let floorItem: FloorComboBoxItem | FloorNestedComboBoxItem;
|
||||
|
||||
if (floor) {
|
||||
const floorName = computeFloorName(floor);
|
||||
|
||||
@@ -309,7 +213,7 @@ const getAreasAndFloorsItems = (
|
||||
})
|
||||
.flat();
|
||||
|
||||
floorItem = {
|
||||
items.push({
|
||||
id: formatId({ id: floor.floor_id, type: "floor" }),
|
||||
type: "floor",
|
||||
primary: floorName,
|
||||
@@ -321,9 +225,25 @@ const getAreasAndFloorsItems = (
|
||||
...floor.aliases,
|
||||
...areaSearchLabels,
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
const floorAreasItems = floorAreas.map((area) => {
|
||||
items.push(
|
||||
...floorAreas.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
items.push(
|
||||
...unassignedAreas.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
@@ -333,38 +253,8 @@ const getAreasAndFloorsItems = (
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
});
|
||||
|
||||
if (floor) {
|
||||
items.push(floorItem!);
|
||||
}
|
||||
|
||||
if (nested && floor) {
|
||||
(floorItem! as FloorNestedComboBoxItem).areas = floorAreasItems;
|
||||
} else {
|
||||
items.push(...floorAreasItems);
|
||||
}
|
||||
});
|
||||
|
||||
const unassignedAreaItems = unassignedAreas.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
});
|
||||
|
||||
if (nested && unassignedAreaItems.length) {
|
||||
items.push({
|
||||
areas: unassignedAreaItems,
|
||||
} as UnassignedAreasFloorComboBoxItem);
|
||||
} else {
|
||||
items.push(...unassignedAreaItems);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { DeviceRegistryEntry } from "./device_registry";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "./entity_registry";
|
||||
import type { EntityRegistryEntry } from "./entity_registry";
|
||||
import type { RegistryEntry } from "./registry";
|
||||
|
||||
export { subscribeAreaRegistry } from "./ws-area_registry";
|
||||
@@ -21,10 +18,7 @@ export interface AreaRegistryEntry extends RegistryEntry {
|
||||
temperature_entity_id: string | null;
|
||||
}
|
||||
|
||||
export type AreaEntityLookup = Record<
|
||||
string,
|
||||
(EntityRegistryEntry | EntityRegistryDisplayEntry)[]
|
||||
>;
|
||||
export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>;
|
||||
|
||||
export type AreaDeviceLookup = Record<string, DeviceRegistryEntry[]>;
|
||||
|
||||
@@ -66,17 +60,11 @@ export const deleteAreaRegistryEntry = (hass: HomeAssistant, areaId: string) =>
|
||||
});
|
||||
|
||||
export const getAreaEntityLookup = (
|
||||
entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[],
|
||||
filterHidden = false
|
||||
entities: EntityRegistryEntry[]
|
||||
): AreaEntityLookup => {
|
||||
const areaEntityLookup: AreaEntityLookup = {};
|
||||
for (const entity of entities) {
|
||||
if (
|
||||
!entity.area_id ||
|
||||
(filterHidden &&
|
||||
((entity as EntityRegistryDisplayEntry).hidden ||
|
||||
(entity as EntityRegistryEntry).hidden_by))
|
||||
) {
|
||||
if (!entity.area_id) {
|
||||
continue;
|
||||
}
|
||||
if (!(entity.area_id in areaEntityLookup)) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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";
|
||||
@@ -14,19 +12,10 @@ 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;
|
||||
@@ -96,12 +85,6 @@ 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 {
|
||||
@@ -211,7 +194,7 @@ export interface CalendarTrigger extends BaseTrigger {
|
||||
offset: string;
|
||||
}
|
||||
|
||||
export type LegacyTrigger =
|
||||
export type Trigger =
|
||||
| StateTrigger
|
||||
| MqttTrigger
|
||||
| GeoLocationTrigger
|
||||
@@ -228,9 +211,8 @@ export type LegacyTrigger =
|
||||
| TemplateTrigger
|
||||
| EventTrigger
|
||||
| DeviceTrigger
|
||||
| CalendarTrigger;
|
||||
|
||||
export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
|
||||
| CalendarTrigger
|
||||
| TriggerList;
|
||||
|
||||
interface BaseCondition {
|
||||
condition: string;
|
||||
@@ -275,11 +257,13 @@ 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?: WeekdayShort | WeekdayShort[];
|
||||
weekday?: Weekday | Weekday[];
|
||||
}
|
||||
|
||||
export interface TemplateCondition extends BaseCondition {
|
||||
@@ -592,7 +576,6 @@ export interface TriggerSidebarConfig extends BaseSidebarConfig {
|
||||
insertAfter: (value: Trigger | Trigger[]) => boolean;
|
||||
toggleYamlMode: () => void;
|
||||
config: Trigger;
|
||||
description?: TriggerDescription;
|
||||
yamlMode: boolean;
|
||||
uiSupported: boolean;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
formatListWithAnds,
|
||||
formatListWithOrs,
|
||||
} from "../common/string/format-list";
|
||||
import { hasTemplate } from "../common/string/has-template";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { Condition, ForDict, LegacyTrigger, Trigger } from "./automation";
|
||||
import type { Condition, ForDict, Trigger } from "./automation";
|
||||
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
|
||||
import {
|
||||
localizeDeviceAutomationCondition,
|
||||
@@ -26,7 +25,8 @@ import {
|
||||
} from "./device_automation";
|
||||
import type { EntityRegistryEntry } from "./entity_registry";
|
||||
import type { FrontendLocaleData } from "./translation";
|
||||
import { getTriggerDomain, getTriggerObjectId, isTriggerList } from "./trigger";
|
||||
import { isTriggerList } from "./trigger";
|
||||
import { hasTemplate } from "../common/string/has-template";
|
||||
|
||||
const triggerTranslationBaseKey =
|
||||
"ui.panel.config.automation.editor.triggers.type";
|
||||
@@ -121,37 +121,6 @@ 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[] = [];
|
||||
@@ -833,7 +802,13 @@ const describeLegacyTrigger = (
|
||||
}
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
|
||||
return (
|
||||
hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`
|
||||
) ||
|
||||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
|
||||
);
|
||||
};
|
||||
|
||||
export const describeCondition = (
|
||||
|
||||
@@ -53,6 +53,9 @@ export const enum CalendarEntityFeature {
|
||||
UPDATE_EVENT = 4,
|
||||
}
|
||||
|
||||
/** Type for date values that can come from REST API or subscription */
|
||||
type CalendarDateValue = string | { dateTime: string } | { date: string };
|
||||
|
||||
export const fetchCalendarEvents = async (
|
||||
hass: HomeAssistant,
|
||||
start: Date,
|
||||
@@ -65,11 +68,11 @@ export const fetchCalendarEvents = async (
|
||||
|
||||
const calEvents: CalendarEvent[] = [];
|
||||
const errors: string[] = [];
|
||||
const promises: Promise<CalendarEvent[]>[] = [];
|
||||
const promises: Promise<CalendarEventApiData[]>[] = [];
|
||||
|
||||
calendars.forEach((cal) => {
|
||||
promises.push(
|
||||
hass.callApi<CalendarEvent[]>(
|
||||
hass.callApi<CalendarEventApiData[]>(
|
||||
"GET",
|
||||
`calendars/${cal.entity_id}${params}`
|
||||
)
|
||||
@@ -77,7 +80,7 @@ export const fetchCalendarEvents = async (
|
||||
});
|
||||
|
||||
for (const [idx, promise] of promises.entries()) {
|
||||
let result: CalendarEvent[];
|
||||
let result: CalendarEventApiData[];
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
result = await promise;
|
||||
@@ -87,53 +90,16 @@ export const fetchCalendarEvents = async (
|
||||
}
|
||||
const cal = calendars[idx];
|
||||
result.forEach((ev) => {
|
||||
const eventStart = getCalendarDate(ev.start);
|
||||
const eventEnd = getCalendarDate(ev.end);
|
||||
if (!eventStart || !eventEnd) {
|
||||
return;
|
||||
const normalized = normalizeSubscriptionEventData(ev, cal);
|
||||
if (normalized) {
|
||||
calEvents.push(normalized);
|
||||
}
|
||||
const eventData: CalendarEventData = {
|
||||
uid: ev.uid,
|
||||
summary: ev.summary,
|
||||
description: ev.description,
|
||||
dtstart: eventStart,
|
||||
dtend: eventEnd,
|
||||
recurrence_id: ev.recurrence_id,
|
||||
rrule: ev.rrule,
|
||||
};
|
||||
const event: CalendarEvent = {
|
||||
start: eventStart,
|
||||
end: eventEnd,
|
||||
title: ev.summary,
|
||||
backgroundColor: cal.backgroundColor,
|
||||
borderColor: cal.backgroundColor,
|
||||
calendar: cal.entity_id,
|
||||
eventData: eventData,
|
||||
};
|
||||
|
||||
calEvents.push(event);
|
||||
});
|
||||
}
|
||||
|
||||
return { events: calEvents, errors };
|
||||
};
|
||||
|
||||
const getCalendarDate = (dateObj: any): string | undefined => {
|
||||
if (typeof dateObj === "string") {
|
||||
return dateObj;
|
||||
}
|
||||
|
||||
if (dateObj.dateTime) {
|
||||
return dateObj.dateTime;
|
||||
}
|
||||
|
||||
if (dateObj.date) {
|
||||
return dateObj.date;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getCalendars = (hass: HomeAssistant): Calendar[] =>
|
||||
Object.keys(hass.states)
|
||||
.filter(
|
||||
@@ -191,3 +157,89 @@ export const deleteCalendarEvent = (
|
||||
recurrence_id,
|
||||
recurrence_range,
|
||||
});
|
||||
|
||||
/**
|
||||
* Calendar event data from both REST API and WebSocket subscription.
|
||||
* Both APIs use the same data format.
|
||||
*/
|
||||
export interface CalendarEventApiData {
|
||||
summary: string;
|
||||
start: CalendarDateValue;
|
||||
end: CalendarDateValue;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
uid?: string | null;
|
||||
recurrence_id?: string | null;
|
||||
rrule?: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarEventSubscription {
|
||||
events: CalendarEventApiData[] | null;
|
||||
}
|
||||
|
||||
export const subscribeCalendarEvents = (
|
||||
hass: HomeAssistant,
|
||||
entity_id: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
callback: (update: CalendarEventSubscription) => void
|
||||
) =>
|
||||
hass.connection.subscribeMessage<CalendarEventSubscription>(callback, {
|
||||
type: "calendar/event/subscribe",
|
||||
entity_id,
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
});
|
||||
|
||||
const getCalendarDate = (dateObj: CalendarDateValue): string | undefined => {
|
||||
if (typeof dateObj === "string") {
|
||||
return dateObj;
|
||||
}
|
||||
|
||||
if ("dateTime" in dateObj) {
|
||||
return dateObj.dateTime;
|
||||
}
|
||||
|
||||
if ("date" in dateObj) {
|
||||
return dateObj.date;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize calendar event data from API format to internal format.
|
||||
* Handles both REST API format (with dateTime/date objects) and subscription format (strings).
|
||||
* Converts to internal format with { dtstart, dtend, ... }
|
||||
*/
|
||||
export const normalizeSubscriptionEventData = (
|
||||
eventData: CalendarEventApiData,
|
||||
calendar: Calendar
|
||||
): CalendarEvent | null => {
|
||||
const eventStart = getCalendarDate(eventData.start);
|
||||
const eventEnd = getCalendarDate(eventData.end);
|
||||
|
||||
if (!eventStart || !eventEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedEventData: CalendarEventData = {
|
||||
summary: eventData.summary,
|
||||
dtstart: eventStart,
|
||||
dtend: eventEnd,
|
||||
description: eventData.description ?? undefined,
|
||||
uid: eventData.uid ?? undefined,
|
||||
recurrence_id: eventData.recurrence_id ?? undefined,
|
||||
rrule: eventData.rrule ?? undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
start: eventStart,
|
||||
end: eventEnd,
|
||||
title: eventData.summary,
|
||||
backgroundColor: calendar.backgroundColor,
|
||||
borderColor: calendar.backgroundColor,
|
||||
calendar: calendar.entity_id,
|
||||
eventData: normalizedEventData,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -50,10 +50,7 @@ export type DeviceEntityDisplayLookup = Record<
|
||||
EntityRegistryDisplayEntry[]
|
||||
>;
|
||||
|
||||
export type DeviceEntityLookup = Record<
|
||||
string,
|
||||
(EntityRegistryEntry | EntityRegistryDisplayEntry)[]
|
||||
>;
|
||||
export type DeviceEntityLookup = Record<string, EntityRegistryEntry[]>;
|
||||
|
||||
export interface DeviceRegistryEntryMutableParams {
|
||||
area_id?: string | null;
|
||||
@@ -110,17 +107,11 @@ export const sortDeviceRegistryByName = (
|
||||
);
|
||||
|
||||
export const getDeviceEntityLookup = (
|
||||
entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[],
|
||||
filterHidden = false
|
||||
entities: EntityRegistryEntry[]
|
||||
): DeviceEntityLookup => {
|
||||
const deviceEntityLookup: DeviceEntityLookup = {};
|
||||
for (const entity of entities) {
|
||||
if (
|
||||
!entity.device_id ||
|
||||
(filterHidden &&
|
||||
((entity as EntityRegistryDisplayEntry).hidden ||
|
||||
(entity as EntityRegistryEntry).hidden_by))
|
||||
) {
|
||||
if (!entity.device_id) {
|
||||
continue;
|
||||
}
|
||||
if (!(entity.device_id in deviceEntityLookup)) {
|
||||
|
||||
@@ -102,7 +102,6 @@ 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;
|
||||
}
|
||||
@@ -131,17 +130,11 @@ 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;
|
||||
}
|
||||
@@ -150,7 +143,6 @@ export interface SolarSourceTypeEnergyPreference {
|
||||
type: "solar";
|
||||
|
||||
stat_energy_from: string;
|
||||
stat_rate?: string;
|
||||
config_entry_solar_forecast: string[] | null;
|
||||
}
|
||||
|
||||
@@ -158,7 +150,6 @@ export interface BatterySourceTypeEnergyPreference {
|
||||
type: "battery";
|
||||
stat_energy_from: string;
|
||||
stat_energy_to: string;
|
||||
stat_rate?: string;
|
||||
}
|
||||
export interface GasSourceTypeEnergyPreference {
|
||||
type: "gas";
|
||||
@@ -360,35 +351,6 @@ 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",
|
||||
@@ -436,10 +398,9 @@ const getEnergyData = async (
|
||||
"gas",
|
||||
"device",
|
||||
]);
|
||||
const powerStatIds = getReferencedStatisticIdsPower(prefs);
|
||||
const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]);
|
||||
|
||||
const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds];
|
||||
const allStatIDs = [...energyStatIds, ...waterStatIds];
|
||||
|
||||
const dayDifference = differenceInDays(end || new Date(), start);
|
||||
const period =
|
||||
@@ -450,8 +411,6 @@ 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
|
||||
@@ -473,9 +432,6 @@ 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,
|
||||
@@ -486,12 +442,6 @@ 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",
|
||||
@@ -598,7 +548,6 @@ const getEnergyData = async (
|
||||
|
||||
const [
|
||||
energyStats,
|
||||
powerStats,
|
||||
waterStats,
|
||||
energyStatsCompare,
|
||||
waterStatsCompare,
|
||||
@@ -606,14 +555,13 @@ const getEnergyData = async (
|
||||
fossilEnergyConsumptionCompare,
|
||||
] = await Promise.all([
|
||||
_energyStats,
|
||||
_powerStats,
|
||||
_waterStats,
|
||||
_energyStatsCompare,
|
||||
_waterStatsCompare,
|
||||
_fossilEnergyConsumption,
|
||||
_fossilEnergyConsumptionCompare,
|
||||
]);
|
||||
const stats = { ...energyStats, ...waterStats, ...powerStats };
|
||||
const stats = { ...energyStats, ...waterStats };
|
||||
if (compare) {
|
||||
statsCompare = { ...energyStatsCompare, ...waterStatsCompare };
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ 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;
|
||||
@@ -134,19 +133,14 @@ 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 | TriggerIcons,
|
||||
T extends ComponentIcons | PlatformIcons | ServiceIcons,
|
||||
> {
|
||||
resources: Record<string, T>;
|
||||
}
|
||||
@@ -190,22 +184,12 @@ type ServiceIcons = Record<
|
||||
{ service: string; sections?: Record<string, string> }
|
||||
>;
|
||||
|
||||
type TriggerIcons = Record<
|
||||
string,
|
||||
{ trigger: string; sections?: Record<string, string> }
|
||||
>;
|
||||
|
||||
export type IconCategory =
|
||||
| "entity"
|
||||
| "entity_component"
|
||||
| "services"
|
||||
| "triggers";
|
||||
export type IconCategory = "entity" | "entity_component" | "services";
|
||||
|
||||
interface CategoryType {
|
||||
entity: PlatformIcons;
|
||||
entity_component: ComponentIcons;
|
||||
services: ServiceIcons;
|
||||
triggers: TriggerIcons;
|
||||
}
|
||||
|
||||
export const getHassIcons = async <T extends IconCategory>(
|
||||
@@ -274,59 +258,42 @@ export const getComponentIcons = async (
|
||||
return resources.entity_component.resources.then((res) => res[domain]);
|
||||
};
|
||||
|
||||
export const getCategoryIcons = async <
|
||||
T extends Exclude<IconCategory, "entity" | "entity_component">,
|
||||
>(
|
||||
export const getServiceIcons = async (
|
||||
hass: HomeAssistant,
|
||||
category: T,
|
||||
domain?: string,
|
||||
force = false
|
||||
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
|
||||
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> => {
|
||||
if (!domain) {
|
||||
if (!force && resources[category].all) {
|
||||
return resources[category].all as Promise<
|
||||
Record<string, CategoryType[T]>
|
||||
>;
|
||||
if (!force && resources.services.all) {
|
||||
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]>>;
|
||||
resources.services.all = getHassIcons(hass, "services", domain).then(
|
||||
(res) => {
|
||||
resources.services.domains = res.resources;
|
||||
return res?.resources;
|
||||
}
|
||||
);
|
||||
return resources.services.all;
|
||||
}
|
||||
if (!force && domain in resources[category].domains) {
|
||||
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||
if (!force && 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 (resources.services.all && !force) {
|
||||
await resources.services.all;
|
||||
if (domain in resources.services.domains) {
|
||||
return resources.services.domains[domain];
|
||||
}
|
||||
}
|
||||
if (!isComponentLoaded(hass, domain)) {
|
||||
return undefined;
|
||||
}
|
||||
const result = getHassIcons(hass, category, domain);
|
||||
resources[category].domains[domain] = result.then(
|
||||
const result = getHassIcons(hass, "services", domain);
|
||||
resources.services.domains[domain] = result.then(
|
||||
(res) => res?.resources[domain]
|
||||
) as any;
|
||||
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||
);
|
||||
return resources.services.domains[domain];
|
||||
};
|
||||
|
||||
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[]>();
|
||||
|
||||
@@ -506,26 +473,6 @@ 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
|
||||
|
||||
@@ -101,10 +101,7 @@ export const deleteLabelRegistryEntry = (
|
||||
});
|
||||
|
||||
export const getLabels = (
|
||||
hassStates: HomeAssistant["states"],
|
||||
hassAreas: HomeAssistant["areas"],
|
||||
hassDevices: HomeAssistant["devices"],
|
||||
hassEntities: HomeAssistant["entities"],
|
||||
hass: HomeAssistant,
|
||||
labels?: LabelRegistryEntry[],
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
@@ -118,8 +115,8 @@ export const getLabels = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const devices = Object.values(hassDevices);
|
||||
const entities = Object.values(hassEntities);
|
||||
const devices = Object.values(hass.devices);
|
||||
const entities = Object.values(hass.entities);
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
@@ -173,7 +170,7 @@ export const getLabels = (
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
@@ -184,7 +181,7 @@ export const getLabels = (
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
@@ -203,7 +200,7 @@ export const getLabels = (
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
@@ -211,7 +208,7 @@ export const getLabels = (
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
@@ -248,7 +245,7 @@ export const getLabels = (
|
||||
|
||||
if (areaIds) {
|
||||
areaIds.forEach((areaId) => {
|
||||
const area = hassAreas[areaId];
|
||||
const area = hass.areas[areaId];
|
||||
area.labels.forEach((label) => usedLabels.add(label));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export interface TodoItem {
|
||||
status: TodoItemStatus | null;
|
||||
description?: string | null;
|
||||
due?: string | null;
|
||||
completed?: string | null;
|
||||
}
|
||||
|
||||
export const enum TodoListEntityFeature {
|
||||
|
||||
@@ -73,8 +73,7 @@ export type TranslationCategory =
|
||||
| "application_credentials"
|
||||
| "issues"
|
||||
| "selector"
|
||||
| "services"
|
||||
| "triggers";
|
||||
| "services";
|
||||
|
||||
export const subscribeTranslationPreferences = (
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
import { mdiMapClock, mdiShape } from "@mdi/js";
|
||||
import {
|
||||
mdiAvTimer,
|
||||
mdiCalendar,
|
||||
mdiClockOutline,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiFormatListBulleted,
|
||||
mdiGestureDoubleTap,
|
||||
mdiMapClock,
|
||||
mdiMapMarker,
|
||||
mdiMapMarkerRadius,
|
||||
mdiMessageAlert,
|
||||
mdiMicrophoneMessage,
|
||||
mdiNfcVariant,
|
||||
mdiNumeric,
|
||||
mdiShape,
|
||||
mdiStateMachine,
|
||||
mdiSwapHorizontal,
|
||||
mdiWeatherSunny,
|
||||
mdiWebhook,
|
||||
} from "@mdi/js";
|
||||
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
||||
import type {
|
||||
AutomationElementGroupCollection,
|
||||
Trigger,
|
||||
TriggerList,
|
||||
} from "./automation";
|
||||
import type { Selector, TargetSelector } from "./selector";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device: {},
|
||||
dynamicGroups: {},
|
||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
@@ -46,33 +83,3 @@ 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,8 +2,7 @@ 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/ha-wa-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import { createCloseHeading } from "../../components/ha-dialog";
|
||||
import "../../components/ha-formfield";
|
||||
import "../../components/ha-switch";
|
||||
import "../../components/ha-button";
|
||||
@@ -29,8 +28,6 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public async showDialog(
|
||||
params: ConfigEntrySystemOptionsDialogParams
|
||||
): Promise<void> {
|
||||
@@ -38,14 +35,9 @@ 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 });
|
||||
@@ -57,19 +49,18 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.dialogs.config_entry_system_options.title",
|
||||
{
|
||||
<ha-dialog
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
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
|
||||
@@ -91,10 +82,10 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
</p>`}
|
||||
>
|
||||
<ha-switch
|
||||
autofocus
|
||||
.checked=${!this._disableNewEntities}
|
||||
@change=${this._disableNewEntitiesChanged}
|
||||
.disabled=${this._submitting}
|
||||
dialogInitialFocus
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
|
||||
@@ -122,27 +113,22 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
.disabled=${this._submitting}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,44 +20,23 @@
|
||||
<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;
|
||||
@@ -80,14 +59,6 @@
|
||||
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,7 +35,6 @@ 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,56 +1,82 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Base config type that can be used with conditional listeners
|
||||
* Extract media queries from conditions recursively
|
||||
*/
|
||||
export interface ConditionalConfig {
|
||||
visibility?: Condition[];
|
||||
[key: string]: any;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixin to handle conditional listeners for visibility control
|
||||
*
|
||||
* Provides lifecycle management for listeners that control conditional
|
||||
* visibility of components.
|
||||
* Provides lifecycle management for listeners (media queries, time-based, state changes, etc.)
|
||||
* that control conditional visibility of components.
|
||||
*
|
||||
* Usage:
|
||||
* 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)
|
||||
* 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
|
||||
*
|
||||
* 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 = <
|
||||
TConfig extends ConditionalConfig = ConditionalConfig,
|
||||
T extends Constructor<ReactiveElement>,
|
||||
>(
|
||||
superClass: Constructor<ReactiveElement>
|
||||
superClass: T
|
||||
) => {
|
||||
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();
|
||||
@@ -61,72 +87,17 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
protected setupConditionalListeners(): void {
|
||||
// Override in subclass
|
||||
}
|
||||
}
|
||||
return ConditionalListenerClass;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { mdiChevronDown, mdiPlus, mdiRefresh } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
@@ -20,8 +21,17 @@ import "../../components/ha-menu-button";
|
||||
import "../../components/ha-state-icon";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-two-pane-top-app-bar-fixed";
|
||||
import type { Calendar, CalendarEvent } from "../../data/calendar";
|
||||
import { fetchCalendarEvents, getCalendars } from "../../data/calendar";
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
CalendarEventSubscription,
|
||||
CalendarEventApiData,
|
||||
} from "../../data/calendar";
|
||||
import {
|
||||
getCalendars,
|
||||
normalizeSubscriptionEventData,
|
||||
subscribeCalendarEvents,
|
||||
} from "../../data/calendar";
|
||||
import { fetchIntegrationManifest } from "../../data/integration";
|
||||
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
@@ -42,6 +52,8 @@ class PanelCalendar extends LitElement {
|
||||
|
||||
@state() private _error?: string = undefined;
|
||||
|
||||
@state() private _errorCalendars: string[] = [];
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
key: "deSelectedCalendars",
|
||||
@@ -53,6 +65,8 @@ class PanelCalendar extends LitElement {
|
||||
|
||||
private _end?: Date;
|
||||
|
||||
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
|
||||
|
||||
private _showPaneController = new ResizeController(this, {
|
||||
callback: (entries) => entries[0]?.contentRect.width > 750,
|
||||
});
|
||||
@@ -78,6 +92,7 @@ class PanelCalendar extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
this._mql?.removeListener(this._setIsMobile!);
|
||||
this._mql = undefined;
|
||||
this._unsubscribeAll();
|
||||
}
|
||||
|
||||
private _setIsMobile = (ev: MediaQueryListEvent) => {
|
||||
@@ -194,19 +209,95 @@ class PanelCalendar extends LitElement {
|
||||
.map((cal) => cal);
|
||||
}
|
||||
|
||||
private async _fetchEvents(
|
||||
start: Date | undefined,
|
||||
end: Date | undefined,
|
||||
calendars: Calendar[]
|
||||
): Promise<{ events: CalendarEvent[]; errors: string[] }> {
|
||||
if (!calendars.length || !start || !end) {
|
||||
return { events: [], errors: [] };
|
||||
private _subscribeCalendarEvents(calendars: Calendar[]): void {
|
||||
if (!this._start || !this._end || calendars.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return fetchCalendarEvents(this.hass, start, end, calendars);
|
||||
this._error = undefined;
|
||||
|
||||
calendars.forEach((calendar) => {
|
||||
// Unsubscribe existing subscription if any
|
||||
if (calendar.entity_id in this._unsubs) {
|
||||
this._unsubs[calendar.entity_id]
|
||||
.then((unsubFunc) => unsubFunc())
|
||||
.catch(() => {
|
||||
// Subscription may have already been closed
|
||||
});
|
||||
}
|
||||
|
||||
const unsub = subscribeCalendarEvents(
|
||||
this.hass,
|
||||
calendar.entity_id,
|
||||
this._start!,
|
||||
this._end!,
|
||||
(update: CalendarEventSubscription) => {
|
||||
this._handleCalendarUpdate(calendar, update);
|
||||
}
|
||||
);
|
||||
this._unsubs[calendar.entity_id] = unsub;
|
||||
});
|
||||
}
|
||||
|
||||
private async _requestSelected(ev: CustomEvent<RequestSelectedDetail>) {
|
||||
private _handleCalendarUpdate(
|
||||
calendar: Calendar,
|
||||
update: CalendarEventSubscription
|
||||
): void {
|
||||
// Remove events from this calendar
|
||||
this._events = this._events.filter(
|
||||
(event) => event.calendar !== calendar.entity_id
|
||||
);
|
||||
|
||||
if (update.events === null) {
|
||||
// Error fetching events
|
||||
if (!this._errorCalendars.includes(calendar.entity_id)) {
|
||||
this._errorCalendars = [...this._errorCalendars, calendar.entity_id];
|
||||
}
|
||||
this._handleErrors(this._errorCalendars);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from error list if successfully loaded
|
||||
this._errorCalendars = this._errorCalendars.filter(
|
||||
(id) => id !== calendar.entity_id
|
||||
);
|
||||
this._handleErrors(this._errorCalendars);
|
||||
|
||||
// Add new events from this calendar
|
||||
const newEvents: CalendarEvent[] = update.events
|
||||
.map((eventData: CalendarEventApiData) =>
|
||||
normalizeSubscriptionEventData(eventData, calendar)
|
||||
)
|
||||
.filter((event): event is CalendarEvent => event !== null);
|
||||
|
||||
this._events = [...this._events, ...newEvents];
|
||||
}
|
||||
|
||||
private async _unsubscribeAll(): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.values(this._unsubs).map((unsub) =>
|
||||
unsub
|
||||
.then((unsubFunc) => unsubFunc())
|
||||
.catch(() => {
|
||||
// Subscription may have already been closed
|
||||
})
|
||||
)
|
||||
);
|
||||
this._unsubs = {};
|
||||
}
|
||||
|
||||
private _unsubscribeCalendar(entityId: string): void {
|
||||
if (entityId in this._unsubs) {
|
||||
this._unsubs[entityId]
|
||||
.then((unsubFunc) => unsubFunc())
|
||||
.catch(() => {
|
||||
// Subscription may have already been closed
|
||||
});
|
||||
delete this._unsubs[entityId];
|
||||
}
|
||||
}
|
||||
|
||||
private _requestSelected(ev: CustomEvent<RequestSelectedDetail>) {
|
||||
ev.stopPropagation();
|
||||
const entityId = (ev.target as HaListItem).value;
|
||||
if (ev.detail.selected) {
|
||||
@@ -223,13 +314,10 @@ class PanelCalendar extends LitElement {
|
||||
if (!calendar) {
|
||||
return;
|
||||
}
|
||||
const result = await this._fetchEvents(this._start, this._end, [
|
||||
calendar,
|
||||
]);
|
||||
this._events = [...this._events, ...result.events];
|
||||
this._handleErrors(result.errors);
|
||||
this._subscribeCalendarEvents([calendar]);
|
||||
} else {
|
||||
this._deSelectedCalendars = [...this._deSelectedCalendars, entityId];
|
||||
this._unsubscribeCalendar(entityId);
|
||||
this._events = this._events.filter(
|
||||
(event) => event.calendar !== entityId
|
||||
);
|
||||
@@ -254,23 +342,15 @@ class PanelCalendar extends LitElement {
|
||||
): Promise<void> {
|
||||
this._start = ev.detail.start;
|
||||
this._end = ev.detail.end;
|
||||
const result = await this._fetchEvents(
|
||||
this._start,
|
||||
this._end,
|
||||
this._selectedCalendars
|
||||
);
|
||||
this._events = result.events;
|
||||
this._handleErrors(result.errors);
|
||||
await this._unsubscribeAll();
|
||||
this._events = [];
|
||||
this._subscribeCalendarEvents(this._selectedCalendars);
|
||||
}
|
||||
|
||||
private async _handleRefresh(): Promise<void> {
|
||||
const result = await this._fetchEvents(
|
||||
this._start,
|
||||
this._end,
|
||||
this._selectedCalendars
|
||||
);
|
||||
this._events = result.events;
|
||||
this._handleErrors(result.errors);
|
||||
await this._unsubscribeAll();
|
||||
this._events = [];
|
||||
this._subscribeCalendarEvents(this._selectedCalendars);
|
||||
}
|
||||
|
||||
private _handleErrors(error_entity_ids: string[]) {
|
||||
|
||||
@@ -14,13 +14,11 @@ import "../../../../components/ha-sortable";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import {
|
||||
ACTION_BUILDING_BLOCKS,
|
||||
getService,
|
||||
isService,
|
||||
VIRTUAL_ACTIONS,
|
||||
} from "../../../../data/action";
|
||||
import {
|
||||
getValueFromDynamic,
|
||||
isDynamic,
|
||||
type AutomationClipboard,
|
||||
} from "../../../../data/automation";
|
||||
import type { AutomationClipboard } from "../../../../data/automation";
|
||||
import type { Action } from "../../../../data/script";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
@@ -219,9 +217,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 (isDynamic(action)) {
|
||||
} else if (isService(action)) {
|
||||
actions = this.actions.concat({
|
||||
action: getValueFromDynamic(action),
|
||||
action: getService(action),
|
||||
metadata: {},
|
||||
});
|
||||
} else {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ 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,6 +17,7 @@ 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,9 +6,6 @@ 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,15 +15,8 @@ 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 {
|
||||
LegacyTrigger,
|
||||
TriggerSidebarConfig,
|
||||
} from "../../../../data/automation";
|
||||
import {
|
||||
getTriggerDomain,
|
||||
getTriggerObjectId,
|
||||
isTriggerList,
|
||||
} from "../../../../data/trigger";
|
||||
import type { TriggerSidebarConfig } from "../../../../data/automation";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
||||
@@ -70,7 +63,8 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
|
||||
protected render() {
|
||||
const rowDisabled =
|
||||
"enabled" in this.config.config && this.config.config.enabled === false;
|
||||
this.disabled ||
|
||||
("enabled" in this.config.config && this.config.config.enabled === false);
|
||||
const type = isTriggerList(this.config.config)
|
||||
? "list"
|
||||
: this.config.config.trigger;
|
||||
@@ -79,18 +73,9 @@ export default class HaAutomationSidebarTrigger extends LitElement {
|
||||
"ui.panel.config.automation.editor.triggers.trigger"
|
||||
);
|
||||
|
||||
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`);
|
||||
const title = this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.${type}.label`
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-automation-sidebar-card
|
||||
@@ -284,7 +269,6 @@ 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,12 +9,10 @@ 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 {
|
||||
@@ -33,8 +31,6 @@ 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() {
|
||||
@@ -91,18 +87,11 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
<div @value-changed=${this._onUiChanged}>
|
||||
${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,
|
||||
})}
|
||||
${dynamicElement(`ha-automation-trigger-${type}`, {
|
||||
hass: this.hass,
|
||||
trigger: this.trigger,
|
||||
disabled: this.disabled,
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
@@ -40,11 +40,9 @@ 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";
|
||||
@@ -52,8 +50,7 @@ import { describeTrigger } from "../../../../data/automation_i18n";
|
||||
import { validateConfig } from "../../../../data/config";
|
||||
import { fullEntitiesContext } from "../../../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
|
||||
import type { TriggerDescriptions } from "../../../../data/trigger";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import { TRIGGER_ICONS, isTriggerList } from "../../../../data/trigger";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showPromptDialog,
|
||||
@@ -75,7 +72,6 @@ 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";
|
||||
@@ -141,9 +137,6 @@ 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")
|
||||
@@ -185,24 +178,18 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
}
|
||||
|
||||
private _renderRow() {
|
||||
const type = this._getType(this.trigger, this.triggerDescriptions);
|
||||
const type = this._getType(this.trigger);
|
||||
|
||||
const supported = this._uiSupported(type);
|
||||
|
||||
const yamlMode = this._yamlMode || !supported;
|
||||
|
||||
return html`
|
||||
${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>`}
|
||||
<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
class="trigger-icon"
|
||||
.path=${TRIGGER_ICONS[type]}
|
||||
></ha-svg-icon>
|
||||
<h3 slot="header">
|
||||
${describeTrigger(this.trigger, this.hass, this._entityReg)}
|
||||
</h3>
|
||||
@@ -406,9 +393,6 @@ 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}
|
||||
@@ -568,7 +552,6 @@ 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 });
|
||||
@@ -593,14 +576,8 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
duplicate: this._duplicateTrigger,
|
||||
cut: this._cutTrigger,
|
||||
insertAfter: this._insertAfter,
|
||||
config: trigger,
|
||||
uiSupported: this._uiSupported(
|
||||
this._getType(trigger, this.triggerDescriptions)
|
||||
),
|
||||
description:
|
||||
"trigger" in trigger
|
||||
? this.triggerDescriptions[trigger.trigger]
|
||||
: undefined,
|
||||
config: trigger || this.trigger,
|
||||
uiSupported: this._uiSupported(this._getType(trigger || this.trigger)),
|
||||
yamlMode: this._yamlMode,
|
||||
} satisfies TriggerSidebarConfig);
|
||||
this._selected = true;
|
||||
@@ -782,18 +759,8 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _getType = memoizeOne(
|
||||
(trigger: Trigger, triggerDescriptions: TriggerDescriptions) => {
|
||||
if (isTriggerList(trigger)) {
|
||||
return "list";
|
||||
}
|
||||
|
||||
if (trigger.trigger in triggerDescriptions) {
|
||||
return "platform";
|
||||
}
|
||||
|
||||
return trigger.trigger;
|
||||
}
|
||||
private _getType = memoizeOne((trigger: Trigger) =>
|
||||
isTriggerList(trigger) ? "list" : trigger.trigger
|
||||
);
|
||||
|
||||
private _uiSupported = memoizeOne(
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -13,16 +12,12 @@ import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-button-menu";
|
||||
import "../../../../components/ha-sortable";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import {
|
||||
getValueFromDynamic,
|
||||
isDynamic,
|
||||
type AutomationClipboard,
|
||||
type Trigger,
|
||||
type TriggerList,
|
||||
import type {
|
||||
AutomationClipboard,
|
||||
Trigger,
|
||||
TriggerList,
|
||||
} from "../../../../data/automation";
|
||||
import type { TriggerDescriptions } from "../../../../data/trigger";
|
||||
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
PASTE_VALUE,
|
||||
@@ -31,9 +26,10 @@ 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 SubscribeMixin(LitElement) {
|
||||
export default class HaAutomationTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public triggers!: Trigger[];
|
||||
@@ -66,23 +62,6 @@ export default class HaAutomationTrigger extends SubscribeMixin(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
|
||||
@@ -106,7 +85,6 @@ export default class HaAutomationTrigger extends SubscribeMixin(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}
|
||||
@@ -178,10 +156,6 @@ export default class HaAutomationTrigger extends SubscribeMixin(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(
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
EnergySource,
|
||||
FlowFromGridSourceEnergyPreference,
|
||||
FlowToGridSourceEnergyPreference,
|
||||
GridPowerSourceEnergyPreference,
|
||||
GridSourceTypeEnergyPreference,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
@@ -48,7 +47,6 @@ import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import {
|
||||
showEnergySettingsGridFlowFromDialog,
|
||||
showEnergySettingsGridFlowToDialog,
|
||||
showEnergySettingsGridPowerDialog,
|
||||
} from "../dialogs/show-dialogs-energy";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
@@ -228,58 +226,6 @@ 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"
|
||||
@@ -553,97 +499,6 @@ 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<
|
||||
@@ -652,8 +507,7 @@ export class EnergyGridSettings extends LitElement {
|
||||
if (
|
||||
source.type !== "grid" ||
|
||||
source.flow_from.length > 0 ||
|
||||
source.flow_to.length > 0 ||
|
||||
(source.power && source.power.length > 0)
|
||||
source.flow_to.length > 0
|
||||
) {
|
||||
acc.push(source);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ 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
|
||||
@@ -33,14 +32,10 @@ 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> {
|
||||
@@ -51,9 +46,6 @@ 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);
|
||||
@@ -64,9 +56,6 @@ 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() {
|
||||
@@ -83,6 +72,8 @@ export class DialogEnergyBatterySettings
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@@ -94,6 +85,12 @@ 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}
|
||||
@@ -108,10 +105,6 @@ 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>
|
||||
|
||||
@@ -128,25 +121,6 @@ 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
|
||||
@@ -176,10 +150,6 @@ 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!);
|
||||
@@ -198,11 +168,7 @@ export class DialogEnergyBatterySettings
|
||||
--mdc-dialog-max-width: 430px;
|
||||
}
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -21,7 +21,6 @@ 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
|
||||
@@ -36,14 +35,10 @@ 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(
|
||||
@@ -55,15 +50,9 @@ 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() {
|
||||
@@ -104,6 +93,8 @@ export class DialogEnergyDeviceSettings
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@@ -117,6 +108,12 @@ 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}
|
||||
@@ -128,28 +125,9 @@ 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"
|
||||
@@ -232,20 +210,6 @@ 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!,
|
||||
@@ -281,19 +245,15 @@ 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: var(--ha-space-4);
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
ha-textfield {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -104,6 +104,8 @@ export class DialogEnergyGridFlowSettings
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
||||
|
||||
const unitPriceFixed = `${this.hass.config.currency}/kWh`;
|
||||
|
||||
const externalSource =
|
||||
@@ -133,11 +135,19 @@ export class DialogEnergyGridFlowSettings
|
||||
@closed=${this.closeDialog}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.paragraph`
|
||||
)}
|
||||
</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>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -153,10 +163,6 @@ 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>
|
||||
|
||||
@@ -355,10 +361,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
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,7 +28,6 @@ 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
|
||||
@@ -47,14 +46,10 @@ 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> {
|
||||
@@ -67,15 +62,9 @@ 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() {
|
||||
@@ -92,6 +81,8 @@ export class DialogEnergySolarSettings
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._energy_units?.join(", ") || "";
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@@ -103,6 +94,12 @@ 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}
|
||||
@@ -114,28 +111,9 @@ 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"
|
||||
@@ -289,10 +267,6 @@ 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) {
|
||||
@@ -313,10 +287,6 @@ 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,7 +7,6 @@ import type {
|
||||
FlowFromGridSourceEnergyPreference,
|
||||
FlowToGridSourceEnergyPreference,
|
||||
GasSourceTypeEnergyPreference,
|
||||
GridPowerSourceEnergyPreference,
|
||||
GridSourceTypeEnergyPreference,
|
||||
SolarSourceTypeEnergyPreference,
|
||||
WaterSourceTypeEnergyPreference,
|
||||
@@ -42,12 +41,6 @@ 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;
|
||||
@@ -159,14 +152,3 @@ 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,10 +426,6 @@ class DialogZHAReconfigureDevice extends LitElement {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 800px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 1fr 2fr;
|
||||
|
||||
@@ -360,20 +360,6 @@ 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) =>
|
||||
@@ -484,18 +470,13 @@ export class HaConfigLovelaceDashboards extends LitElement {
|
||||
}
|
||||
|
||||
private _canDelete(urlPath: string) {
|
||||
return ![
|
||||
"lovelace",
|
||||
"energy",
|
||||
"light",
|
||||
"security",
|
||||
"climate",
|
||||
"home",
|
||||
].includes(urlPath);
|
||||
return !["lovelace", "energy", "light", "security", "climate"].includes(
|
||||
urlPath
|
||||
);
|
||||
}
|
||||
|
||||
private _canEdit(urlPath: string) {
|
||||
return !["light", "security", "climate", "home"].includes(urlPath);
|
||||
return !["light", "security", "climate"].includes(urlPath);
|
||||
}
|
||||
|
||||
private _handleDelete = async (item: DataTableItem) => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, state, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-wa-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-md-dialog";
|
||||
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
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";
|
||||
@@ -41,7 +43,7 @@ export class DialogLovelaceResourceDetail extends LitElement {
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state() private _open = false;
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
|
||||
public showDialog(params: LovelaceResourceDetailsDialogParams): void {
|
||||
this._params = params;
|
||||
@@ -56,11 +58,6 @@ export class DialogLovelaceResourceDetail extends LitElement {
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
@@ -68,6 +65,10 @@ 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;
|
||||
@@ -80,45 +81,56 @@ 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-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
prevent-scrim-close
|
||||
header-title=${dialogTitle}
|
||||
<ha-md-dialog
|
||||
open
|
||||
disable-cancel-action
|
||||
@closed=${this._dialogClosed}
|
||||
.ariaLabel=${ariaLabel}
|
||||
>
|
||||
<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
|
||||
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"
|
||||
<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-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}>
|
||||
${this.hass!.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._updateResource}
|
||||
.disabled=${urlInvalid || !this._data?.res_type || this._submitting}
|
||||
>
|
||||
@@ -130,8 +142,8 @@ export class DialogLovelaceResourceDetail extends LitElement {
|
||||
"ui.panel.config.lovelace.resources.detail.create"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,15 +65,13 @@ 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}
|
||||
.leftChevron=${hasSelector}
|
||||
left-chevron
|
||||
@toggle-collapsed=${this._toggleCollapse}
|
||||
.collapsed=${this._collapsed}
|
||||
.highlight=${this.highlight}
|
||||
@@ -142,124 +140,117 @@ export default class HaScriptFieldRow extends LitElement {
|
||||
<slot name="icons" slot="icons"></slot>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
${hasSelector
|
||||
? html`
|
||||
<div
|
||||
class=${classMap({
|
||||
"selector-row": true,
|
||||
"parent-selected": this._selected,
|
||||
hidden: this._collapsed,
|
||||
})}
|
||||
<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"
|
||||
>
|
||||
<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]
|
||||
<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"}`
|
||||
)}
|
||||
.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
|
||||
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
|
||||
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}
|
||||
>${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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
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,7 +5,10 @@ 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 } from "../../../mixins/conditional-listener-mixin";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} 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";
|
||||
@@ -19,9 +22,7 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-badge")
|
||||
export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
ReactiveElement
|
||||
) {
|
||||
export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@property({ attribute: false }) public config?: LovelaceBadgeConfig;
|
||||
@@ -52,7 +53,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
this._updateVisibility();
|
||||
}
|
||||
|
||||
protected _updateElement(config: LovelaceBadgeConfig) {
|
||||
private _updateElement(config: LovelaceBadgeConfig) {
|
||||
if (!this._element) {
|
||||
return;
|
||||
}
|
||||
@@ -132,7 +133,22 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
}
|
||||
}
|
||||
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
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) {
|
||||
if (!this._element || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@@ -153,9 +169,9 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass));
|
||||
ignoreConditions ||
|
||||
!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass);
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,8 @@ 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 {
|
||||
@@ -172,10 +170,11 @@ function formatTooltip(
|
||||
compare
|
||||
? `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}: `
|
||||
: ""
|
||||
}${formatTime(date, locale, config)}`;
|
||||
if (params[0].componentSubType === "bar") {
|
||||
period += ` – ${formatTime(addHours(date, 1), locale, config)}`;
|
||||
}
|
||||
}${formatTime(date, locale, config)} – ${formatTime(
|
||||
addHours(date, 1),
|
||||
locale,
|
||||
config
|
||||
)}`;
|
||||
}
|
||||
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
|
||||
|
||||
@@ -203,7 +202,7 @@ function formatTooltip(
|
||||
countNegative++;
|
||||
}
|
||||
}
|
||||
return `${param.marker} ${filterXSS(param.seriesName!)}: <div style="direction:ltr; display: inline;">${value} ${unit}</div>`;
|
||||
return `${param.marker} ${filterXSS(param.seriesName!)}: ${value} ${unit}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
let footer = "";
|
||||
@@ -282,35 +281,6 @@ 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";
|
||||
private _chartType: "bar" | "pie" = "bar";
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
@@ -101,14 +101,6 @@ 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) ||
|
||||
@@ -117,21 +109,8 @@ 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 || !this._chartType) {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -139,19 +118,13 @@ export class HuiEnergyDevicesGraphCard
|
||||
<ha-card>
|
||||
<div class="card-header">
|
||||
<span>${this._config.title ? this._config.title : nothing}</span>
|
||||
${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}
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
class="content ${classMap({
|
||||
@@ -185,7 +158,7 @@ export class HuiEnergyDevicesGraphCard
|
||||
this.hass.locale,
|
||||
params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined
|
||||
)} kWh`;
|
||||
return `${title}${params.marker} ${params.seriesName}: <div style="direction:ltr; display: inline;">${value}</div>`;
|
||||
return `${title}${params.marker} ${params.seriesName}: ${value}`;
|
||||
}
|
||||
|
||||
private _createOptions = memoizeOne(
|
||||
@@ -556,13 +529,7 @@ export class HuiEnergyDevicesGraphCard
|
||||
}
|
||||
|
||||
private _handleChartTypeChange(): void {
|
||||
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._chartType = this._chartType === "pie" ? "bar" : "pie";
|
||||
this._getStatistics(this._data!);
|
||||
}
|
||||
|
||||
|
||||
@@ -402,9 +402,7 @@ class HuiEnergySankeyCard
|
||||
}
|
||||
|
||||
private _valueFormatter = (value: number) =>
|
||||
`<div style="direction:ltr; display: inline;">
|
||||
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
|
||||
kWh</div>`;
|
||||
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`;
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
@@ -7,8 +8,16 @@ import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_elemen
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import "../../../components/ha-card";
|
||||
import type { Calendar, CalendarEvent } from "../../../data/calendar";
|
||||
import { fetchCalendarEvents } from "../../../data/calendar";
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
CalendarEventSubscription,
|
||||
CalendarEventApiData,
|
||||
} from "../../../data/calendar";
|
||||
import {
|
||||
normalizeSubscriptionEventData,
|
||||
subscribeCalendarEvents,
|
||||
} from "../../../data/calendar";
|
||||
import type {
|
||||
CalendarViewChanged,
|
||||
FullCalendarView,
|
||||
@@ -65,12 +74,16 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@state() private _error?: string = undefined;
|
||||
|
||||
@state() private _errorCalendars: string[] = [];
|
||||
|
||||
private _startDate?: Date;
|
||||
|
||||
private _endDate?: Date;
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
|
||||
|
||||
public setConfig(config: CalendarCardConfig): void {
|
||||
if (!config.entities?.length) {
|
||||
throw new Error("Entities must be specified");
|
||||
@@ -86,7 +99,8 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
|
||||
}));
|
||||
|
||||
if (this._config?.entities !== config.entities) {
|
||||
this._fetchCalendarEvents();
|
||||
this._unsubscribeAll();
|
||||
// Subscription will happen when view-changed event fires
|
||||
}
|
||||
|
||||
this._config = { initial_view: "dayGridMonth", ...config };
|
||||
@@ -115,6 +129,7 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
}
|
||||
this._unsubscribeAll();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -170,31 +185,85 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleViewChanged(ev: HASSDomEvent<CalendarViewChanged>): void {
|
||||
private async _handleViewChanged(
|
||||
ev: HASSDomEvent<CalendarViewChanged>
|
||||
): Promise<void> {
|
||||
this._startDate = ev.detail.start;
|
||||
this._endDate = ev.detail.end;
|
||||
this._fetchCalendarEvents();
|
||||
await this._unsubscribeAll();
|
||||
this._subscribeCalendarEvents();
|
||||
}
|
||||
|
||||
private async _fetchCalendarEvents(): Promise<void> {
|
||||
if (!this._startDate || !this._endDate) {
|
||||
private _subscribeCalendarEvents(): void {
|
||||
if (!this.hass || !this._startDate || !this._endDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._error = undefined;
|
||||
const result = await fetchCalendarEvents(
|
||||
this.hass!,
|
||||
this._startDate,
|
||||
this._endDate,
|
||||
this._calendars
|
||||
);
|
||||
this._events = result.events;
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
this._calendars.forEach((calendar) => {
|
||||
const unsub = subscribeCalendarEvents(
|
||||
this.hass!,
|
||||
calendar.entity_id,
|
||||
this._startDate!,
|
||||
this._endDate!,
|
||||
(update: CalendarEventSubscription) => {
|
||||
this._handleCalendarUpdate(calendar, update);
|
||||
}
|
||||
);
|
||||
this._unsubs[calendar.entity_id] = unsub;
|
||||
});
|
||||
}
|
||||
|
||||
private _handleCalendarUpdate(
|
||||
calendar: Calendar,
|
||||
update: CalendarEventSubscription
|
||||
): void {
|
||||
// Remove events from this calendar
|
||||
this._events = this._events.filter(
|
||||
(event) => event.calendar !== calendar.entity_id
|
||||
);
|
||||
|
||||
if (update.events === null) {
|
||||
// Error fetching events
|
||||
if (!this._errorCalendars.includes(calendar.entity_id)) {
|
||||
this._errorCalendars = [...this._errorCalendars, calendar.entity_id];
|
||||
}
|
||||
this._error = `${this.hass!.localize(
|
||||
"ui.components.calendar.event_retrieval_error"
|
||||
)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from error list if successfully loaded
|
||||
this._errorCalendars = this._errorCalendars.filter(
|
||||
(id) => id !== calendar.entity_id
|
||||
);
|
||||
if (this._errorCalendars.length === 0) {
|
||||
this._error = undefined;
|
||||
}
|
||||
|
||||
// Add new events from this calendar
|
||||
const newEvents: CalendarEvent[] = update.events
|
||||
.map((eventData: CalendarEventApiData) =>
|
||||
normalizeSubscriptionEventData(eventData, calendar)
|
||||
)
|
||||
.filter((event): event is CalendarEvent => event !== null);
|
||||
|
||||
this._events = [...this._events, ...newEvents];
|
||||
}
|
||||
|
||||
private async _unsubscribeAll(): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.values(this._unsubs).map((unsub) =>
|
||||
unsub
|
||||
.then((unsubFunc) => unsubFunc())
|
||||
.catch(() => {
|
||||
// Subscription may have already been closed
|
||||
})
|
||||
)
|
||||
);
|
||||
this._unsubs = {};
|
||||
}
|
||||
|
||||
private _measureCard() {
|
||||
|
||||
@@ -5,7 +5,10 @@ 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 } from "../../../mixins/conditional-listener-mixin";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} 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";
|
||||
@@ -21,9 +24,7 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-card")
|
||||
export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||
ReactiveElement
|
||||
) {
|
||||
export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@property({ attribute: false }) public config?: LovelaceCardConfig;
|
||||
@@ -120,7 +121,7 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||
return {};
|
||||
}
|
||||
|
||||
protected _updateElement(config: LovelaceCardConfig) {
|
||||
private _updateElement(config: LovelaceCardConfig) {
|
||||
if (!this._element) {
|
||||
return;
|
||||
}
|
||||
@@ -246,7 +247,22 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||
}
|
||||
}
|
||||
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
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) {
|
||||
if (!this._element || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@@ -267,9 +283,9 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass));
|
||||
ignoreConditions ||
|
||||
!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass);
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,6 @@ export interface EnergyDevicesGraphCardConfig extends EnergyCardBaseConfig {
|
||||
title?: string;
|
||||
max_devices?: number;
|
||||
hide_compound_stats?: boolean;
|
||||
modes?: ("bar" | "pie")[];
|
||||
}
|
||||
|
||||
export interface EnergyDevicesDetailGraphCardConfig
|
||||
@@ -231,11 +230,6 @@ 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,7 +1,6 @@
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiAmpersand,
|
||||
mdiCalendarClock,
|
||||
mdiGateOr,
|
||||
mdiMapMarker,
|
||||
mdiNotEqualVariant,
|
||||
@@ -16,7 +15,6 @@ export const ICON_CONDITION: Record<Condition["condition"], string> = {
|
||||
numeric_state: mdiNumeric,
|
||||
state: mdiStateMachine,
|
||||
screen: mdiResponsive,
|
||||
time: mdiCalendarClock,
|
||||
user: mdiAccount,
|
||||
and: mdiAmpersand,
|
||||
not: mdiNotEqualVariant,
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
import { UNKNOWN } from "../../../data/entity";
|
||||
import { getUserPerson } from "../../../data/person";
|
||||
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";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
export type Condition =
|
||||
| LocationCondition
|
||||
| NumericStateCondition
|
||||
| StateCondition
|
||||
| ScreenCondition
|
||||
| TimeCondition
|
||||
| UserCondition
|
||||
| OrCondition
|
||||
| AndCondition
|
||||
@@ -58,13 +50,6 @@ 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[];
|
||||
@@ -165,13 +150,6 @@ function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) {
|
||||
: false;
|
||||
}
|
||||
|
||||
function checkTimeCondition(
|
||||
condition: Omit<TimeCondition, "condition">,
|
||||
hass: HomeAssistant
|
||||
) {
|
||||
return checkTimeInRange(hass, condition);
|
||||
}
|
||||
|
||||
function checkLocationCondition(
|
||||
condition: LocationCondition,
|
||||
hass: HomeAssistant
|
||||
@@ -217,8 +195,6 @@ 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":
|
||||
@@ -295,35 +271,6 @@ 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;
|
||||
}
|
||||
@@ -363,8 +310,6 @@ export function validateConditionalConfig(
|
||||
switch (c.condition) {
|
||||
case "screen":
|
||||
return validateScreenCondition(c);
|
||||
case "time":
|
||||
return validateTimeCondition(c);
|
||||
case "user":
|
||||
return validateUserCondition(c);
|
||||
case "location":
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} 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";
|
||||
@@ -19,9 +22,9 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-conditional-base")
|
||||
export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
ConditionalCardConfig | ConditionalRowConfig
|
||||
>(ReactiveElement) {
|
||||
export class HuiConditionalBase extends ConditionalListenerMixin(
|
||||
ReactiveElement
|
||||
) {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
@@ -70,13 +73,18 @@ 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[];
|
||||
|
||||
// Pass filtered conditions to parent implementation
|
||||
super.setupConditionalListeners(supportedConditions);
|
||||
setupMediaQueryListeners(
|
||||
supportedConditions,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
(conditionsMet) => {
|
||||
this.setVisibility(conditionsMet);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected update(changed: PropertyValues): void {
|
||||
@@ -94,15 +102,17 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
}
|
||||
}
|
||||
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
private _updateVisibility() {
|
||||
if (!this._element || !this.hass || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._element.preview = this.preview;
|
||||
|
||||
const conditionMet =
|
||||
conditionsMet ?? checkConditionsMet(this._config.conditions, this.hass);
|
||||
const conditionMet = checkConditionsMet(
|
||||
this._config!.conditions,
|
||||
this.hass!
|
||||
);
|
||||
|
||||
this.setVisibility(conditionMet);
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@ 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,7 +25,6 @@ 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";
|
||||
|
||||
@@ -34,7 +33,6 @@ const UI_CONDITION = [
|
||||
"numeric_state",
|
||||
"state",
|
||||
"screen",
|
||||
"time",
|
||||
"user",
|
||||
"and",
|
||||
"not",
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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,7 +22,6 @@ 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,7 +4,10 @@ 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 } from "../../../mixins/conditional-listener-mixin";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} 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";
|
||||
@@ -18,9 +21,7 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-heading-badge")
|
||||
export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBadgeConfig>(
|
||||
ReactiveElement
|
||||
) {
|
||||
export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) {
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@property({ attribute: false }) public config?: LovelaceHeadingBadgeConfig;
|
||||
@@ -51,7 +52,7 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
|
||||
this._updateVisibility();
|
||||
}
|
||||
|
||||
protected _updateElement(config: LovelaceHeadingBadgeConfig) {
|
||||
private _updateElement(config: LovelaceHeadingBadgeConfig) {
|
||||
if (!this._element) {
|
||||
return;
|
||||
}
|
||||
@@ -132,7 +133,22 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
|
||||
}
|
||||
}
|
||||
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
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) {
|
||||
if (!this._element || !this.hass) {
|
||||
return;
|
||||
}
|
||||
@@ -142,20 +158,11 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.preview) {
|
||||
this._setElementVisibility(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config?.disabled) {
|
||||
this._setElementVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass));
|
||||
forceVisible ||
|
||||
this.preview ||
|
||||
!this.config?.visibility ||
|
||||
checkConditionsMet(this.config.visibility, this.hass);
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
} from "../../../data/lovelace/config/section";
|
||||
import { isStrategySection } from "../../../data/lovelace/config/section";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import {
|
||||
ConditionalListenerMixin,
|
||||
setupMediaQueryListeners,
|
||||
} from "../../../mixins/conditional-listener-mixin";
|
||||
import "../cards/hui-card";
|
||||
import type { HuiCard } from "../cards/hui-card";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
@@ -34,9 +37,7 @@ declare global {
|
||||
}
|
||||
|
||||
@customElement("hui-section")
|
||||
export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
ReactiveElement
|
||||
) {
|
||||
export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public config!: LovelaceSectionRawConfig;
|
||||
@@ -58,6 +59,8 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
|
||||
private _layoutElement?: LovelaceSectionElement;
|
||||
|
||||
private _config: LovelaceSectionConfig | undefined;
|
||||
|
||||
@storage({
|
||||
key: "dashboardCardClipboard",
|
||||
state: false,
|
||||
@@ -113,7 +116,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._updateVisibility();
|
||||
this._updateElement();
|
||||
}
|
||||
|
||||
protected update(changedProperties) {
|
||||
@@ -144,11 +147,26 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
this._layoutElement.cards = this._cards;
|
||||
}
|
||||
if (changedProperties.has("hass") || changedProperties.has("preview")) {
|
||||
this._updateVisibility();
|
||||
this._updateElement();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -190,11 +208,11 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
while (this.lastChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
this._updateVisibility();
|
||||
this._updateElement();
|
||||
}
|
||||
}
|
||||
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
private _updateElement(ignoreConditions?: boolean) {
|
||||
if (!this._layoutElement || !this._config) {
|
||||
return;
|
||||
}
|
||||
@@ -210,9 +228,9 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this._config.visibility ||
|
||||
checkConditionsMet(this._config.visibility, this.hass));
|
||||
ignoreConditions ||
|
||||
!this._config.visibility ||
|
||||
checkConditionsMet(this._config.visibility, this.hass);
|
||||
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
@@ -168,27 +168,6 @@ 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",
|
||||
@@ -199,9 +178,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
// Rest of entities grouped by device
|
||||
const otherEntities = areaEntities.filter(
|
||||
(entityId) =>
|
||||
!summaryEntities.includes(entityId) &&
|
||||
!scenes.includes(entityId) &&
|
||||
!automations.includes(entityId)
|
||||
!summaryEntities.includes(entityId) && !automations.includes(entityId)
|
||||
);
|
||||
|
||||
const entitiesByDevice: Record<string, string[]> = {};
|
||||
|
||||
@@ -109,7 +109,6 @@ 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 (
|
||||
@@ -117,18 +116,16 @@ export class HUIViewBackground extends LitElement {
|
||||
this.hass.themes !== oldHass.themes ||
|
||||
this.hass.selectedTheme !== oldHass.selectedTheme
|
||||
) {
|
||||
applyTheme = true;
|
||||
this._applyTheme();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has("background")) {
|
||||
applyTheme = true;
|
||||
this._applyTheme();
|
||||
this._fetchMedia();
|
||||
}
|
||||
if (changedProperties.has("resolvedImage")) {
|
||||
applyTheme = true;
|
||||
}
|
||||
if (applyTheme) {
|
||||
this._applyTheme();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,12 @@ 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";
|
||||
@@ -148,18 +146,19 @@ class HaRefreshTokens extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<ha-dropdown @wa-select=${this._handleDropdownSelect}>
|
||||
<ha-md-button-menu positioning="popover">
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item
|
||||
<ha-md-menu-item
|
||||
graphic="icon"
|
||||
@click=${this._toggleTokenExpiration}
|
||||
.token=${token}
|
||||
.action=${"toggle_expiration"}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
slot="start"
|
||||
.path=${token.expire_at
|
||||
? mdiClockRemoveOutline
|
||||
: mdiClockCheckOutline}
|
||||
@@ -171,20 +170,24 @@ class HaRefreshTokens extends LitElement {
|
||||
: this.hass.localize(
|
||||
"ui.panel.profile.refresh_tokens.enable_token_expiration"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item
|
||||
.token=${token}
|
||||
.action=${"delete_token"}
|
||||
variant="danger"
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item
|
||||
graphic="icon"
|
||||
class="warning"
|
||||
.disabled=${token.is_current}
|
||||
@click=${this._deleteToken}
|
||||
.token=${token}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
class="warning"
|
||||
slot="start"
|
||||
.path=${mdiDelete}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize("ui.common.delete")}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.common.delete")}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
</ha-md-button-menu>
|
||||
</div>
|
||||
</ha-settings-row>
|
||||
`
|
||||
@@ -207,17 +210,8 @@ class HaRefreshTokens extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
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> {
|
||||
private async _toggleTokenExpiration(ev): Promise<void> {
|
||||
const token = (ev.currentTarget as any).token as RefreshToken;
|
||||
const enable = !token.expire_at;
|
||||
if (!enable) {
|
||||
if (
|
||||
@@ -258,7 +252,8 @@ class HaRefreshTokens extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteToken(token: RefreshToken): Promise<void> {
|
||||
private async _deleteToken(ev): Promise<void> {
|
||||
const token = (ev.currentTarget as any).token as RefreshToken;
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -26,7 +26,6 @@ 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 {
|
||||
@@ -42,8 +41,6 @@ class DialogTodoItemEditor extends LitElement {
|
||||
|
||||
@state() private _due?: Date;
|
||||
|
||||
@state() private _completedTime?: Date;
|
||||
|
||||
@state() private _checked = false;
|
||||
|
||||
@state() private _hasTime = false;
|
||||
@@ -68,9 +65,6 @@ 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`)
|
||||
@@ -144,17 +138,6 @@ 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
|
||||
)
|
||||
@@ -472,9 +455,6 @@ class DialogTodoItemEditor extends LitElement {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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,10 +74,6 @@ 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,7 +155,6 @@ export const semanticColorStyles = css`
|
||||
|
||||
/* Surfaces */
|
||||
--ha-color-surface-default: var(--ha-color-neutral-95);
|
||||
--ha-color-on-surface-default: var(--ha-color-neutral-05);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -287,6 +286,5 @@ 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,11 +52,7 @@ 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-quiet: var(--ha-color-text-secondary);
|
||||
|
||||
--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;
|
||||
@@ -64,7 +60,5 @@ export const waColorStyles = css`
|
||||
|
||||
--wa-focus-ring-color: var(--ha-color-neutral-60);
|
||||
--wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3);
|
||||
|
||||
--wa-color-text-normal: var(--ha-color-text-primary);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -42,27 +42,6 @@ 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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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,9 +1,7 @@
|
||||
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,
|
||||
@@ -14,11 +12,9 @@ export const themeStyles = [
|
||||
coreStyles.toString(),
|
||||
mainStyles.toString(),
|
||||
typographyStyles.toString(),
|
||||
semanticStyles.toString(),
|
||||
...colorStylesCollection,
|
||||
fontStyles.toString(),
|
||||
waMainStyles.toString(),
|
||||
animationStyles.toString(),
|
||||
].join("");
|
||||
|
||||
export const derivedStyles = {
|
||||
|
||||
@@ -9,33 +9,15 @@ 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-xs: var(--ha-space-2);
|
||||
--wa-space-m: var(--ha-space-4);
|
||||
--wa-space-l: var(--ha-space-6);
|
||||
--wa-space-xl: var(--ha-space-8);
|
||||
|
||||
--wa-space-l: 24px;
|
||||
--wa-shadow-l: 0 8px 8px -4px rgba(0, 0, 0, 0.2);
|
||||
--wa-form-control-padding-block: 0.75em;
|
||||
--wa-form-control-value-line-height: var(--ha-line-height-condensed);
|
||||
|
||||
--wa-font-weight-action: var(--ha-font-weight-medium);
|
||||
--wa-transition-normal: 150ms;
|
||||
--wa-transition-fast: 75ms;
|
||||
--wa-transition-easing: ease;
|
||||
|
||||
--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-font-size-m: var(--ha-font-size-m);
|
||||
--wa-shadow-s: var(--ha-box-shadow-s);
|
||||
--wa-shadow-m: var(--ha-box-shadow-m);
|
||||
--wa-shadow-l: var(--ha-box-shadow-l);
|
||||
--wa-border-width-l: var(--ha-border-radius-lg);
|
||||
--wa-space-xl: 32px;
|
||||
}
|
||||
|
||||
${scrollLockStyles}
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
"profile": "Profile",
|
||||
"light": "Lights",
|
||||
"security": "Security",
|
||||
"climate": "Climate",
|
||||
"home": "Home"
|
||||
"climate": "Climate"
|
||||
},
|
||||
"state": {
|
||||
"default": {
|
||||
@@ -1130,7 +1129,6 @@
|
||||
"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",
|
||||
@@ -3078,15 +3076,6 @@
|
||||
"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": {
|
||||
@@ -3134,7 +3123,6 @@
|
||||
"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",
|
||||
@@ -3152,12 +3140,9 @@
|
||||
"add_battery_system": "Add battery system",
|
||||
"dialog": {
|
||||
"header": "Configure battery system",
|
||||
"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."
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"gas": {
|
||||
@@ -3219,8 +3204,7 @@
|
||||
"header": "Add a device",
|
||||
"display_name": "Display name",
|
||||
"device_consumption_energy": "Device energy consumption",
|
||||
"device_consumption_power": "Device power consumption",
|
||||
"selected_stat_intro": "Select the sensor that measures the device's electricity usage in either of {unit}.",
|
||||
"selected_stat_intro": "Select the energy sensor that measures the device's energy 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"
|
||||
@@ -3990,14 +3974,7 @@
|
||||
"item_pasted": "{item} pasted",
|
||||
"ctrl": "Ctrl",
|
||||
"del": "Del",
|
||||
"targets": "Targets",
|
||||
"select_target": "Select a target",
|
||||
"home": "Home",
|
||||
"unassigned": "Unassigned",
|
||||
"blocks": "Blocks",
|
||||
"show_more": "Show more",
|
||||
"unassigned_entities": "Unassigned entities",
|
||||
"unassigned_devices": "Unassigned devices",
|
||||
"triggers": {
|
||||
"name": "Triggers",
|
||||
"header": "When",
|
||||
@@ -7006,7 +6983,6 @@
|
||||
"other_areas": "Other areas",
|
||||
"unamed_device": "Unnamed device",
|
||||
"others": "Others",
|
||||
"scenes": "Scenes",
|
||||
"automations": "Automations"
|
||||
},
|
||||
"common_controls": {
|
||||
@@ -7169,12 +7145,6 @@
|
||||
"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",
|
||||
@@ -7592,12 +7562,6 @@
|
||||
"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,30 +1,11 @@
|
||||
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?.parentElement) {
|
||||
return;
|
||||
if (launchScreenElement) {
|
||||
launchScreenElement.parentElement!.removeChild(launchScreenElement);
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,519 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,320 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,307 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user