Skip to content

Add visibility condition for lovelace cards based on an entity's last updated time #25864

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion src/panels/lovelace/common/validate-condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { listenMediaQuery } from "../../../common/dom/media_query";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { UNKNOWN } from "../../../data/entity";
import type { HomeAssistant } from "../../../types";
import { createDurationData } from "../../../common/datetime/create_duration_data";
import type { HaDurationData } from "../../../components/ha-duration-input";

export type Condition =
| LastUpdatedStateCondition
| NumericStateCondition
| StateCondition
| ScreenCondition
Expand All @@ -24,6 +27,13 @@ interface BaseCondition {
condition: string;
}

export interface LastUpdatedStateCondition extends BaseCondition {
condition: "last_updated_state";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are mixing last updated and last changed, these are 2 different things.
If an entity is updated, it doesn't necessarily have to have changed the state, it could still be in the same state as before the update. It just fetched updated data.

entity?: string;
within?: HaDurationData;
after?: HaDurationData;
}

export interface NumericStateCondition extends BaseCondition {
condition: "numeric_state";
entity?: string;
Expand Down Expand Up @@ -132,6 +142,62 @@ function checkStateNumericCondition(
);
}

function checkLastUpdatedStateCondition(
condition: LastUpdatedStateCondition,
hass: HomeAssistant
) {
const state_last_changed = (
condition.entity ? hass.states[condition.entity] : undefined
)?.last_changed;
const within = condition.within;
const after = condition.after;

function HaDurationData_to_milliseconds(
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned: I feel like this function must exist somewhere, but if it doesn't, this is not the right place for it. Any tips for where it should go?

duration: HaDurationData | undefined
) {
// This function should not be here, and surely something like this already exists?
// If so, I can't find it :'(
if (duration) {
const days = duration.days || 0;
let hours = duration.hours || 0;
let minutes = duration.minutes || 0;
let seconds = duration.seconds || 0;
let milliseconds = duration.milliseconds || 0;

hours += days * 24;
minutes += hours * 60;
seconds += minutes * 60;
milliseconds += seconds * 1000;

return milliseconds;
}
return 0; // this also is probably not good
}

const withinDuration = HaDurationData_to_milliseconds(
createDurationData(within)
);
const afterDuration = HaDurationData_to_milliseconds(
createDurationData(after)
);

const numericLastUpdatedState = new Date(state_last_changed).getTime();
const numericWithin = numericLastUpdatedState + withinDuration;
const numericAfter = numericLastUpdatedState + afterDuration;

if (isNaN(numericLastUpdatedState)) {
return false;
}

const now = new Date().getTime();
return (
(condition.within == null ||
isNaN(numericWithin) ||
now <= numericWithin) &&
(condition.after == null || isNaN(numericAfter) || now > numericAfter)
);
}

function checkScreenCondition(condition: ScreenCondition, _: HomeAssistant) {
return condition.media_query
? matchMedia(condition.media_query).matches
Expand Down Expand Up @@ -173,6 +239,8 @@ export function checkConditionsMet(
return checkUserCondition(c, hass);
case "numeric_state":
return checkStateNumericCondition(c, hass);
case "last_updated_state":
return checkLastUpdatedStateCondition(c, hass);
case "and":
return checkAndCondition(c, hass);
case "or":
Expand Down Expand Up @@ -206,6 +274,22 @@ export function extractConditionEntityIds(
) {
entityIds.add(condition.below);
}
} else if (condition.condition === "last_updated_state") {
if (condition.entity) {
entityIds.add(condition.entity);
}
if (
typeof condition.within === "string" &&
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really sure if this chunk is right..., I copied it from the NumericState condition

isValidEntityId(condition.within)
) {
entityIds.add(condition.within);
}
if (
typeof condition.after === "string" &&
isValidEntityId(condition.after)
) {
entityIds.add(condition.after);
}
} else if (condition.condition === "state") {
if (condition.entity) {
entityIds.add(condition.entity);
Expand Down Expand Up @@ -257,6 +341,14 @@ function validateNumericStateCondition(condition: NumericStateCondition) {
(condition.above != null || condition.below != null)
);
}
function validateLastUpdatedStateCondition(
condition: LastUpdatedStateCondition
) {
return (
condition.entity != null &&
(condition.within != null || condition.after != null)
);
}
/**
* Validate the conditions config for the UI
* @param conditions conditions to apply
Expand All @@ -274,6 +366,8 @@ export function validateConditionalConfig(
return validateUserCondition(c);
case "numeric_state":
return validateNumericStateCondition(c);
case "last_updated_state":
return validateLastUpdatedStateCondition(c);
case "and":
return validateAndCondition(c);
case "or":
Expand Down Expand Up @@ -307,7 +401,8 @@ export function addEntityToCondition(

if (
condition.condition === "state" ||
condition.condition === "numeric_state"
condition.condition === "numeric_state" ||
condition.condition === "last_updated_state"
) {
return {
entity: entityId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import type { HaCardConditionEditor } from "./ha-card-condition-editor";
import type { LovelaceConditionEditorConstructor } from "./types";
import "./types/ha-card-condition-and";
import "./types/ha-card-condition-numeric_state";
import "./types/ha-card-condition-last_updated_state";
import "./types/ha-card-condition-or";
import "./types/ha-card-condition-screen";
import "./types/ha-card-condition-state";
import "./types/ha-card-condition-user";

const UI_CONDITION = [
"numeric_state",
"last_updated_state",
"state",
"screen",
"user",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
assert,
literal,
number,
object,
optional,
string,
union,
} from "superstruct";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import type {
SchemaUnion,
HaFormSchema,
} from "../../../../../components/ha-form/types";
import { forDictStruct } from "../../../../config/automation/structs";
import type { HomeAssistant } from "../../../../../types";
import type {
LastUpdatedStateCondition,
StateCondition,
} from "../../../common/validate-condition";

const lastUpdatedStateConditionStruct = object({
condition: literal("last_updated_state"),
entity: optional(string()),
within: optional(union([number(), string(), forDictStruct])),
after: optional(union([number(), string(), forDictStruct])),
});

@customElement("ha-card-condition-last_updated_state")
export class HaCardConditionLastUpdatedState extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;

@property({ attribute: false }) public condition!: LastUpdatedStateCondition;

@property({ type: Boolean }) public disabled = false;

public static get defaultConfig(): LastUpdatedStateCondition {
return { condition: "last_updated_state", entity: "" };
}

protected static validateUIConfig(condition: StateCondition) {
return assert(condition, lastUpdatedStateConditionStruct);
}

private _schema = memoizeOne(
() =>
[
{ name: "entity", selector: { entity: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "within",
selector: {
duration: {},
},
},
{
name: "after",
selector: {
duration: {},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
);

protected render() {
const stateObj = this.condition.entity
? this.hass.states[this.condition.entity]
: undefined;

return html`
<ha-form
.hass=${this.hass}
.data=${this.condition}
.schema=${this._schema(stateObj)}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
></ha-form>
`;
}

private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const condition = ev.detail.value as LastUpdatedStateCondition;
fireEvent(this, "value-changed", { value: condition });
}

private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => {
switch (schema.name) {
case "entity":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "within":
case "after":
return this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.state.for"
);
default:
return "";
}
};
}

declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-last_updated_state": HaCardConditionLastUpdatedState;
}
}
3 changes: 3 additions & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7136,6 +7136,9 @@
"above": "Above",
"below": "Below"
},
"last_updated_state": {
"label": "Entity updated"
Copy link
Contributor

@NoRi2909 NoRi2909 Jun 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand this is the name of the condition. In that case I'd use "Last entity update" here instead as this popup menu contains only nouns so far.

Suggested change
"label": "Entity updated"
"label": "Last entity update"

},
"screen": {
"label": "Screen",
"breakpoints": "Screen sizes",
Expand Down