fix: Clear the initialized condition state when the card is not usable (#2646)

The `initialized` state (used in conditions/triggers) was written once
and never cleared, so it meant "has this card ever been initialized"
while everything reading it took it as "is this card usable now". Home
Assistant takes a card off the page and puts it back whenever its
dashboard tab is left and returned to, so the card initialized again
while the state claimed it was initialized throughout: `trigger:
initialized` fired once per card rather than once per startup, and
automations were dropped in between.

The card lifecycle is now an explicit state machine (`SessionManager`),
the only writer of `initialized`, which separates a card that is
starting up from one initializing part of itself again while it runs. A
new `ever` parameter (conditions/triggers) selects the old latched
behaviour.

`remote_control` uses that parameter to keep its two camera priorities
correct under repeated starts. With `camera_priority: entity` the card
now re-reads the entity every time it starts, so a camera selected while
the card was away is picked up on return. With `camera_priority: card`
the card writes the entity on its first start only, unchanged, since
repeating that write would overwrite a camera the user had selected.

Closes: #2642


BREAKING CHANGE: `condition: initialized` is now `false` whenever the
card is not usable, and `trigger: initialized` fires each time the card
starts up rather than only the first time. Set `ever: true` on either to
keep the previous behaviour.
This commit is contained in:
Dermot Duffy
2026-08-02 12:52:58 -07:00
committed by GitHub
parent e590f72783
commit 17146c8ca6
44 changed files with 1967 additions and 654 deletions
@@ -167,12 +167,35 @@ describe('IssueManager', () => {
const hass = createHASS();
conditionStateManager.setState({ hass });
conditionStateManager.setState({ initialized: true });
conditionStateManager.setState({ initialized: true, everInitialized: true });
await flushPromises();
expect(detectStatic).toHaveBeenCalledWith(hass);
});
it('should run static detection once regardless how often the card initializes', async () => {
const api = createCardAPI();
const conditionStateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
const manager = new IssueManager(api);
const detectStatic = vi.fn().mockResolvedValue(undefined);
const issue = createIssue('legacy_resource', { detectStatic });
manager.addIssue(issue);
conditionStateManager.setState({ hass: createHASS() });
conditionStateManager.setState({ initialized: true, everInitialized: true });
// The card gets disconnected/reconnected as it does on a dashboard tab
// change. `everInitialized` is unchanged by that, so detection does not
// run a second time.
conditionStateManager.setState({ initialized: false });
conditionStateManager.setState({ initialized: true, everInitialized: true });
await flushPromises();
expect(detectStatic).toHaveBeenCalledTimes(1);
});
it('should not run static detection when hass is unset', () => {
const api = createCardAPI();
const conditionStateManager = new ConditionStateManager();
@@ -183,7 +206,7 @@ describe('IssueManager', () => {
const issue = createIssue('legacy_resource', { detectStatic });
manager.addIssue(issue);
conditionStateManager.setState({ initialized: true });
conditionStateManager.setState({ initialized: true, everInitialized: true });
expect(detectStatic).not.toHaveBeenCalled();
});
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest';
import { MountedCard } from '../../../browser/mounted-card';
import {
createStillCameraHASS,
createStillImageCameraConfig,
createStillImageCardConfig,
getBlockNotificationText,
} from '../../../browser/test-utils';
const STARTED_MESSAGE = 'card-started';
const INIT_FAILED_ISSUE_HEADING = 'Initialization failed';
// A camera Home Assistant has never heard of, which is what a typo in a
// configuration looks like and the earliest thing a camera can fail on.
const MISSING_CAMERA_ENTITY = 'camera.missing';
const getStartedMessages = (card: MountedCard): string[] =>
card.console.getMessages('info').filter((message) => message === STARTED_MESSAGE);
const getReportedInitializationFailures = (card: MountedCard): string[] =>
card.console
.getMessages('warn')
.filter((message) => message.includes('[issue=initialization]'));
const waitForInitializationFailures = async (card: MountedCard): Promise<void> =>
await vi.waitFor(() =>
expect(getBlockNotificationText(card.card)).toContain(INIT_FAILED_ISSUE_HEADING),
);
/**
* A card whose camera cannot be initialized. Giving Home Assistant the entity
* is what makes it initializable, which a test does with `setEntityState`.
*/
const mountBrokenCard = async (): Promise<MountedCard> =>
await MountedCard.create(
createStillImageCardConfig({
cameras: [createStillImageCameraConfig(MISSING_CAMERA_ENTITY)],
// Automatic retries switched off, so any recovery below can only be the
// retry control being used.
view: { issues: { retry_seconds: 0 } },
automations: [
{
triggers: [{ trigger: 'initialized' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'log',
message: STARTED_MESSAGE,
},
],
},
],
}),
createStillCameraHASS(),
);
describe('InitializationIssue', () => {
it('should report a card that could not be started, and start it on a retry', async () => {
const card = await mountBrokenCard();
await waitForInitializationFailures(card);
// A card that failed to start has not started, whatever it is showing.
expect(getStartedMessages(card)).toHaveLength(0);
// The camera the user meant now exists. Nothing recovers on its own from
// here: automatic retries are off, and a card showing a full-card issue
// refuses to start.
card.setEntityState(MISSING_CAMERA_ENTITY, 'idle');
await card.clickControl('Retry');
await vi.waitFor(() => expect(getStartedMessages(card)).toHaveLength(1));
await card.events.waitForFirst('advanced-camera-card:media:loaded');
// Starting is not the same as the issue leaving the screen, since a
// full-card issue hides the views behind it.
expect(getBlockNotificationText(card.card)).not.toContain(INIT_FAILED_ISSUE_HEADING);
});
it('should keep reporting a card whose retry fails again', async () => {
const card = await mountBrokenCard();
await waitForInitializationFailures(card);
expect(getReportedInitializationFailures(card)).toHaveLength(1);
await card.clickControl('Retry');
// The camera still does not exist, so the retry fails too. A second failure
// has to be raised rather than leaving the card looking as though the retry
// had worked.
await vi.waitFor(() =>
expect(getReportedInitializationFailures(card)).toHaveLength(2),
);
await waitForInitializationFailures(card);
expect(getStartedMessages(card)).toHaveLength(0);
});
});
@@ -6,11 +6,11 @@ import type { InternalCallbackActionConfig } from '../../../../src/config/schema
import { createCardAPI } from '../../../test-utils';
describe('InitializationIssue', () => {
const createAPI = (isInitializedMandatory = false): CardController => {
const createAPI = (areMandatoryAspectsInitialized = false): CardController => {
const api = createCardAPI();
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
isInitializedMandatory,
);
vi.mocked(
api.getInitializationManager().areMandatoryAspectsInitialized,
).mockReturnValue(areMandatoryAspectsInitialized);
return api;
};
@@ -190,8 +190,14 @@ describe('InitializationIssue', () => {
expect(result).toBe(false);
expect(issue.hasIssue()).toBe(false);
expect(api.getInitializationManager().uninitializeMandatory).toHaveBeenCalled();
expect(
api.getInitializationManager().invalidateMandatoryAspects,
).toHaveBeenCalled();
expect(api.getCameraManager().destroy).toHaveBeenCalled();
// What follows is a fresh attempt at starting the card, so the previous
// session is ended here.
expect(api.getInitializationManager().getSessionManager().end).toHaveBeenCalled();
});
it('should be a no-op while a retry is already in flight', () => {
@@ -199,14 +205,14 @@ describe('InitializationIssue', () => {
const issue = new InitializationIssue(api);
issue.trigger({ error: new Error('init failed') });
issue.retry();
vi.mocked(api.getInitializationManager().uninitializeMandatory).mockClear();
vi.mocked(api.getInitializationManager().invalidateMandatoryAspects).mockClear();
vi.mocked(api.getCameraManager().destroy).mockClear();
const result = issue.retry();
expect(result).toBe(false);
expect(
api.getInitializationManager().uninitializeMandatory,
api.getInitializationManager().invalidateMandatoryAspects,
).not.toHaveBeenCalled();
expect(api.getCameraManager().destroy).not.toHaveBeenCalled();
});
@@ -16,6 +16,7 @@ import {
createUnansweredMediaURL,
deepQuery,
deepQueryAll,
getBlockNotificationText,
isLiveMediaShowing,
STILL_CAMERA_ENTITY,
} from '../../../browser/test-utils';
@@ -102,10 +103,6 @@ const mountCardDualCameras = async (): Promise<MountedCard> => {
return card;
};
const getNotificationText = (card: MountedCard): string =>
deepQuery(card.card, 'advanced-camera-card-notification-block')?.shadowRoot
?.textContent ?? '';
beforeEach(() => {
vi.useFakeTimers();
});
@@ -160,8 +157,8 @@ describe('MediaUnavailableIssue', () => {
// Which camera, not just that something is wrong: with several on screen a
// report that does not say which one leaves the user to guess.
expect(getNotificationText(card)).toContain('Camera entity unavailable');
expect(getNotificationText(card)).toContain(SECOND_CAMERA_ENTITY);
expect(getBlockNotificationText(card.card)).toContain('Camera entity unavailable');
expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY);
});
it('should leave the cameras that are still working alone', async () => {
@@ -190,8 +187,8 @@ describe('MediaUnavailableIssue', () => {
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
expect(getNotificationText(card)).toContain('Could not load image');
expect(getNotificationText(card)).toContain(STILL_CAMERA_ENTITY);
expect(getBlockNotificationText(card.card)).toContain('Could not load image');
expect(getBlockNotificationText(card.card)).toContain(STILL_CAMERA_ENTITY);
});
it('should clear the report once the camera delivers media again', async () => {
@@ -369,7 +366,7 @@ describe('MediaUnavailableIssue', () => {
// Stalled rather than failed: the picture on screen is real but frozen, and
// saying so is the difference between "this is old" and "this is broken".
expect(getNotificationText(card)).toContain('Stream stalled');
expect(getBlockNotificationText(card.card)).toContain('Stream stalled');
});
it('should report a player that reports a playback error', async () => {
@@ -383,7 +380,9 @@ describe('MediaUnavailableIssue', () => {
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
expect(getNotificationText(card)).toContain('Could not get camera endpoint');
expect(getBlockNotificationText(card.card)).toContain(
'Could not get camera endpoint',
);
await card.clickControl(REPORT_TITLE);
await card.waitForSelector('advanced-camera-card-notification');