Skip to content

Commit 1aa6218

Browse files
committed
Add visibility condition for lovelace cards based on an entity's last updated time
1 parent ad589b3 commit 1aa6218

File tree

4 files changed

+219
-1
lines changed

4 files changed

+219
-1
lines changed

src/panels/lovelace/common/validate-condition.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import { listenMediaQuery } from "../../../common/dom/media_query";
44
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
55
import { UNKNOWN } from "../../../data/entity";
66
import type { HomeAssistant } from "../../../types";
7+
import { createDurationData } from "../../../common/datetime/create_duration_data";
8+
import type { HaDurationData } from "../../../components/ha-duration-input";
79

810
export type Condition =
11+
| LastUpdatedStateCondition
912
| NumericStateCondition
1013
| StateCondition
1114
| ScreenCondition
@@ -24,6 +27,13 @@ interface BaseCondition {
2427
condition: string;
2528
}
2629

30+
export interface LastUpdatedStateCondition extends BaseCondition {
31+
condition: "last_updated_state";
32+
entity?: string;
33+
within?: HaDurationData;
34+
after?: HaDurationData;
35+
}
36+
2737
export interface NumericStateCondition extends BaseCondition {
2838
condition: "numeric_state";
2939
entity?: string;
@@ -132,6 +142,62 @@ function checkStateNumericCondition(
132142
);
133143
}
134144

145+
function checkLastUpdatedStateCondition(
146+
condition: LastUpdatedStateCondition,
147+
hass: HomeAssistant
148+
) {
149+
const state_last_changed = (
150+
condition.entity ? hass.states[condition.entity] : undefined
151+
)?.last_changed;
152+
const within = condition.within;
153+
const after = condition.after;
154+
155+
function HaDurationData_to_milliseconds(
156+
duration: HaDurationData | undefined
157+
) {
158+
// This function should not be here, and surely something like this already exists?
159+
// If so, I can't find it :'(
160+
if (duration) {
161+
const days = duration.days || 0;
162+
let hours = duration.hours || 0;
163+
let minutes = duration.minutes || 0;
164+
let seconds = duration.seconds || 0;
165+
let milliseconds = duration.milliseconds || 0;
166+
167+
hours += days * 24;
168+
minutes += hours * 60;
169+
seconds += minutes * 60;
170+
milliseconds += seconds * 1000;
171+
172+
return milliseconds;
173+
}
174+
return 0; // this also is probably not good
175+
}
176+
177+
const withinDuration = HaDurationData_to_milliseconds(
178+
createDurationData(within)
179+
);
180+
const afterDuration = HaDurationData_to_milliseconds(
181+
createDurationData(after)
182+
);
183+
184+
const numericLastUpdatedState = new Date(state_last_changed).getTime();
185+
const numericWithin = numericLastUpdatedState + withinDuration;
186+
const numericAfter = numericLastUpdatedState + afterDuration;
187+
188+
if (isNaN(numericLastUpdatedState)) {
189+
return false;
190+
}
191+
192+
const now = new Date().getTime();
193+
return (
194+
(condition.within == null ||
195+
isNaN(numericWithin) ||
196+
now <= numericWithin) &&
197+
(condition.after == null || isNaN(numericAfter) || now > numericAfter)
198+
);
199+
}
200+
135201
function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) {
136202
return condition.media_query
137203
? matchMedia(condition.media_query).matches
@@ -173,6 +239,8 @@ export function checkConditionsMet(
173239
return checkUserCondition(c, hass);
174240
case "numeric_state":
175241
return checkStateNumericCondition(c, hass);
242+
case "last_updated_state":
243+
return checkLastUpdatedStateCondition(c, hass);
176244
case "and":
177245
return checkAndCondition(c, hass);
178246
case "or":
@@ -206,6 +274,22 @@ export function extractConditionEntityIds(
206274
) {
207275
entityIds.add(condition.below);
208276
}
277+
} else if (condition.condition === "last_updated_state") {
278+
if (condition.entity) {
279+
entityIds.add(condition.entity);
280+
}
281+
if (
282+
typeof condition.within === "string" &&
283+
isValidEntityId(condition.within)
284+
) {
285+
entityIds.add(condition.within);
286+
}
287+
if (
288+
typeof condition.after === "string" &&
289+
isValidEntityId(condition.after)
290+
) {
291+
entityIds.add(condition.after);
292+
}
209293
} else if (condition.condition === "state") {
210294
if (condition.entity) {
211295
entityIds.add(condition.entity);
@@ -257,6 +341,14 @@ function validateNumericStateCondition(condition: NumericStateCondition) {
257341
(condition.above != null || condition.below != null)
258342
);
259343
}
344+
function validateLastUpdatedStateCondition(
345+
condition: LastUpdatedStateCondition
346+
) {
347+
return (
348+
condition.entity != null &&
349+
(condition.within != null || condition.after != null)
350+
);
351+
}
260352
/**
261353
* Validate the conditions config for the UI
262354
* @param conditions conditions to apply
@@ -274,6 +366,8 @@ export function validateConditionalConfig(
274366
return validateUserCondition(c);
275367
case "numeric_state":
276368
return validateNumericStateCondition(c);
369+
case "last_updated_state":
370+
return validateLastUpdatedStateCondition(c);
277371
case "and":
278372
return validateAndCondition(c);
279373
case "or":
@@ -307,7 +401,8 @@ export function addEntityToCondition(
307401

308402
if (
309403
condition.condition === "state" ||
310-
condition.condition === "numeric_state"
404+
condition.condition === "numeric_state" ||
405+
condition.condition === "last_updated_state"
311406
) {
312407
return {
313408
entity: entityId,

src/panels/lovelace/editor/conditions/ha-card-conditions-editor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ import type { HaCardConditionEditor } from "./ha-card-condition-editor";
1919
import type { LovelaceConditionEditorConstructor } from "./types";
2020
import "./types/ha-card-condition-and";
2121
import "./types/ha-card-condition-numeric_state";
22+
import "./types/ha-card-condition-last_updated_state";
2223
import "./types/ha-card-condition-or";
2324
import "./types/ha-card-condition-screen";
2425
import "./types/ha-card-condition-state";
2526
import "./types/ha-card-condition-user";
2627

2728
const UI_CONDITION = [
2829
"numeric_state",
30+
"last_updated_state",
2931
"state",
3032
"screen",
3133
"user",
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { html, LitElement } from "lit";
2+
import { customElement, property } from "lit/decorators";
3+
import memoizeOne from "memoize-one";
4+
import {
5+
assert,
6+
literal,
7+
number,
8+
object,
9+
optional,
10+
string,
11+
union,
12+
} from "superstruct";
13+
import { fireEvent } from "../../../../../common/dom/fire_event";
14+
import "../../../../../components/ha-form/ha-form";
15+
import type {
16+
SchemaUnion,
17+
HaFormSchema,
18+
} from "../../../../../components/ha-form/types";
19+
import { forDictStruct } from "../../../../config/automation/structs";
20+
import type { HomeAssistant } from "../../../../../types";
21+
import type {
22+
LastUpdatedStateCondition,
23+
StateCondition,
24+
} from "../../../common/validate-condition";
25+
26+
const lastUpdatedStateConditionStruct = object({
27+
condition: literal("last_updated_state"),
28+
entity: optional(string()),
29+
within: optional(union([number(), string(), forDictStruct])),
30+
after: optional(union([number(), string(), forDictStruct])),
31+
});
32+
33+
@customElement("ha-card-condition-last_updated_state")
34+
export class HaCardConditionLastUpdatedState extends LitElement {
35+
@property({ attribute: false }) public hass!: HomeAssistant;
36+
37+
@property({ attribute: false }) public condition!: LastUpdatedStateCondition;
38+
39+
@property({ type: Boolean }) public disabled = false;
40+
41+
public static get defaultConfig(): LastUpdatedStateCondition {
42+
return { condition: "last_updated_state", entity: "" };
43+
}
44+
45+
protected static validateUIConfig(condition: StateCondition) {
46+
return assert(condition, lastUpdatedStateConditionStruct);
47+
}
48+
49+
private _schema = memoizeOne(
50+
() =>
51+
[
52+
{ name: "entity", selector: { entity: {} } },
53+
{
54+
name: "",
55+
type: "grid",
56+
schema: [
57+
{
58+
name: "within",
59+
selector: {
60+
duration: {},
61+
},
62+
},
63+
{
64+
name: "after",
65+
selector: {
66+
duration: {},
67+
},
68+
},
69+
],
70+
},
71+
] as const satisfies readonly HaFormSchema[]
72+
);
73+
74+
protected render() {
75+
const stateObj = this.condition.entity
76+
? this.hass.states[this.condition.entity]
77+
: undefined;
78+
79+
return html`
80+
<ha-form
81+
.hass=${this.hass}
82+
.data=${this.condition}
83+
.schema=${this._schema(stateObj)}
84+
.disabled=${this.disabled}
85+
@value-changed=${this._valueChanged}
86+
.computeLabel=${this._computeLabelCallback}
87+
></ha-form>
88+
`;
89+
}
90+
91+
private _valueChanged(ev: CustomEvent): void {
92+
ev.stopPropagation();
93+
const condition = ev.detail.value as LastUpdatedStateCondition;
94+
fireEvent(this, "value-changed", { value: condition });
95+
}
96+
97+
private _computeLabelCallback = (
98+
schema: SchemaUnion<ReturnType<typeof this._schema>>
99+
): string => {
100+
switch (schema.name) {
101+
case "entity":
102+
return this.hass.localize("ui.components.entity.entity-picker.entity");
103+
case "within":
104+
case "after":
105+
return this.hass.localize(
106+
"ui.panel.config.automation.editor.triggers.type.state.for"
107+
);
108+
default:
109+
return "";
110+
}
111+
};
112+
}
113+
114+
declare global {
115+
interface HTMLElementTagNameMap {
116+
"ha-card-condition-last_updated_state": HaCardConditionLastUpdatedState;
117+
}
118+
}

src/translations/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7136,6 +7136,9 @@
71367136
"above": "Above",
71377137
"below": "Below"
71387138
},
7139+
"last_updated_state": {
7140+
"label": "Entity updated"
7141+
},
71397142
"screen": {
71407143
"label": "Screen",
71417144
"breakpoints": "Screen sizes",

0 commit comments

Comments
 (0)