Compare commits

..

19 Commits

Author SHA1 Message Date
Aidan Timson
5d325b5627 Remove seconds 2025-11-05 12:32:14 +00:00
Aidan Timson
ef0368f9c3 Update src/common/datetime/check_time.ts
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-05 07:57:47 +00:00
Aidan Timson
8fc305ec6b Add tests for DST transitions 2025-11-04 11:32:45 +00:00
Aidan Timson
fd657fbf68 Add missing import 2025-11-04 11:32:45 +00:00
Aidan Timson
885f4564a9 Docstring 2025-11-04 11:32:45 +00:00
Aidan Timson
d27c2e3a04 Cleanup 2025-11-04 11:32:45 +00:00
Aidan Timson
f2655ec226 Use satisfies for typing 2025-11-04 11:32:45 +00:00
Aidan Timson
1c3043ed37 Validate time range 2025-11-04 11:32:45 +00:00
Aidan Timson
86bfb6a482 Remove mutability 2025-11-04 11:32:45 +00:00
Aidan Timson
bd3a1ea281 Add tests 2025-11-04 11:32:45 +00:00
Aidan Timson
90757b6f97 Check format 2025-11-04 11:32:45 +00:00
Aidan Timson
6a2bbb02a2 Add default 2025-11-04 11:32:45 +00:00
Aidan Timson
698197dccd Improve validation 2025-11-04 11:32:45 +00:00
Aidan Timson
665ff88d58 Add translations 2025-11-04 11:32:45 +00:00
Aidan Timson
bb8dd69e7b Setup 2025-11-04 11:32:45 +00:00
Aidan Timson
819182977c Add condition function 2025-11-04 11:32:45 +00:00
Aidan Timson
2d838e22d2 Make weekday types 2025-11-04 11:32:45 +00:00
Aidan Timson
036cbe0a1a Rename to time 2025-11-04 11:32:45 +00:00
Aidan Timson
6fc039340e Skeleton 2025-11-04 11:32:45 +00:00
46 changed files with 1079 additions and 1193 deletions

View File

@@ -18,16 +18,16 @@ module.exports.sourceMapURL = () => {
module.exports.ignorePackages = () => [];
// Files from NPM packages that we should replace with empty file
module.exports.emptyPackages = ({ isHassioBuild, isLandingPageBuild }) =>
module.exports.emptyPackages = ({ isHassioBuild }) =>
[
require.resolve("@vaadin/vaadin-material-styles/typography.js"),
require.resolve("@vaadin/vaadin-material-styles/font-icons.js"),
// Icons in supervisor conflict with icons in HA so we don't load.
(isHassioBuild || isLandingPageBuild) &&
isHassioBuild &&
require.resolve(
path.resolve(paths.root_dir, "src/components/ha-icon.ts")
),
(isHassioBuild || isLandingPageBuild) &&
isHassioBuild &&
require.resolve(
path.resolve(paths.root_dir, "src/components/ha-icon-picker.ts")
),
@@ -337,7 +337,6 @@ module.exports.config = {
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isLandingPageBuild: true,
};
},
};

View File

@@ -41,7 +41,6 @@ const createRspackConfig = ({
isStatsBuild,
isTestBuild,
isHassioBuild,
isLandingPageBuild,
dontHash,
}) => {
if (!dontHash) {
@@ -169,9 +168,7 @@ const createRspackConfig = ({
},
}),
new rspack.NormalModuleReplacementPlugin(
new RegExp(
bundle.emptyPackages({ isHassioBuild, isLandingPageBuild }).join("|")
),
new RegExp(bundle.emptyPackages({ isHassioBuild }).join("|")),
path.resolve(paths.root_dir, "src/util/empty.js")
),
!isProdBuild && new LogStartCompilePlugin(),

View File

@@ -1,25 +1,22 @@
import "@material/mwc-linear-progress";
import { mdiOpenInNew } from "@mdi/js";
import { css, html, nothing, type PropertyValues } from "lit";
import { type PropertyValues, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { extractSearchParam } from "../../src/common/url/search-params";
import "../../src/components/ha-alert";
import "../../src/components/ha-button";
import "../../src/components/ha-fade-in";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import "../../src/onboarding/onboarding-welcome-links";
import { onBoardingStyles } from "../../src/onboarding/styles";
import { haStyle } from "../../src/resources/styles";
import "./components/landing-page-logs";
import "../../src/onboarding/onboarding-welcome-links";
import "./components/landing-page-network";
import "./components/landing-page-logs";
import { extractSearchParam } from "../../src/common/url/search-params";
import { onBoardingStyles } from "../../src/onboarding/styles";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import { LandingPageBaseElement } from "./landing-page-base-element";
import {
getSupervisorNetworkInfo,
pingSupervisor,
type NetworkInfo,
} from "./data/supervisor";
import { LandingPageBaseElement } from "./landing-page-base-element";
export const ASSUME_CORE_START_SECONDS = 60;
const SCHEDULE_CORE_CHECK_SECONDS = 1;
@@ -97,21 +94,16 @@ class HaLandingPage extends LandingPageBaseElement {
<ha-language-picker
.value=${this.language}
.label=${""}
button-style
native-name
@value-changed=${this._languageChanged}
inline-arrow
></ha-language-picker>
<ha-button
appearance="plain"
variant="neutral"
<a
href="https://www.home-assistant.io/getting-started/onboarding/"
target="_blank"
rel="noreferrer noopener"
>${this.localize("ui.panel.page-onboarding.help")}</a
>
${this.localize("ui.panel.page-onboarding.help")}
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-button>
</div>
`;
}
@@ -226,8 +218,26 @@ class HaLandingPage extends LandingPageBaseElement {
ha-alert p {
text-align: unset;
}
.footer ha-svg-icon {
--mdc-icon-size: var(--ha-space-5);
ha-language-picker {
display: block;
width: 200px;
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
--ha-select-height: 40px;
--mdc-select-fill-color: none;
--mdc-select-label-ink-color: var(--primary-text-color, #212121);
--mdc-select-ink-color: var(--primary-text-color, #212121);
--mdc-select-idle-line-color: transparent;
--mdc-select-hover-line-color: transparent;
--mdc-select-dropdown-icon-color: var(--primary-text-color, #212121);
--mdc-shape-small: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
}
ha-fade-in {
min-height: calc(100vh - 64px - 88px);

View File

@@ -1,5 +1,4 @@
/* eslint-disable lit/prefer-static-styles */
import { mdiOpenInNew } from "@mdi/js";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -7,8 +6,6 @@ import punycode from "punycode";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
import { extractSearchParamsObject } from "../common/url/search-params";
import "../components/ha-alert";
import "../components/ha-button";
import "../components/ha-svg-icon";
import type { AuthProvider, AuthUrlSearchParams } from "../data/auth";
import { fetchAuthProviders } from "../data/auth";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
@@ -136,8 +133,25 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
justify-content: space-between;
align-items: center;
}
.footer ha-svg-icon {
--mdc-icon-size: var(--ha-space-5);
ha-language-picker {
width: 200px;
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
--ha-select-height: 40px;
--mdc-select-fill-color: none;
--mdc-select-label-ink-color: var(--primary-text-color, #212121);
--mdc-select-ink-color: var(--primary-text-color, #212121);
--mdc-select-idle-line-color: transparent;
--mdc-select-hover-line-color: transparent;
--mdc-select-dropdown-icon-color: var(--primary-text-color, #212121);
--mdc-shape-small: 0;
}
.footer a {
text-decoration: none;
color: var(--primary-text-color);
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
}
h1 {
font-size: var(--ha-font-size-3xl);
@@ -191,21 +205,16 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
<ha-language-picker
.value=${this.language}
.label=${""}
button-style
native-name
@value-changed=${this._languageChanged}
inline-arrow
></ha-language-picker>
<ha-button
appearance="plain"
variant="neutral"
<a
href="https://www.home-assistant.io/docs/authentication/"
target="_blank"
rel="noreferrer noopener"
>${this.localize("ui.panel.page-authorize.help")}</a
>
${this.localize("ui.panel.page-authorize.help")}
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-button>
</div>
`;
}

View File

@@ -0,0 +1,110 @@
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, type WeekdayShort } from "./weekday";
/**
* Parse a time string (HH:MM or HH:MM:SS) and set it on today's date in the given timezone
* @param timeString The time string to parse
* @param timezone The timezone to use
* @returns The Date object
*/
const parseTimeString = (timeString: string, timezone: string): Date => {
const parts = timeString.split(":");
if (parts.length < 2 || parts.length > 3) {
throw new Error(
`Invalid time format: ${timeString}. Expected HH:MM or HH:MM:SS`
);
}
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)) {
throw new Error(`Invalid time values in: ${timeString}`);
}
// Add range validation
if (hours < 0 || hours > 23) {
throw new Error(`Invalid hours in: ${timeString}. Must be 0-23`);
}
if (minutes < 0 || minutes > 59) {
throw new Error(`Invalid minutes in: ${timeString}. Must be 0-59`);
}
if (seconds < 0 || seconds > 59) {
throw new Error(`Invalid seconds in: ${timeString}. Must be 0-59`);
}
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 after Optional time string (HH:MM) for after condition
* @param before Optional time string (HH:MM) for before condition
* @param weekdays Optional array of weekdays to match
* @returns true if current time matches the condition
*/
export const checkTimeInRange = (
hass: HomeAssistant,
after?: string,
before?: string,
weekdays?: WeekdayShort[]
): 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 isAfter(now, afterDate) || isBefore(now, beforeDate);
}
return isWithinInterval(now, { start: afterDate, end: beforeDate });
}
if (afterDate) {
return !isBefore(now, afterDate);
}
if (beforeDate) {
return !isAfter(now, beforeDate);
}
return true;
};

View File

@@ -1,18 +1,7 @@
import { getWeekStartByLocale } from "weekstart";
import type { FrontendLocaleData } from "../../data/translation";
import { FirstWeekday } from "../../data/translation";
export const weekdays = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const;
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
if (locale.first_weekday === FirstWeekday.language) {
@@ -23,12 +12,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
}
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
}
return weekdays.includes(locale.first_weekday)
? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex)
return WEEKDAYS_LONG.includes(locale.first_weekday)
? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex)
: 1;
};
export const firstWeekday = (locale: FrontendLocaleData) => {
const index = firstWeekdayIndex(locale);
return weekdays[index];
return WEEKDAYS_LONG[index];
};

View File

@@ -0,0 +1,59 @@
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export type WeekdayShort =
| "sun"
| "mon"
| "tue"
| "wed"
| "thu"
| "fri"
| "sat";
export type WeekdayLong =
| "sunday"
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday";
export const WEEKDAYS_SHORT = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
] as const satisfies readonly WeekdayShort[];
export const WEEKDAYS_LONG = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const satisfies readonly WeekdayLong[];
export const WEEKDAY_MAP = {
0: "sun",
1: "mon",
2: "tue",
3: "wed",
4: "thu",
5: "fri",
6: "sat",
} as const satisfies Record<WeekdayIndex, WeekdayShort>;
export const WEEKDAY_SHORT_TO_LONG = {
sun: "sunday",
mon: "monday",
tue: "tuesday",
wed: "wednesday",
thu: "thursday",
fri: "friday",
sat: "saturday",
} as const satisfies Record<WeekdayShort, WeekdayLong>;

View File

@@ -10,6 +10,7 @@ import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-picker-combo-box";
import type {

View File

@@ -1,7 +1,6 @@
import { mdiMenuDown } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { formatLanguageCode } from "../common/language/format_language";
@@ -9,10 +8,10 @@ import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { FrontendLocaleData } from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-button";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-list-item";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-select";
export const getLanguageOptions = (
languages: string[],
@@ -76,9 +75,6 @@ export class HaLanguagePicker extends LitElement {
@property({ attribute: "native-name", type: Boolean })
public nativeName = false;
@property({ type: Boolean, attribute: "button-style" })
public buttonStyle = false;
@property({ attribute: "no-sort", type: Boolean }) public noSort = false;
@property({ attribute: "inline-arrow", type: Boolean })
@@ -86,8 +82,6 @@ export class HaLanguagePicker extends LitElement {
@state() _defaultLanguages: string[] = [];
@query("ha-generic-picker", true) public genericPicker!: HaGenericPicker;
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._computeDefaultLanguageOptions();
@@ -107,13 +101,12 @@ export class HaLanguagePicker extends LitElement {
this.hass?.locale
);
private _getLanguageName = (lang?: string) =>
this._getItems().find((language) => language.id === lang)?.primary;
private _valueRenderer = (value) =>
html`<span slot="headline"
>${this._getLanguageName(value) ?? value}</span
> `;
private _valueRenderer = (value) => {
const language = this._getItems().find(
(lang) => lang.id === value
)?.primary;
return html`<span slot="headline">${language ?? value}</span> `;
};
protected render() {
const value =
@@ -137,28 +130,10 @@ export class HaLanguagePicker extends LitElement {
.getItems=${this._getItems}
@value-changed=${this._changed}
hide-clear-icon
>
${this.buttonStyle
? html`<ha-button
slot="field"
.disabled=${this.disabled}
@click=${this._openPicker}
appearance="plain"
variant="neutral"
>
${this._getLanguageName(value)}
<ha-svg-icon slot="end" .path=${mdiMenuDown}></ha-svg-icon>
</ha-button>`
: nothing}
</ha-generic-picker>
></ha-generic-picker>
`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this.genericPicker.open();
}
static styles = css`
ha-generic-picker {
width: 100%;

View File

@@ -87,208 +87,166 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
protected render() {
if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `;
return html` ${this._renderChips()} ${this._renderItems()} `;
}
return html` ${this._renderItems()} ${this._renderPicker()} `;
return html` ${this._renderItems()} ${this._renderChips()} `;
}
private _renderValueChips() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
: [];
const deviceIds = this.value?.device_id
? ensureArray(this.value.device_id)
: [];
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
const floorIds = this.value?.floor_id
? ensureArray(this.value.floor_id)
: [];
const labelIds = this.value?.label_id
? ensureArray(this.value.label_id)
: [];
if (
!entityIds.length &&
!deviceIds.length &&
!areaIds.length &&
!floorIds.length &&
!labelIds.length
) {
return nothing;
}
return html`
<div class="mdc-chip-set items">
${floorIds.length
? floorIds.map(
(floor_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="floor"
.itemId=${floor_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${areaIds.length
? areaIds.map(
(area_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="area"
.itemId=${area_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${deviceIds.length
? deviceIds.map(
(device_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="device"
.itemId=${device_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${entityIds.length
? entityIds.map(
(entity_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="entity"
.itemId=${entity_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${labelIds.length
? labelIds.map(
(label_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="label"
.itemId=${label_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
</div>
`;
return html`<div class="mdc-chip-set items">
${this.value?.floor_id
? ensureArray(this.value.floor_id).map(
(floor_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="floor"
.itemId=${floor_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${this.value?.area_id
? ensureArray(this.value.area_id).map(
(area_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="area"
.itemId=${area_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${this.value?.device_id
? ensureArray(this.value.device_id).map(
(device_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="device"
.itemId=${device_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${this.value?.entity_id
? ensureArray(this.value.entity_id).map(
(entity_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="entity"
.itemId=${entity_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${this.value?.label_id
? ensureArray(this.value.label_id).map(
(label_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="label"
.itemId=${label_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
</div>`;
}
private _renderValueGroups() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
: [];
const deviceIds = this.value?.device_id
? ensureArray(this.value.device_id)
: [];
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
const floorIds = this.value?.floor_id
? ensureArray(this.value.floor_id)
: [];
const labelIds = this.value?.label_id
? ensureArray(this.value?.label_id)
: [];
return html`<div class="item-groups">
${this.value?.entity_id
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="entity"
.hass=${this.hass}
.items=${{ entity: ensureArray(this.value?.entity_id) }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${this.value?.device_id
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="device"
.hass=${this.hass}
.items=${{ device: ensureArray(this.value?.device_id) }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${this.value?.floor_id || this.value?.area_id
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="area"
.hass=${this.hass}
.items=${{
floor: ensureArray(this.value?.floor_id),
area: ensureArray(this.value?.area_id),
}}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${this.value?.label_id
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="label"
.hass=${this.hass}
.items=${{ label: ensureArray(this.value?.label_id) }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
</div>`;
}
private _renderItems() {
if (
!entityIds.length &&
!deviceIds.length &&
!areaIds.length &&
!floorIds.length &&
!labelIds.length
!this.value?.floor_id &&
!this.value?.area_id &&
!this.value?.device_id &&
!this.value?.entity_id &&
!this.value?.label_id
) {
return nothing;
}
return html`
<div class="item-groups">
${entityIds.length
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="entity"
.hass=${this.hass}
.items=${{ entity: entityIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${deviceIds.length
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="device"
.hass=${this.hass}
.items=${{ device: deviceIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${floorIds.length || areaIds.length
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="area"
.hass=${this.hass}
.items=${{
floor: floorIds,
area: areaIds,
}}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${labelIds.length
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="label"
.hass=${this.hass}
.items=${{ label: labelIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
</div>
`;
}
private _renderItems() {
return html`
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
`;
}
private _renderPicker() {
private _renderChips() {
return html`
<div class="add-target-wrapper">
<ha-button
@@ -389,8 +347,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._pickerFilter = filter;
};
private _hidePicker(ev) {
ev.stopPropagation();
private _hidePicker() {
this._open = false;
this._pickerWrapperOpen = false;

View File

@@ -6,7 +6,6 @@ import {
mdiLabel,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -20,12 +19,9 @@ import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area_registry";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label_registry";
import {
@@ -115,10 +111,10 @@ export class HaTargetPickerItemRow extends LitElement {
}
protected render() {
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
const { name, context, iconPath, fallbackIconPath, stateObject } =
this._itemData(this.type, this.itemId);
const showEntities = this.type !== "entity" && !notFound;
const showEntities = this.type !== "entity";
const entries = this.parentEntries || this._entries;
@@ -132,7 +128,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
return html`
<ha-md-list-item type="text" class=${notFound ? "error" : ""}>
<ha-md-list-item type="text">
<div class="icon" slot="start">
${this.subEntry
? html`
@@ -152,15 +148,11 @@ export class HaTargetPickerItemRow extends LitElement {
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
: stateObject
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
.stateObj=${stateObject}
>
</ha-state-icon>
`
@@ -168,14 +160,8 @@ export class HaTargetPickerItemRow extends LitElement {
</div>
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
${context && !this.hideContext
? html`<span slot="supporting-text">${context}</span>`
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
@@ -488,28 +474,26 @@ export class HaTargetPickerItemRow extends LitElement {
private _itemData = memoizeOne((type: TargetType, item: string) => {
if (type === "floor") {
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
const floor = this.hass.floors?.[item];
return {
name: floor?.name || item,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
notFound: !floor,
};
}
if (type === "area") {
const area: AreaRegistryEntry | undefined = this.hass.areas?.[item];
const area = this.hass.areas?.[item];
return {
name: area?.name || item,
context: area?.floor_id && this.hass.floors?.[area.floor_id]?.name,
context: area.floor_id && this.hass.floors?.[area.floor_id]?.name,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
notFound: !area,
};
}
if (type === "device") {
const device: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
const device = this.hass.devices?.[item];
if (device?.primary_config_entry) {
if (device.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
@@ -517,25 +501,24 @@ export class HaTargetPickerItemRow extends LitElement {
name: device ? computeDeviceNameDisplay(device, this.hass) : item,
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
fallbackIconPath: mdiDevices,
notFound: !device,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(item));
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: undefined, device: undefined };
const stateObject = this.hass.states[item];
const entityName = computeEntityName(
stateObject,
this.hass.entities,
this.hass.devices
);
const { area, device } = getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
@@ -545,19 +528,15 @@ export class HaTargetPickerItemRow extends LitElement {
name: entityName || deviceName || item,
context,
stateObject,
notFound: !stateObject,
};
}
// type label
const label: LabelRegistryEntry | undefined = this._labelRegistry.find(
(lab) => lab.label_id === item
);
const label = this._labelRegistry.find((lab) => lab.label_id === item);
return {
name: label?.name || item,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
notFound: !label,
};
});
@@ -618,27 +597,17 @@ export class HaTargetPickerItemRow extends LitElement {
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.error {
background: var(--ha-color-fill-warning-quiet-resting);
}
.error [slot="supporting-text"] {
color: var(--ha-color-on-warning-normal);
}
state-badge {
color: var(--ha-color-on-neutral-quiet);
}
.icon {
width: 24px;
display: flex;
}
img {
width: 24px;
height: 24px;
z-index: 1;
}
ha-icon-button {
--mdc-icon-button-size: 32px;

View File

@@ -705,7 +705,7 @@ export class HaTargetPickerSelector extends LitElement {
) as EntityComboBoxItem[];
}
if (!filterType && entities.length) {
if (!filterType) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.entities")
@@ -733,7 +733,7 @@ export class HaTargetPickerSelector extends LitElement {
devices = this._filterGroup("device", devices);
}
if (!filterType && devices.length) {
if (!filterType) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.devices")
@@ -769,7 +769,7 @@ export class HaTargetPickerSelector extends LitElement {
) as FloorComboBoxItem[];
}
if (!filterType && areasAndFloors.length) {
if (!filterType) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.areas")
@@ -811,7 +811,7 @@ export class HaTargetPickerSelector extends LitElement {
labels = this._filterGroup("label", labels);
}
if (!filterType && labels.length) {
if (!filterType) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.labels")

View File

@@ -12,6 +12,7 @@ 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 { WeekdayShort } from "../common/datetime/weekday";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10;
@@ -257,13 +258,11 @@ export interface ZoneCondition extends BaseCondition {
zone: string;
}
type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
export interface TimeCondition extends BaseCondition {
condition: "time";
after?: string;
before?: string;
weekday?: Weekday | Weekday[];
weekday?: WeekdayShort | WeekdayShort[];
}
export interface TemplateCondition extends BaseCondition {

View File

@@ -12,7 +12,6 @@ import {
mdiChatSleep,
mdiClipboardList,
mdiClock,
mdiCodeBraces,
mdiCog,
mdiCommentAlert,
mdiCounter,
@@ -114,7 +113,6 @@ export const FALLBACK_DOMAIN_ICONS = {
text: mdiFormTextbox,
time: mdiClock,
timer: mdiTimerOutline,
template: mdiCodeBraces,
todo: mdiClipboardList,
tts: mdiSpeakerMessage,
vacuum: mdiRobotVacuum,

View File

@@ -1,152 +0,0 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-alert";
import "../../components/ha-icon";
import "../../components/ha-list-item";
import "../../components/ha-spinner";
import type {
ExternalEntityAddToActions,
ExternalEntityAddToAction,
} from "../../external_app/external_messaging";
import { showToast } from "../../util/toast";
import type { HomeAssistant } from "../../types";
@customElement("ha-more-info-add-to")
export class HaMoreInfoAddTo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@state() private _externalActions?: ExternalEntityAddToActions = {
actions: [],
};
@state() private _loading = true;
private async _loadExternalActions() {
if (this.hass.auth.external?.config.hasEntityAddTo) {
this._externalActions =
await this.hass.auth.external?.sendMessage<"entity/add_to/get_actions">(
{
type: "entity/add_to/get_actions",
payload: { entity_id: this.entityId },
}
);
}
}
private async _actionSelected(ev: CustomEvent) {
const action = (ev.currentTarget as any)
.action as ExternalEntityAddToAction;
if (!action.enabled) {
return;
}
try {
await this.hass.auth.external!.fireMessage({
type: "entity/add_to",
payload: {
entity_id: this.entityId,
app_payload: action.app_payload,
},
});
} catch (err: any) {
showToast(this, {
message: this.hass.localize(
"ui.dialogs.more_info_control.add_to.action_failed",
{
error: err.message || err,
}
),
});
}
}
protected async firstUpdated() {
await this._loadExternalActions();
this._loading = false;
}
protected render() {
if (this._loading) {
return html`
<div class="loading">
<ha-spinner></ha-spinner>
</div>
`;
}
if (!this._externalActions?.actions.length) {
return html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.no_actions"
)}
</ha-alert>
`;
}
return html`
<div class="actions-list">
${this._externalActions.actions.map(
(action) => html`
<ha-list-item
graphic="icon"
.disabled=${!action.enabled}
.action=${action}
.twoline=${!!action.details}
@click=${this._actionSelected}
>
<span>${action.name}</span>
${action.details
? html`<span slot="secondary">${action.details}</span>`
: nothing}
<ha-icon slot="graphic" .icon=${action.mdi_icon}></ha-icon>
</ha-list-item>
`
)}
</div>
`;
}
static styles = css`
:host {
display: block;
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: var(--ha-space-8);
}
.actions-list {
display: flex;
flex-direction: column;
}
ha-list-item {
cursor: pointer;
}
ha-list-item[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
ha-icon {
display: flex;
align-items: center;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-add-to": HaMoreInfoAddTo;
}
}

View File

@@ -8,7 +8,6 @@ import {
mdiPencil,
mdiPencilOff,
mdiPencilOutline,
mdiPlusBoxMultipleOutline,
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -61,7 +60,6 @@ import {
computeShowLogBookComponent,
} from "./const";
import "./controls/more-info-default";
import "./ha-more-info-add-to";
import "./ha-more-info-history-and-logbook";
import "./ha-more-info-info";
import "./ha-more-info-settings";
@@ -75,7 +73,7 @@ export interface MoreInfoDialogParams {
data?: Record<string, any>;
}
type View = "info" | "history" | "settings" | "related" | "add_to";
type View = "info" | "history" | "settings" | "related";
interface ChildView {
viewTag: string;
@@ -196,10 +194,6 @@ export class MoreInfoDialog extends LitElement {
);
}
private _shouldShowAddEntityTo(): boolean {
return !!this.hass.auth.external?.config.hasEntityAddTo;
}
private _getDeviceId(): string | null {
const entity = this.hass.entities[this._entityId!] as
| EntityRegistryEntry
@@ -301,11 +295,6 @@ export class MoreInfoDialog extends LitElement {
this._setView("related");
}
private _goToAddEntityTo(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) return;
this._setView("add_to");
}
private _breadcrumbClick(ev: Event) {
ev.stopPropagation();
this._setView("related");
@@ -532,22 +521,6 @@ export class MoreInfoDialog extends LitElement {
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item>
${this._shouldShowAddEntityTo()
? html`
<ha-list-item
graphic="icon"
@request-selected=${this._goToAddEntityTo}
>
${this.hass.localize(
"ui.dialogs.more_info_control.add_entity_to"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPlusBoxMultipleOutline}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
</ha-button-menu>
`
: nothing}
@@ -640,14 +613,7 @@ export class MoreInfoDialog extends LitElement {
: "entity"}
></ha-related-items>
`
: this._currView === "add_to"
? html`
<ha-more-info-add-to
.hass=${this.hass}
.entityId=${entityId}
></ha-more-info-add-to>
`
: nothing
: nothing
)}
</div>
`

View File

@@ -36,13 +36,6 @@ interface EMOutgoingMessageConfigGet extends EMMessage {
type: "config/get";
}
interface EMOutgoingMessageEntityAddToGetActions extends EMMessage {
type: "entity/add_to/get_actions";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageBarCodeScan extends EMMessage {
type: "bar_code/scan";
payload: {
@@ -82,10 +75,6 @@ interface EMOutgoingMessageWithAnswer {
request: EMOutgoingMessageConfigGet;
response: ExternalConfig;
};
"entity/add_to/get_actions": {
request: EMOutgoingMessageEntityAddToGetActions;
response: ExternalEntityAddToActions;
};
}
interface EMOutgoingMessageExoplayerPlayHLS extends EMMessage {
@@ -168,14 +157,6 @@ interface EMOutgoingMessageThreadStoreInPlatformKeychain extends EMMessage {
};
}
interface EMOutgoingMessageAddEntityTo extends EMMessage {
type: "entity/add_to";
payload: {
entity_id: string;
app_payload: string; // Opaque string received from get_actions
};
}
type EMOutgoingMessageWithoutAnswer =
| EMMessageResultError
| EMMessageResultSuccess
@@ -196,8 +177,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageThemeUpdate
| EMOutgoingMessageThreadStoreInPlatformKeychain
| EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo;
| EMOutgoingMessageImprovConfigureDevice;
export interface EMIncomingMessageRestart {
id: number;
@@ -313,31 +293,18 @@ type EMIncomingMessage =
type EMIncomingMessageHandler = (msg: EMIncomingMessageCommands) => boolean;
export interface ExternalConfig {
hasSettingsScreen?: boolean;
hasSidebar?: boolean;
canWriteTag?: boolean;
hasExoPlayer?: boolean;
canCommissionMatter?: boolean;
canImportThreadCredentials?: boolean;
canTransferThreadCredentialsToKeychain?: boolean;
hasAssist?: boolean;
hasBarCodeScanner?: number;
canSetupImprov?: boolean;
downloadFileSupported?: boolean;
appVersion?: string;
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
}
export interface ExternalEntityAddToAction {
enabled: boolean;
name: string; // Translated name of the action to be displayed in the UI
details?: string; // Optional translated details of the action to be displayed in the UI
mdi_icon: string; // MDI icon name to be displayed in the UI (e.g., "mdi:car")
app_payload: string; // Opaque string to be sent back when the action is selected
}
export interface ExternalEntityAddToActions {
actions: ExternalEntityAddToAction[];
hasSettingsScreen: boolean;
hasSidebar: boolean;
canWriteTag: boolean;
hasExoPlayer: boolean;
canCommissionMatter: boolean;
canImportThreadCredentials: boolean;
canTransferThreadCredentialsToKeychain: boolean;
hasAssist: boolean;
hasBarCodeScanner: number;
canSetupImprov: boolean;
downloadFileSupported: boolean;
appVersion: string;
}
export class ExternalMessaging {

View File

@@ -20,21 +20,6 @@
<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);
@@ -47,28 +32,11 @@
}
}
#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;
}
@media (prefers-color-scheme: dark) {
#ha-launch-screen {
background-color: var(--primary-background-color, #111111);
}
}
#ha-launch-screen.removing {
opacity: 0;
}
#ha-launch-screen svg {
width: 112px;

View File

@@ -33,7 +33,7 @@ const COMPONENTS = {
"media-browser": () =>
import("../panels/media-browser/ha-panel-media-browser"),
light: () => import("../panels/light/ha-panel-light"),
security: () => import("../panels/security/ha-panel-security"),
safety: () => import("../panels/safety/ha-panel-safety"),
climate: () => import("../panels/climate/ha-panel-climate"),
};

View File

@@ -1,23 +1,24 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "climate",
},
const CLIMATE_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "climate",
},
},
],
};
@customElement("ha-panel-climate")
@@ -32,119 +33,65 @@ class PanelClimate extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
public firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
}
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) {
if (oldHass?.locale !== this.hass.locale) {
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();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
protected render(): TemplateResult {
return html`
<div class="header">
<div class="toolbar">
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
<div class="main-title">${this.hass.localize("panel.climate")}</div>
</div>
</div>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`;
}
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
CLIMATE_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
private _setLovelace() {
this._lovelace = {
config: config,
rawConfig: rawConfig,
config: CLIMATE_LOVELACE_CONFIG,
rawConfig: CLIMATE_LOVELACE_CONFIG,
editMode: false,
urlPath: "climate",
mode: "generated",

View File

@@ -257,7 +257,7 @@ class DialogAddAutomationElement
const results = fuse.multiTermsSearch(filter);
if (results) {
return results.map((result) => result.item).filter((item) => item.name);
return results.map((result) => result.item);
}
return items;
}
@@ -294,7 +294,7 @@ class DialogAddAutomationElement
const results = fuse.multiTermsSearch(filter);
if (results) {
return results.map((result) => result.item).filter((item) => item.name);
return results.map((result) => result.item);
}
return items;
}
@@ -383,16 +383,9 @@ class DialogAddAutomationElement
generatedCollections.push({
titleKey: collection.titleKey,
groups: groups.sort((a, b) => {
// make sure device is always on top
if (a.key === "device" || a.key === "device_id") {
return -1;
}
if (b.key === "device" || b.key === "device_id") {
return 1;
}
return stringCompare(a.name, b.name, this.hass.locale.language);
}),
groups: groups.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
),
});
});
return generatedCollections;
@@ -685,7 +678,7 @@ class DialogAddAutomationElement
);
const typeTitle = this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.add`
`ui.panel.config.automation.editor.${automationElementType}s.header`
);
const tabButtons = [
@@ -902,9 +895,7 @@ class DialogAddAutomationElement
return html`
<div class="items-title ${this._itemsScrolled ? "scrolled" : ""}">
${this._tab === "blocks" && !this._filter
? this.hass.localize("ui.panel.config.automation.editor.blocks")
: title}
${title}
</div>
<ha-md-list
dialogInitialFocus=${ifDefined(this._fullScreen ? "" : undefined)}

View File

@@ -9,7 +9,6 @@ import "../../../../components/ha-dialog";
import "../../../../components/ha-button";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-markdown";
import type { HaRadio } from "../../../../components/ha-radio";
import type {
FlowFromGridSourceEnergyPreference,
@@ -20,7 +19,11 @@ import {
emptyFlowToGridSourceEnergyPreference,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import { isExternalStatistic } from "../../../../data/recorder";
import {
getDisplayUnit,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
@@ -44,6 +47,8 @@ export class DialogEnergyGridFlowSettings
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _pickedDisplayUnit?: string | null;
@state() private _energy_units?: string[];
@state() private _error?: string;
@@ -76,6 +81,11 @@ export class DialogEnergyGridFlowSettings
: "stat_energy_to"
];
this._pickedDisplayUnit = getDisplayUnit(
this.hass,
initialSourceId,
params.metadata
);
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
@@ -93,6 +103,7 @@ export class DialogEnergyGridFlowSettings
public closeDialog() {
this._params = undefined;
this._source = undefined;
this._pickedDisplayUnit = undefined;
this._error = undefined;
this._excludeList = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -106,6 +117,10 @@ export class DialogEnergyGridFlowSettings
const pickableUnit = this._energy_units?.join(", ") || "";
const unitPriceSensor = this._pickedDisplayUnit
? `${this.hass.config.currency}/${this._pickedDisplayUnit}`
: undefined;
const unitPriceFixed = `${this.hass.config.currency}/kWh`;
const externalSource =
@@ -231,15 +246,9 @@ export class DialogEnergyGridFlowSettings
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
.label=${`${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_entity_input`
)}
.helper=${html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.grid.flow_dialog.cost_entity_helper",
{ currency: this.hass.config.currency }
)}
></ha-markdown>`}
)} ${unitPriceSensor ? ` (${unitPriceSensor})` : ""}`}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
@@ -332,6 +341,16 @@ export class DialogEnergyGridFlowSettings
}
private async _statisticChanged(ev: CustomEvent<{ value: string }>) {
if (ev.detail.value) {
const metadata = await getStatisticMetadata(this.hass, [ev.detail.value]);
this._pickedDisplayUnit = getDisplayUnit(
this.hass,
ev.detail.value,
metadata[0]
);
} else {
this._pickedDisplayUnit = undefined;
}
this._source = {
...this._source!,
[this._params!.direction === "from"

View File

@@ -332,13 +332,13 @@ export class HaConfigLovelaceDashboards extends LitElement {
});
}
if (this.hass.panels.security) {
if (this.hass.panels.safety) {
result.push({
icon: "mdi:security",
title: this.hass.localize("panel.security"),
title: this.hass.localize("panel.safety"),
show_in_sidebar: false,
mode: "storage",
url_path: "security",
url_path: "safety",
filename: "",
default: false,
require_admin: false,
@@ -470,13 +470,13 @@ export class HaConfigLovelaceDashboards extends LitElement {
}
private _canDelete(urlPath: string) {
return !["lovelace", "energy", "light", "security", "climate"].includes(
return !["lovelace", "energy", "light", "safety", "climate"].includes(
urlPath
);
}
private _canEdit(urlPath: string) {
return !["light", "security", "climate"].includes(urlPath);
return !["light", "safety", "climate"].includes(urlPath);
}
private _handleDelete = async (item: DataTableItem) => {

View File

@@ -109,26 +109,15 @@ export class AssistPipelineDetailConversation extends LitElement {
}
private _supportedLanguagesChanged(ev) {
this._supportedLanguages = ev.detail.value;
if (
this._supportedLanguages === "*" ||
!this._supportedLanguages?.includes(
this.data?.conversation_language || ""
) ||
!this.data?.conversation_language
) {
if (ev.detail.value === "*") {
// wait for update of conversation_engine
setTimeout(() => {
const value = { ...this.data };
if (this._supportedLanguages === "*") {
value.conversation_language = "*";
} else {
value.conversation_language = this._supportedLanguages?.[0] ?? null;
}
value.conversation_language = "*";
fireEvent(this, "value-changed", { value });
}, 0);
}
this._supportedLanguages = ev.detail.value;
}
static styles = css`

View File

@@ -214,7 +214,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updatePipeline}
.loading=${this._submitting}
.disabled=${this._submitting}
dialogInitialFocus
>
${this._params.pipeline?.id

View File

@@ -1,23 +1,24 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
const LIGHT_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "light",
},
const LIGHT_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "light",
},
},
],
};
@customElement("ha-panel-light")
@@ -33,118 +34,60 @@ class PanelLight extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
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) {
if (oldHass?.locale !== this.hass.locale) {
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();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
protected render(): TemplateResult {
return html`
<div class="header">
<div class="toolbar">
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
<div class="main-title">${this.hass.localize("panel.light")}</div>
</div>
</div>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`;
}
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
LIGHT_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [LIGHT_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
private _setLovelace() {
this._lovelace = {
config: config,
rawConfig: rawConfig,
config: LIGHT_LOVELACE_CONFIG,
rawConfig: LIGHT_LOVELACE_CONFIG,
editMode: false,
urlPath: "light",
mode: "generated",

View File

@@ -33,7 +33,7 @@ import type { HomeSummaryCard } from "./types";
const COLORS: Record<HomeSummary, string> = {
light: "amber",
climate: "deep-orange",
security: "blue-grey",
safety: "blue-grey",
media_players: "blue",
};
@@ -142,20 +142,20 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
? `${formattedMinTemp}°`
: `${formattedMinTemp} - ${formattedMaxTemp}°`;
}
case "security": {
case "safety": {
// Alarm and lock status
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
const safetyFilters = HOME_SUMMARIES_FILTERS.safety.map((filter) =>
generateEntityFilter(this.hass!, filter)
);
const securityEntities = findEntities(allEntities, securityFilters);
const safetyEntities = findEntities(allEntities, safetyFilters);
const locks = securityEntities.filter((entityId) => {
const locks = safetyEntities.filter((entityId) => {
const domain = computeDomain(entityId);
return domain === "lock";
});
const alarms = securityEntities.filter((entityId) => {
const alarms = safetyEntities.filter((entityId) => {
const domain = computeDomain(entityId);
return domain === "alarm_control_panel";
});

View File

@@ -1,6 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
@@ -98,10 +97,7 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
: `${sandbox_user_params} ${IFRAME_SANDBOX}`;
return html`
<ha-card
class=${classMap({ "hide-background": this._config.hide_background })}
.header=${this._config.title}
>
<ha-card .header=${this._config.title}>
<div
id="root"
style=${styleMap({
@@ -137,12 +133,6 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
flex-direction: column;
}
ha-card.hide-background {
background: transparent;
box-shadow: none;
border: none;
}
#root {
width: 100%;
height: 100%;

View File

@@ -323,7 +323,6 @@ export interface IframeCardConfig extends LovelaceCardConfig {
title?: string;
allow?: string;
url: string;
hide_background?: boolean;
}
export interface LightCardConfig extends LovelaceCardConfig {

View File

@@ -1,6 +1,7 @@
import {
mdiAccount,
mdiAmpersand,
mdiCalendarClock,
mdiGateOr,
mdiMapMarker,
mdiNotEqualVariant,
@@ -15,6 +16,7 @@ export const ICON_CONDITION: Record<Condition["condition"], string> = {
numeric_state: mdiNumeric,
state: mdiStateMachine,
screen: mdiResponsive,
time: mdiCalendarClock,
user: mdiAccount,
and: mdiAmpersand,
not: mdiNotEqualVariant,

View File

@@ -1,4 +1,9 @@
import { ensureArray } from "../../../common/array/ensure-array";
import { checkTimeInRange } from "../../../common/datetime/check_time";
import {
WEEKDAYS_SHORT,
type WeekdayShort,
} from "../../../common/datetime/weekday";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import { listenMediaQuery } from "../../../common/dom/media_query";
@@ -12,6 +17,7 @@ export type Condition =
| NumericStateCondition
| StateCondition
| ScreenCondition
| TimeCondition
| UserCondition
| OrCondition
| AndCondition
@@ -52,6 +58,13 @@ export interface ScreenCondition extends BaseCondition {
media_query?: string;
}
export interface TimeCondition extends BaseCondition {
condition: "time";
after?: string;
before?: string;
weekdays?: WeekdayShort[];
}
export interface UserCondition extends BaseCondition {
condition: "user";
users?: string[];
@@ -152,6 +165,15 @@ function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) {
: false;
}
function checkTimeCondition(condition: TimeCondition, hass: HomeAssistant) {
return checkTimeInRange(
hass,
condition.after,
condition.before,
condition.weekdays
);
}
function checkLocationCondition(
condition: LocationCondition,
hass: HomeAssistant
@@ -197,6 +219,8 @@ export function checkConditionsMet(
return conditions.every((c) => {
if ("condition" in c) {
switch (c.condition) {
case "time":
return checkTimeCondition(c, hass);
case "screen":
return checkScreenCondition(c, hass);
case "user":
@@ -273,6 +297,17 @@ function validateScreenCondition(condition: ScreenCondition) {
return condition.media_query != null;
}
function validateTimeCondition(condition: TimeCondition) {
const hasTime = condition.after != null || condition.before != null;
const hasWeekdays =
condition.weekdays != null && condition.weekdays.length > 0;
const weekdaysValid =
!hasWeekdays ||
condition.weekdays!.every((w: WeekdayShort) => WEEKDAYS_SHORT.includes(w));
return (hasTime || hasWeekdays) && weekdaysValid;
}
function validateUserCondition(condition: UserCondition) {
return condition.users != null;
}
@@ -312,6 +347,8 @@ export function validateConditionalConfig(
switch (c.condition) {
case "screen":
return validateScreenCondition(c);
case "time":
return validateTimeCondition(c);
case "user":
return validateUserCondition(c);
case "location":

View File

@@ -25,6 +25,7 @@ import "./types/ha-card-condition-numeric_state";
import "./types/ha-card-condition-or";
import "./types/ha-card-condition-screen";
import "./types/ha-card-condition-state";
import "./types/ha-card-condition-time";
import "./types/ha-card-condition-user";
import { storage } from "../../../../common/decorators/storage";
@@ -33,6 +34,7 @@ const UI_CONDITION = [
"numeric_state",
"state",
"screen",
"time",
"user",
"and",
"not",

View File

@@ -0,0 +1,102 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import {
literal,
array,
object,
optional,
string,
assert,
enums,
} from "superstruct";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../../../../../types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import {
WEEKDAY_SHORT_TO_LONG,
WEEKDAYS_SHORT,
} from "../../../../../common/datetime/weekday";
import type { TimeCondition } from "../../../common/validate-condition";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../../components/ha-form/types";
import "../../../../../components/ha-form/ha-form";
const timeConditionStruct = object({
condition: literal("time"),
after: optional(string()),
before: optional(string()),
weekdays: optional(array(enums(WEEKDAYS_SHORT))),
});
@customElement("ha-card-condition-time")
export class HaCardConditionTime extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: TimeCondition;
@property({ type: Boolean }) public disabled = false;
public static get defaultConfig(): TimeCondition {
return { condition: "time", after: "08:00", before: "17:00" };
}
protected static validateUIConfig(condition: TimeCondition) {
return assert(condition, timeConditionStruct);
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{ name: "after", selector: { time: { no_second: true } } },
{ name: "before", selector: { time: { no_second: true } } },
{
name: "weekdays",
selector: {
select: {
mode: "list",
multiple: true,
options: WEEKDAYS_SHORT.map((day) => ({
value: day,
label: localize(`ui.weekdays.${WEEKDAY_SHORT_TO_LONG[day]}`),
})),
},
},
},
] as const satisfies HaFormSchema[]
);
protected render() {
return html`
<ha-form
.hass=${this.hass}
.data=${this.condition}
.computeLabel=${this._computeLabelCallback}
.schema=${this._schema(this.hass.localize)}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const data = ev.detail.value as TimeCondition;
fireEvent(this, "value-changed", { value: data });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.time.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-time": HaCardConditionTime;
}
}

View File

@@ -16,7 +16,6 @@ const cardConfigStruct = assign(
url: optional(string()),
aspect_ratio: optional(string()),
allow_open_top_navigation: optional(boolean()),
hide_background: optional(boolean()),
})
);
@@ -30,7 +29,6 @@ const SCHEMA = [
{ name: "aspect_ratio", selector: { text: {} } },
],
},
{ name: "hide_background", selector: { boolean: {} } },
] as const;
@customElement("hui-iframe-card-editor")
@@ -58,7 +56,6 @@ export class HuiIframeCardEditor
.data=${this._config}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
@@ -68,29 +65,8 @@ export class HuiIframeCardEditor
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "hide_background":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.iframe.hide_background"
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "hide_background":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.iframe.hide_background_helper"
);
default:
return "";
}
};
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) =>
this.hass!.localize(`ui.panel.lovelace.editor.card.generic.${schema.name}`);
}
declare global {

View File

@@ -1,7 +1,5 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
@@ -11,21 +9,23 @@ import {
optional,
string,
} from "superstruct";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-entities-picker";
import "../../../../components/ha-target-picker";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-target-picker";
import type { HaEntityPickerEntityFilterFunc } from "../../../../data/entity";
import { filterLogbookCompatibleEntities } from "../../../../data/logbook";
import { targetStruct } from "../../../../data/script";
import { resolveEntityIDs } from "../../../../data/selector";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-logbook-card";
import type { LogbookCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-logbook-card";
import { targetStruct } from "../../../../data/script";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
import type { HaEntityPickerEntityFilterFunc } from "../../../../data/entity";
import { resolveEntityIDs } from "../../../../data/selector";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
@@ -132,6 +132,7 @@ export class HuiLogbookCardEditor
.hass=${this.hass}
.entityFilter=${this._filterFunc}
.value=${this._targetPicker}
add-on-top
@value-changed=${this._entitiesChanged}
></ha-target-picker>
`;
@@ -188,13 +189,6 @@ export class HuiLogbookCardEditor
);
}
};
static styles = css`
ha-target-picker {
display: block;
margin-top: var(--ha-space-4);
}
`;
}
declare global {

View File

@@ -48,7 +48,7 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
import("./home/home-media-players-view-strategy"),
"home-area": () => import("./home/home-area-view-strategy"),
light: () => import("../../light/strategies/light-view-strategy"),
security: () => import("../../security/strategies/security-view-strategy"),
safety: () => import("../../safety/strategies/safety-view-strategy"),
climate: () => import("../../climate/strategies/climate-view-strategy"),
},
section: {

View File

@@ -2,12 +2,12 @@ import type { EntityFilter } from "../../../../../common/entity/entity_filter";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { climateEntityFilters } from "../../../../climate/strategies/climate-view-strategy";
import { lightEntityFilters } from "../../../../light/strategies/light-view-strategy";
import { securityEntityFilters } from "../../../../security/strategies/security-view-strategy";
import { safetyEntityFilters } from "../../../../safety/strategies/safety-view-strategy";
export const HOME_SUMMARIES = [
"light",
"climate",
"security",
"safety",
"media_players",
] as const;
@@ -16,14 +16,14 @@ export type HomeSummary = (typeof HOME_SUMMARIES)[number];
export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
light: "mdi:lamps",
climate: "mdi:home-thermometer",
security: "mdi:security",
safety: "mdi:security",
media_players: "mdi:multimedia",
};
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
light: lightEntityFilters,
climate: climateEntityFilters,
security: securityEntityFilters,
safety: safetyEntityFilters,
media_players: [{ domain: "media_player", entity_category: "none" }],
};
@@ -31,7 +31,7 @@ export const getSummaryLabel = (
localize: LocalizeFunc,
summary: HomeSummary
) => {
if (summary === "light" || summary === "climate" || summary === "security") {
if (summary === "light" || summary === "climate" || summary === "safety") {
return localize(`panel.${summary}`);
}
return localize(`ui.panel.lovelace.strategy.home.summary_list.${summary}`);

View File

@@ -104,7 +104,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
const {
light,
climate,
security,
safety,
media_players: mediaPlayers,
} = entitiesBySummary;
@@ -136,16 +136,16 @@ export class HomeAreaViewStrategy extends ReactiveElement {
});
}
if (security.length > 0) {
if (safety.length > 0) {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
getSummaryLabel(hass.localize, "security"),
HOME_SUMMARIES_ICONS.security,
"/security?historyBack=1"
getSummaryLabel(hass.localize, "safety"),
HOME_SUMMARIES_ICONS.safety,
"/safety?historyBack=1"
),
...security.map(computeTileCard),
...safety.map(computeTileCard),
],
});
}

View File

@@ -1,11 +1,7 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import {
findEntities,
generateEntityFilter,
} from "../../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
import { generateEntityFilter } from "../../../../common/entity/entity_filter";
import type { AreaRegistryEntry } from "../../../../data/area_registry";
import { getEnergyPreferences } from "../../../../data/energy";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
@@ -25,7 +21,7 @@ import type {
import { getAreas, getFloors } from "../areas/helpers/areas-strategy-helper";
import type { CommonControlSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
import { getHomeStructure } from "./helpers/home-structure";
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
export interface HomeMainViewStrategyConfig {
type: "home-main";
@@ -148,105 +144,82 @@ export class HomeMainViewStrategy extends ReactiveElement {
column_span: maxColumns,
} as LovelaceStrategySectionConfig;
const allEntities = Object.keys(hass.states);
const mediaPlayerFilter = HOME_SUMMARIES_FILTERS.media_players.map(
(filter) => generateEntityFilter(hass, filter)
);
const lightsFilters = HOME_SUMMARIES_FILTERS.light.map((filter) =>
generateEntityFilter(hass, filter)
);
const climateFilters = HOME_SUMMARIES_FILTERS.climate.map((filter) =>
generateEntityFilter(hass, filter)
);
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(hass, filter)
);
const hasLights = findEntities(allEntities, lightsFilters).length > 0;
const hasMediaPlayers =
findEntities(allEntities, mediaPlayerFilter).length > 0;
const hasClimate = findEntities(allEntities, climateFilters).length > 0;
const hasSecurity = findEntities(allEntities, securityFilters).length > 0;
// Check if there are any media player entities
const mediaPlayerFilter = generateEntityFilter(hass, {
domain: "media_player",
entity_category: "none",
});
const hasMediaPlayers = Object.keys(hass.states).some(mediaPlayerFilter);
const summaryCards: LovelaceCardConfig[] = [
hasLights &&
({
type: "home-summary",
summary: "light",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "/light?historyBack=1",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard),
hasClimate &&
({
type: "home-summary",
summary: "climate",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "/climate?historyBack=1",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard),
hasSecurity &&
({
type: "home-summary",
summary: "security",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "/security?historyBack=1",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard),
hasMediaPlayers &&
({
type: "home-summary",
summary: "media_players",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "media-players",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard),
].filter(Boolean) as LovelaceCardConfig[];
{
type: "heading",
heading: hass.localize("ui.panel.lovelace.strategy.home.summaries"),
},
{
type: "home-summary",
summary: "light",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "/light?historyBack=1",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard,
{
type: "home-summary",
summary: "climate",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "/climate?historyBack=1",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard,
{
type: "home-summary",
summary: "safety",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "/safety?historyBack=1",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard,
];
// Only add media players summary if there are media player entities
if (hasMediaPlayers) {
summaryCards.push({
type: "home-summary",
summary: "media_players",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "media-players",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard);
}
const summarySection: LovelaceSectionConfig = {
type: "grid",
column_span: maxColumns,
cards: [],
cards: summaryCards,
};
if (summaryCards.length) {
summarySection.cards!.push(
{
type: "heading",
heading: hass.localize("ui.panel.lovelace.strategy.home.summaries"),
},
...summaryCards
);
}
const weatherFilter = generateEntityFilter(hass, {
domain: "weather",
entity_category: "none",
@@ -302,7 +275,7 @@ export class HomeMainViewStrategy extends ReactiveElement {
[
favoriteSection.cards && favoriteSection,
commonControlsSection,
summarySection.cards && summarySection,
summarySection,
...floorsSections,
widgetSection.cards && widgetSection,
] satisfies (LovelaceSectionRawConfig | undefined)[]

View File

@@ -1,27 +1,28 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
const SECURITY_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "security",
},
const SAFETY_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "safety",
},
},
],
};
@customElement("ha-panel-security")
class PanelSecurity extends LitElement {
@customElement("ha-panel-safety")
class PanelSafety extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -33,120 +34,62 @@ class PanelSecurity extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
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) {
if (oldHass?.locale !== this.hass.locale) {
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();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
protected render(): TemplateResult {
return html`
<div class="header">
<div class="toolbar">
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
<div class="main-title">${this.hass.localize("panel.security")}</div>
</div>
</div>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
></ha-menu-button>
`}
<div class="main-title">${this.hass.localize("panel.safety")}</div>
</div>
</div>
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`;
}
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
SECURITY_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [SECURITY_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
private _setLovelace() {
this._lovelace = {
config: config,
rawConfig: rawConfig,
config: SAFETY_LOVELACE_CONFIG,
rawConfig: SAFETY_LOVELACE_CONFIG,
editMode: false,
urlPath: "security",
urlPath: "safety",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
@@ -248,6 +191,6 @@ class PanelSecurity extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-panel-security": PanelSecurity;
"ha-panel-safety": PanelSafety;
}
}

View File

@@ -17,11 +17,11 @@ import {
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
export interface SecurityViewStrategyConfig {
type: "security";
export interface SafetyViewStrategyConfig {
type: "safety";
}
export const securityEntityFilters: EntityFilter[] = [
export const safetyEntityFilters: EntityFilter[] = [
{
domain: "camera",
entity_category: "none",
@@ -67,7 +67,7 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
const processAreasForSecurity = (
const processAreasForSafety = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
@@ -81,12 +81,12 @@ const processAreasForSecurity = (
const areaFilter = generateEntityFilter(hass, {
area: area.area_id,
});
const areaSecurityEntities = entities.filter(areaFilter);
const areaSafetyEntities = entities.filter(areaFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, "", false);
for (const entityId of areaSecurityEntities) {
for (const entityId of areaSafetyEntities) {
areaCards.push(computeTileCard(entityId));
}
@@ -121,10 +121,10 @@ const processUnassignedEntities = (
return areaCards;
};
@customElement("security-view-strategy")
export class SecurityViewStrategy extends ReactiveElement {
@customElement("safety-view-strategy")
export class SafetyViewStrategy extends ReactiveElement {
static async generate(
_config: SecurityViewStrategyConfig,
_config: SafetyViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = getAreas(hass.areas);
@@ -135,11 +135,11 @@ export class SecurityViewStrategy extends ReactiveElement {
const allEntities = Object.keys(hass.states);
const securityFilters = securityEntityFilters.map((filter) =>
const safetyFilters = safetyEntityFilters.map((filter) =>
generateEntityFilter(hass, filter)
);
const entities = findEntities(allEntities, securityFilters);
const entities = findEntities(allEntities, safetyFilters);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0);
@@ -164,7 +164,7 @@ export class SecurityViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForSecurity(areaIds, hass, entities);
const areaCards = processAreasForSafety(areaIds, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -188,7 +188,7 @@ export class SecurityViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForSecurity(home.areas, hass, entities);
const areaCards = processAreasForSafety(home.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -209,9 +209,9 @@ export class SecurityViewStrategy extends ReactiveElement {
heading:
sections.length > 0
? hass.localize(
"ui.panel.lovelace.strategy.security.other_devices"
"ui.panel.lovelace.strategy.safety.other_devices"
)
: hass.localize("ui.panel.lovelace.strategy.security.devices"),
: hass.localize("ui.panel.lovelace.strategy.safety.devices"),
},
...unassignedCards,
],
@@ -229,6 +229,6 @@ export class SecurityViewStrategy extends ReactiveElement {
declare global {
interface HTMLElementTagNameMap {
"security-view-strategy": SecurityViewStrategy;
"safety-view-strategy": SafetyViewStrategy;
}
}

View File

@@ -199,23 +199,3 @@ export const baseEntrypointStyles = css`
width: 100vw;
}
`;
export const baseAnimationStyles = css`
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
`;

View File

@@ -42,14 +42,6 @@ export const coreStyles = css`
--ha-space-18: 72px;
--ha-space-19: 76px;
--ha-space-20: 80px;
--ha-animation-base-duration: 350ms;
}
@media (prefers-reduced-motion: reduce) {
html {
--ha-animation-base-duration: 0ms;
}
}
`;

View File

@@ -12,7 +12,7 @@
"media_browser": "Media",
"profile": "Profile",
"light": "Lights",
"security": "Security",
"safety": "Safety",
"climate": "Climate"
},
"state": {
@@ -695,11 +695,6 @@
"remove_device_id": "Remove device",
"remove_entity_id": "Remove entity",
"remove_label_id": "Remove label",
"floor_not_found": "Floor not found",
"area_not_found": "Area not found",
"device_not_found": "Device not found",
"entity_not_found": "Entity not found",
"label_not_found": "Label not found",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"target_details": "Target details",
@@ -1434,7 +1429,6 @@
"back_to_info": "Back to info",
"info": "Information",
"related": "Related",
"add_entity_to": "Add to",
"history": "History",
"aggregate": "5-minute aggregated",
"logbook": "Activity",
@@ -1451,10 +1445,6 @@
"last_action": "Last action",
"last_triggered": "Last triggered"
},
"add_to": {
"no_actions": "No actions available",
"action_failed": "Failed to perform the action {error}"
},
"sun": {
"azimuth": "Azimuth",
"elevation": "Elevation",
@@ -3076,7 +3066,6 @@
"remove_co2_signal": "Remove Electricity Maps integration",
"add_co2_signal": "Add Electricity Maps integration",
"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": {
"header": "Configure grid consumption",
"paragraph": "Grid consumption is the energy that flows from the energy grid to your home.",
@@ -6991,7 +6980,7 @@
"lights": "Lights",
"other_lights": "Other lights"
},
"security": {
"safety": {
"devices": "Devices",
"other_devices": "Other devices"
},
@@ -7560,6 +7549,12 @@
"state_equal": "State is equal to",
"state_not_equal": "State is not equal to"
},
"time": {
"label": "Time",
"after": "After",
"before": "Before",
"weekdays": "Weekdays"
},
"location": {
"label": "Location",
"locations": "Locations",
@@ -7789,9 +7784,7 @@
},
"iframe": {
"name": "Webpage",
"description": "The Webpage card allows you to embed your favorite webpage right into Home Assistant.",
"hide_background": "Hide background",
"hide_background_helper": "Useful for pages which allow a transparent background."
"description": "The Webpage card allows you to embed your favorite webpage right into Home Assistant."
},
"light": {
"name": "Light",

View File

@@ -1,26 +1,10 @@
import type { TemplateResult } from "lit";
import { render } from "lit";
const removeElement = (launchScreenElement: HTMLElement) => {
launchScreenElement.classList.add("removing");
setTimeout(() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
}, 500);
};
export const removeLaunchScreen = () => {
const launchScreenElement = document.getElementById("ha-launch-screen");
if (!launchScreenElement?.parentElement) {
return;
}
if (document.startViewTransition) {
document.startViewTransition(() => {
removeElement(launchScreenElement);
});
} else {
// Fallback: Direct removal without transition
removeElement(launchScreenElement);
if (launchScreenElement) {
launchScreenElement.parentElement!.removeChild(launchScreenElement);
}
};

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { checkTimeInRange } from "../../../src/common/datetime/check_time";
import type { HomeAssistant } from "../../../src/types";
import {
NumberFormat,
TimeFormat,
FirstWeekday,
DateFormat,
TimeZone,
} from "../../../src/data/translation";
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, "08:00", "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, "08:00", "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, "08:00", "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, "22:00", "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, "22:00", "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, "22:00", "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, "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, "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, undefined, "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, undefined, "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, undefined, undefined, ["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, undefined, undefined, ["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, undefined, undefined, ["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, "08:00", "17:00", ["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, "08:00", "17:00", ["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, "08:00", "17:00", ["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)).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, "01:00", "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, "01:00", "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, "01:00", "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, "22:00", "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, "08:00", "17:00")).toBe(true);
expect(checkTimeInRange(mockHass, "12:00", "17:00")).toBe(false);
});
});
});