mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-04 16:39:44 +00:00
Compare commits
17 Commits
dev
...
card-visib
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fc305ec6b | ||
|
|
fd657fbf68 | ||
|
|
885f4564a9 | ||
|
|
d27c2e3a04 | ||
|
|
f2655ec226 | ||
|
|
1c3043ed37 | ||
|
|
86bfb6a482 | ||
|
|
bd3a1ea281 | ||
|
|
90757b6f97 | ||
|
|
6a2bbb02a2 | ||
|
|
698197dccd | ||
|
|
665ff88d58 | ||
|
|
bb8dd69e7b | ||
|
|
819182977c | ||
|
|
2d838e22d2 | ||
|
|
036cbe0a1a | ||
|
|
6fc039340e |
110
src/common/datetime/check_time.ts
Normal file
110
src/common/datetime/check_time.ts
Normal 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 isAfter(now, afterDate) || now.getTime() === afterDate.getTime();
|
||||
}
|
||||
|
||||
if (beforeDate) {
|
||||
return isBefore(now, beforeDate) || now.getTime() === beforeDate.getTime();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,18 +1,7 @@
|
||||
import { getWeekStartByLocale } from "weekstart";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { FirstWeekday } from "../../data/translation";
|
||||
|
||||
export const weekdays = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
] as const;
|
||||
|
||||
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
|
||||
|
||||
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
|
||||
if (locale.first_weekday === FirstWeekday.language) {
|
||||
@@ -23,12 +12,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
|
||||
}
|
||||
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
|
||||
}
|
||||
return weekdays.includes(locale.first_weekday)
|
||||
? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex)
|
||||
return WEEKDAYS_LONG.includes(locale.first_weekday)
|
||||
? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex)
|
||||
: 1;
|
||||
};
|
||||
|
||||
export const firstWeekday = (locale: FrontendLocaleData) => {
|
||||
const index = firstWeekdayIndex(locale);
|
||||
return weekdays[index];
|
||||
return WEEKDAYS_LONG[index];
|
||||
};
|
||||
|
||||
59
src/common/datetime/weekday.ts
Normal file
59
src/common/datetime/weekday.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export type WeekdayShort =
|
||||
| "sun"
|
||||
| "mon"
|
||||
| "tue"
|
||||
| "wed"
|
||||
| "thu"
|
||||
| "fri"
|
||||
| "sat";
|
||||
|
||||
export type WeekdayLong =
|
||||
| "sunday"
|
||||
| "monday"
|
||||
| "tuesday"
|
||||
| "wednesday"
|
||||
| "thursday"
|
||||
| "friday"
|
||||
| "saturday";
|
||||
|
||||
export const WEEKDAYS_SHORT = [
|
||||
"sun",
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
] as const satisfies readonly WeekdayShort[];
|
||||
|
||||
export const WEEKDAYS_LONG = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
] as const satisfies readonly WeekdayLong[];
|
||||
|
||||
export const WEEKDAY_MAP = {
|
||||
0: "sun",
|
||||
1: "mon",
|
||||
2: "tue",
|
||||
3: "wed",
|
||||
4: "thu",
|
||||
5: "fri",
|
||||
6: "sat",
|
||||
} as const satisfies Record<WeekdayIndex, WeekdayShort>;
|
||||
|
||||
export const WEEKDAY_SHORT_TO_LONG = {
|
||||
sun: "sunday",
|
||||
mon: "monday",
|
||||
tue: "tuesday",
|
||||
wed: "wednesday",
|
||||
thu: "thursday",
|
||||
fri: "friday",
|
||||
sat: "saturday",
|
||||
} as const satisfies Record<WeekdayShort, WeekdayLong>;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiAmpersand,
|
||||
mdiCalendarClock,
|
||||
mdiGateOr,
|
||||
mdiMapMarker,
|
||||
mdiNotEqualVariant,
|
||||
@@ -15,6 +16,7 @@ export const ICON_CONDITION: Record<Condition["condition"], string> = {
|
||||
numeric_state: mdiNumeric,
|
||||
state: mdiStateMachine,
|
||||
screen: mdiResponsive,
|
||||
time: mdiCalendarClock,
|
||||
user: mdiAccount,
|
||||
and: mdiAmpersand,
|
||||
not: mdiNotEqualVariant,
|
||||
|
||||
@@ -1,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":
|
||||
|
||||
@@ -25,6 +25,7 @@ import "./types/ha-card-condition-numeric_state";
|
||||
import "./types/ha-card-condition-or";
|
||||
import "./types/ha-card-condition-screen";
|
||||
import "./types/ha-card-condition-state";
|
||||
import "./types/ha-card-condition-time";
|
||||
import "./types/ha-card-condition-user";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
|
||||
@@ -33,6 +34,7 @@ const UI_CONDITION = [
|
||||
"numeric_state",
|
||||
"state",
|
||||
"screen",
|
||||
"time",
|
||||
"user",
|
||||
"and",
|
||||
"not",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import {
|
||||
literal,
|
||||
array,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
assert,
|
||||
enums,
|
||||
} from "superstruct";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import {
|
||||
WEEKDAY_SHORT_TO_LONG,
|
||||
WEEKDAYS_SHORT,
|
||||
} from "../../../../../common/datetime/weekday";
|
||||
import type { TimeCondition } from "../../../common/validate-condition";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../../components/ha-form/types";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
|
||||
const timeConditionStruct = object({
|
||||
condition: literal("time"),
|
||||
after: optional(string()),
|
||||
before: optional(string()),
|
||||
weekdays: optional(array(enums(WEEKDAYS_SHORT))),
|
||||
});
|
||||
|
||||
@customElement("ha-card-condition-time")
|
||||
export class HaCardConditionTime extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public condition!: TimeCondition;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
public static get defaultConfig(): TimeCondition {
|
||||
return { condition: "time", after: "08:00", before: "17:00" };
|
||||
}
|
||||
|
||||
protected static validateUIConfig(condition: TimeCondition) {
|
||||
return assert(condition, timeConditionStruct);
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
[
|
||||
{ name: "after", selector: { time: {} } },
|
||||
{ name: "before", selector: { time: {} } },
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)[]
|
||||
|
||||
@@ -11,18 +11,18 @@ import type { Lovelace } from "../lovelace/types";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
|
||||
const SECURITY_LOVELACE_CONFIG: LovelaceConfig = {
|
||||
const SAFETY_LOVELACE_CONFIG: LovelaceConfig = {
|
||||
views: [
|
||||
{
|
||||
strategy: {
|
||||
type: "security",
|
||||
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;
|
||||
@@ -69,7 +69,7 @@ class PanelSecurity extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
></ha-menu-button>
|
||||
`}
|
||||
<div class="main-title">${this.hass.localize("panel.security")}</div>
|
||||
<div class="main-title">${this.hass.localize("panel.safety")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,10 +86,10 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
private _setLovelace() {
|
||||
this._lovelace = {
|
||||
config: SECURITY_LOVELACE_CONFIG,
|
||||
rawConfig: SECURITY_LOVELACE_CONFIG,
|
||||
config: SAFETY_LOVELACE_CONFIG,
|
||||
rawConfig: SAFETY_LOVELACE_CONFIG,
|
||||
editMode: false,
|
||||
urlPath: "security",
|
||||
urlPath: "safety",
|
||||
mode: "generated",
|
||||
locale: this.hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
@@ -191,6 +191,6 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-panel-security": PanelSecurity;
|
||||
"ha-panel-safety": PanelSafety;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"media_browser": "Media",
|
||||
"profile": "Profile",
|
||||
"light": "Lights",
|
||||
"security": "Security",
|
||||
"safety": "Safety",
|
||||
"climate": "Climate"
|
||||
},
|
||||
"state": {
|
||||
@@ -6980,7 +6980,7 @@
|
||||
"lights": "Lights",
|
||||
"other_lights": "Other lights"
|
||||
},
|
||||
"security": {
|
||||
"safety": {
|
||||
"devices": "Devices",
|
||||
"other_devices": "Other devices"
|
||||
},
|
||||
@@ -7549,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",
|
||||
|
||||
209
test/common/datetime/check_time.test.ts
Normal file
209
test/common/datetime/check_time.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user