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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user