feat: Add hardened error handling and retries (#2451)

- Closes #1830
 - Closes #2099
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 4bc787e2b7
commit 47bcce93d3
182 changed files with 7877 additions and 4043 deletions
@@ -45,7 +45,8 @@ export class ActionsManager implements ActionsExecutor {
*/
public getMergedActions(): ActionsConfig {
const view = this._api.getViewManager().getView();
if (this._api.getMessageManager().hasMessage()) {
// Don't apply view actions when there are full-card/serious issues.
if (this._api.getIssueManager().getStateManager().hasFullCardIssue()) {
return {};
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { MediaDetailsController } from '../../../components-lib/media/details-controller';
import { MediaNotificationController } from '../../../components-lib/media/notification-controller';
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import { ViewItemClassifier } from '../../../view/item-classifier';
import { CardActionsAPI } from '../../types';
@@ -13,11 +13,11 @@ export class InfoAction extends AdvancedCameraCardAction<GeneralActionConfig> {
return;
}
const controller = new MediaDetailsController();
controller.calculate(api.getCameraManager(), item);
const notificationController = new MediaNotificationController();
notificationController.calculate(api.getCameraManager(), item);
api.getNotificationManager().setNotification(
controller.getNotification({
notificationController.getNotification({
hass: api.getHASSManager().getHASS() ?? undefined,
viewItemManager: api.getViewItemManager(),
viewManagerEpoch: api.getViewManager().getEpoch(),
+7 -4
View File
@@ -50,7 +50,7 @@ export class AutomationsManager {
!this._api.getInitializationManager().isInitializedMandatory() ||
// Never execute automations if there's an error (as our automation loop
// avoidance -- which shows as an error -- would not work!).
this._api.getMessageManager().hasErrorMessage()
this._api.getIssueManager().getStateManager().hasFullCardIssue()
) {
return;
}
@@ -66,9 +66,12 @@ export class AutomationsManager {
++this._nestedAutomationExecutions;
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.too_many_automations'),
this._api.getNotificationManager().setNotification({
heading: {
text: localize('error.too_many_automations'),
icon: 'mdi:alert',
severity: 'high',
},
});
return;
}
+15 -4
View File
@@ -70,7 +70,6 @@ export class CardElementManager {
this._api.getMediaLoadedInfoManager().initialize();
this._api.getMicrophoneManager().initialize();
this._api.getPIPManager().initialize();
this._api.getProblemManager().initialize();
this._api.getKeyboardStateManager().initialize();
// These initializers are called when the config is updated, but on initial
@@ -159,6 +158,8 @@ export class CardElementManager {
// disconnected/reconnected when dashboard 'tab' changes happen within HA.
this._api.getQueryStringManager().requestExecution();
this._api.getIssueManager().resume();
// Make sure reconnections call the initialization code.
this._element.requestUpdate();
}
@@ -168,20 +169,30 @@ export class CardElementManager {
setOrRemoveAttribute(this._element, false, 'tabindex');
setOrRemoveAttribute(this._element, false, 'casted');
// Suspend issue evaluation so state changes below (e.g. clearing
// mediaLoadedInfo) don't arm timers while the card is detached. Issue state
// is preserved; evaluation resumes via resume() on reconnect.
this._api.getIssueManager().suspend();
// When the dashboard 'tab' is changed, the media is effectively unloaded.
this._api.getMediaLoadedInfoManager().clear();
this._api.getFullscreenManager().disconnect();
this._api.getPIPManager().uninitialize();
this._api.getProblemManager().uninitialize();
this._api.getKeyboardStateManager().uninitialize();
this._api.getActionsManager().uninitialize();
this._api.getInteractionManager().uninitialize();
this._api.getDefaultManager().uninitialize();
this._api.getHASSManager().getStateWatcher()?.unsubscribe(this.update);
// Uninitialize cameras to cause them to reinitialize on
// Uninitialize cameras and triggers to cause them to reinitialize on
// reconnection, to ensure the state subscription/unsubscription works
// correctly for triggers.
// correctly and triggers that changed while detached are picked up.
// Reset trigger state first to stop stale timers and clear condition state.
this._api.getTriggersManager().reset();
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
this._api
.getInitializationManager()
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
this._api.getCameraManager().destroy();
this._element.removeEventListener(
+22 -19
View File
@@ -1,4 +1,5 @@
import { isEqual } from 'lodash-es';
import { ConfigParseError } from './error.js';
import { copyConfig, isConfigUpgradeable } from '../../config/management.js';
import { setProfiles } from '../../config/profiles/set-profiles.js';
import {
@@ -56,7 +57,7 @@ export class ConfigManager {
public setConfig(inputConfig?: RawAdvancedCameraCardConfig): void {
if (!inputConfig) {
throw new Error(localize('error.invalid_configuration'));
throw new ConfigParseError(localize('error.invalid_configuration'));
}
const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig);
@@ -67,7 +68,7 @@ export class ConfigManager {
if (isConfigUpgradeable(inputConfig)) {
upgradeMessage = `${localize('error.upgrade_available')}. `;
}
throw new Error(
throw new ConfigParseError(
upgradeMessage +
`${localize('error.invalid_configuration')}: ` +
(hint ?? localize('error.invalid_configuration_no_hint')),
@@ -109,7 +110,6 @@ export class ConfigManager {
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
this._api.getViewManager().reset();
this._api.getMessageManager().reset();
this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
this._processOverrideConfig();
@@ -118,11 +118,28 @@ export class ConfigManager {
}
private _processOverrideConfig(): void {
const overriddenConfig = this._getOverriddenConfig();
/* istanbul ignore if: No (current) way to reach this code -- @preserve */
if (!this._config) {
return;
}
let overriddenConfig: AdvancedCameraCardConfig;
try {
overriddenConfig = this._overridesManager.getConfig(this._config);
} catch (ev) {
this._api.getIssueManager().trigger('config_error', { error: ev });
return;
}
// Clear any prior config error on success. This method runs both from
// setConfig() and from condition-change-driven override recomputations;
// resetting here ensures a transient override error is cleared when
// conditions change to produce a valid config again.
this._api.getIssueManager().reset('config_error');
// Save on Lit re-rendering costs by only updating the configuration if it
// actually changes.
if (!overriddenConfig || isEqual(overriddenConfig, this._overriddenConfig)) {
if (isEqual(overriddenConfig, this._overriddenConfig)) {
return;
}
@@ -189,20 +206,6 @@ export class ConfigManager {
/* async */ this._initializeBackgroundAndUpdate(previousConfig);
}
private _getOverriddenConfig(): AdvancedCameraCardConfig | null {
/* istanbul ignore if: No (current) way to reach this code -- @preserve */
if (!this._config) {
return null;
}
try {
return this._overridesManager.getConfig(this._config);
} catch (ev) {
this._api.getMessageManager().setErrorIfHigherPriority(ev);
return null;
}
}
/**
* Initialize config dependent items in the background. For items that the
* card hard requires, use InitializationManager instead.
+5
View File
@@ -0,0 +1,5 @@
import { AdvancedCameraCardError } from '../../types.js';
class ConfigError extends AdvancedCameraCardError {}
export class ConfigParseError extends ConfigError {}
+1 -1
View File
@@ -7,6 +7,6 @@ export const setFoldersFromConfig = (api: CardConfigLoaderAPI): void => {
.getFoldersManager()
.addFolders(api.getConfigManager().getConfig()?.folders ?? []);
} catch (ev) {
api.getMessageManager().setErrorIfHigherPriority(ev);
api.getIssueManager().trigger('config_error', { error: ev });
}
};
@@ -1,4 +1,4 @@
import { merge } from 'lodash-es';
import { get, merge } from 'lodash-es';
import { ConditionsManager } from '../../conditions/conditions-manager';
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
import {
@@ -92,9 +92,24 @@ export class OverridesManager {
const parseResult = advancedCameraCardConfigSchema.safeParse(output);
if (!parseResult.success) {
// Surface one co-located failure object per Zod issue — path, the value
// the user actually wrote, and the most informative "expected" field for
// this issue code. Avoids dumping the full merged config (which is
// mostly schema defaults the user never wrote) and keeps the reader from
// cross-referencing two parallel arrays.
const failures = parseResult.error.issues.map((issue) => ({
path: issue.path.map(String).join('.'),
received: get(output, issue.path),
expected:
issue.code === 'invalid_value'
? issue.values
: issue.code === 'invalid_type'
? issue.expected
: issue.message,
}));
throw new OverrideConfigurationError(
localize('error.invalid_configuration_override'),
[parseResult.error.issues, output],
{ failures },
);
}
return parseResult.data;
+7 -14
View File
@@ -29,11 +29,11 @@ import { InteractionManager } from './interaction-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
import { NotificationManager } from './notification-manager';
import { PIPManager } from './pip-manager';
import { ProblemManager } from './problems/manager';
import { createIssueManager } from './issues/factory';
import { IssueManager } from './issues/issue-manager';
import { QueryStringManager } from './query-string-manager';
import { StatusBarItemManager } from './status-bar-item-manager';
import { StyleManager } from './style-manager';
@@ -57,11 +57,10 @@ import {
CardKeyboardStateAPI,
CardMediaLoadedAPI,
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardNotificationAPI,
CardPIPAPI,
CardProblemAPI,
CardIssueManagerAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
@@ -85,13 +84,12 @@ export class CardController
CardFullscreenAPI,
CardHASSAPI,
CardPIPAPI,
CardProblemAPI,
CardIssueManagerAPI,
CardInitializerAPI,
CardInteractionAPI,
CardKeyboardStateAPI,
CardMediaLoadedAPI,
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardNotificationAPI,
CardQueryStringAPI,
@@ -126,11 +124,10 @@ export class CardController
private _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
private _mediaPlayerManager = new MediaPlayerManager(this);
private _messageManager = new MessageManager(this);
private _microphoneManager = new MicrophoneManager(this);
private _notificationManager = new NotificationManager(this);
private _pipManager = new PIPManager(this);
private _problemManager = new ProblemManager(this);
private _issueManager = createIssueManager(this);
private _queryStringManager = new QueryStringManager(this);
private _statusBarItemManager = new StatusBarItemManager(this);
private _styleManager = new StyleManager(this);
@@ -245,10 +242,6 @@ export class CardController
return this._mediaPlayerManager;
}
public getMessageManager(): MessageManager {
return this._messageManager;
}
public getMicrophoneManager(): MicrophoneManager {
return this._microphoneManager;
}
@@ -264,8 +257,8 @@ export class CardController
return this._pipManager;
}
public getProblemManager(): ProblemManager {
return this._problemManager;
public getIssueManager(): IssueManager {
return this._issueManager;
}
public getQueryStringManager(): QueryStringManager {
+28 -34
View File
@@ -1,6 +1,5 @@
import { hasHAConnectionStateChanged } from '../../ha/has-hass-connection-changed';
import { STATE_RUNNING } from 'home-assistant-js-websocket';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { log } from '../../utils/debug';
import { InitializationAspect } from '../initialization-manager';
import { CardHASSAPI } from '../types';
@@ -28,39 +27,30 @@ export class HASSManager {
}
public setHASS(hass?: HomeAssistant | null): void {
if (hasHAConnectionStateChanged(this._hass, hass)) {
if (!hass?.connected) {
this._api.getMessageManager().setMessageIfHigherPriority({
message: localize('error.reconnecting'),
icon: 'mdi:lan-disconnect',
type: 'connection',
dotdotdot: true,
});
} else {
this._api.getMessageManager().resetType('connection');
// When HA transitions from "not ready" to "ready" (WebSocket reconnected
// AND all integrations finished loading), reinitialize cameras and the
// view. This is necessary because event subscriptions (e.g. Frigate
// WebSocket subscriptions via hass.connection.subscribeMessage) are tied to
// the old connection and are lost when it drops. Without reinitialization,
// triggers and thumbnail updates stop working.
//
// We deliberately wait for hass.config.state === STATE_RUNNING rather than
// just hass.connected, because HA exposes the WebSocket before integrations
// have finished loading. Triggering re-init too early would race against
// integration startup and fail with "Unknown command" on
// integration-specific WS calls.
if (this._hass && !this._isReady(this._hass) && this._isReady(hass)) {
log(
this._api.getConfigManager().getCardWideConfig(),
'Advanced Camera Card: HA fully ready, reinitializing...',
);
// When the HA WebSocket connection is restored after a drop,
// reinitialize cameras and the view. This is necessary because
// event subscriptions (e.g. Frigate WebSocket subscriptions via
// hass.connection.subscribeMessage) are tied to the old connection
// and are lost when it drops. Without reinitialization, triggers
// and thumbnail updates stop working.
if (this._hass) {
log(
this._api.getConfigManager().getCardWideConfig(),
'Advanced Camera Card: HA connection restored, reinitializing...',
);
this._api
.getInitializationManager()
.uninitialize(InitializationAspect.CAMERAS);
this._api.getCameraManager().destroy();
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
this._api
.getInitializationManager()
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
}
}
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
this._api.getCameraManager().destroy();
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
this._api
.getInitializationManager()
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
}
if (!hass) {
@@ -79,4 +69,8 @@ export class HASSManager {
this._stateWatcher.setHASS(oldHass, hass);
}
private _isReady(hass?: HomeAssistant | null): boolean {
return !!hass?.connected && hass.config?.state === STATE_RUNNING;
}
}
+93 -64
View File
@@ -1,6 +1,8 @@
import { STATE_RUNNING } from 'home-assistant-js-websocket';
import PQueue from 'p-queue';
import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements';
import { loadLanguages } from '../localize/localize';
import { errorToConsole } from '../utils/basic';
import { Initializer } from '../utils/initializer/initializer';
import { CardInitializerAPI } from './types';
@@ -9,7 +11,6 @@ export enum InitializationAspect {
SIDE_LOAD_ELEMENTS = 'side-load-elements',
CAMERAS = 'cameras',
MICROPHONE_CONNECT = 'microphone-connect',
PROBLEMS = 'problems',
VIEW = 'view',
// The initial triggering must happen after both the config is set (and
@@ -51,10 +52,6 @@ export class InitializationManager {
return this._initializer.isInitialized(aspect);
}
public isInitializedBackground(): boolean {
return this._initializer.isInitialized(InitializationAspect.PROBLEMS);
}
public isInitializedMandatory(): boolean {
const config = this._api.getConfigManager().getConfig();
if (!config) {
@@ -87,14 +84,28 @@ export class InitializationManager {
return;
}
// Wait until HA has finished loading integrations before attempting init.
// Otherwise integration-specific WS calls (e.g. Frigate event
// subscriptions) fail with "Unknown command" against a half-loaded HA. The
// HASSManager will trigger another init attempt as soon as
// hass.config.state transitions to RUNNING.
if (hass.config?.state !== STATE_RUNNING) {
return;
}
if (
!(await this._initializer.initializeMultipleIfNecessary({
// Caution: Ensure nothing in this set of initializers requires
// config or languages since they will not yet have been initialized.
[InitializationAspect.LANGUAGES]: async () => await loadLanguages(hass),
[InitializationAspect.SIDE_LOAD_ELEMENTS]: async () =>
await sideLoadHomeAssistantElements(),
}))
!(await this._tryInitialize(() =>
this._initializer.initializeMultipleIfNecessary({
// Caution: Ensure nothing in this set of initializers requires
// config or languages since they will not yet have been initialized.
[InitializationAspect.LANGUAGES]: async () => {
await loadLanguages(hass);
},
[InitializationAspect.SIDE_LOAD_ELEMENTS]: async () => {
await sideLoadHomeAssistantElements();
},
}),
))
) {
return;
}
@@ -105,52 +116,56 @@ export class InitializationManager {
}
if (
!(await this._initializer.initializeMultipleIfNecessary({
[InitializationAspect.CAMERAS]: async () => {
// Recreate the camera manager to guarantee an immediate re-render.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1811
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1769
this._api.createCameraManager();
return await this._api.getCameraManager().initializeCamerasFromConfig();
},
// Connecting the microphone (if configured) is considered mandatory to
// avoid issues with some cameras that only allow 2-way audio on the
// first stream initialized.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1235
...(this._api.getMicrophoneManager().shouldConnectOnInitialization() && {
[InitializationAspect.MICROPHONE_CONNECT]: async () => {
// Recreate the microphone manager to guarantee an immediate
// re-render.
this._api.createMicrophoneManager();
return await this._api.getMicrophoneManager().connect();
!(await this._tryInitialize(() =>
this._initializer.initializeMultipleIfNecessary({
[InitializationAspect.CAMERAS]: async () => {
// Recreate the camera manager to guarantee an immediate re-render.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1811
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1769
this._api.createCameraManager();
await this._api.getCameraManager().initializeCamerasFromConfig();
},
}),
}))
) {
return;
}
if (
this._api.getMessageManager().hasMessage() ||
!(await this._initializer.initializeIfNecessary(
InitializationAspect.VIEW,
this._api.getViewManager().initialize,
// Connecting the microphone (if configured) is considered mandatory to
// avoid issues with some cameras that only allow 2-way audio on the
// first stream initialized.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1235
...(this._api.getMicrophoneManager().shouldConnectOnInitialization() && {
[InitializationAspect.MICROPHONE_CONNECT]: async () => {
// Recreate the microphone manager to guarantee an immediate
// re-render.
this._api.createMicrophoneManager();
await this._api.getMicrophoneManager().connect();
},
}),
}),
))
) {
return;
}
if (
!(await this._initializer.initializeIfNecessary(
InitializationAspect.INITIAL_TRIGGER,
async (): Promise<boolean> => {
await this._api.getTriggersManager().handleInitialCameraTriggers();
!(await this._tryInitialize(() =>
this._initializer.initializeIfNecessary(
InitializationAspect.VIEW,
this._api.getViewManager().initialize,
),
))
) {
return;
}
// Force a card update to continue the initialization.
this._api.getCardElementManager().update();
return true;
},
if (
!(await this._tryInitialize(() =>
this._initializer.initializeIfNecessary(
InitializationAspect.INITIAL_TRIGGER,
async () => {
await this._api.getTriggersManager().handleInitialCameraTriggers();
// Force a card update to continue the initialization.
this._api.getCardElementManager().update();
},
),
))
) {
return;
@@ -172,26 +187,40 @@ export class InitializationManager {
this._api.getCardElementManager().update();
}
public async initializeBackground(): Promise<void> {
await this._initializationQueue.add(() => this._initializeBackground());
}
private async _initializeBackground(): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
private async _tryInitialize(fn: () => Promise<void>): Promise<boolean> {
try {
await fn();
} catch (e: unknown) {
if (e instanceof Error) {
errorToConsole(e);
}
this._setInitializationIssue(e);
return false;
}
await this._initializer.initializeIfNecessary(
InitializationAspect.PROBLEMS,
async () => {
await this._api.getProblemManager().detectStatic(hass);
return true;
},
);
if (this._api.getIssueManager().getStateManager().hasFullCardIssue()) {
return false;
}
return true;
}
private _setInitializationIssue(error: unknown): void {
this._api.getIssueManager().trigger('initialization', { error });
}
public uninitialize(aspect: InitializationAspect): void {
this._initializer.uninitialize(aspect);
}
public uninitializeMandatory(): void {
for (const aspect of [
InitializationAspect.CAMERAS,
InitializationAspect.MICROPHONE_CONNECT,
InitializationAspect.VIEW,
InitializationAspect.INITIAL_TRIGGER,
]) {
this._initializer.uninitialize(aspect);
}
}
}
@@ -22,6 +22,11 @@ export class InteractionManager {
this._setInteraction(false);
}
public uninitialize(): void {
this._timer.stop();
this.reportInteraction.cancel();
}
public hasInteraction(): boolean {
return this._interacted;
}
+30
View File
@@ -0,0 +1,30 @@
import { CardIssueManagerAPI } from '../types';
import { IssueManager } from './issue-manager';
import { ConfigErrorIssue } from './issues/config-error';
import { ConfigUpgradeIssue } from './issues/config-upgrade';
import { ConnectionIssue } from './issues/connection';
import { InitializationIssue } from './issues/initialization';
import { LegacyResourceIssue } from './issues/legacy-resource';
import { MediaLoadIssue } from './issues/media-load';
import { MediaQueryIssue } from './issues/media-query';
import { ViewIncompatibleIssue } from './issues/view-incompatible';
export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => {
const manager = new IssueManager(api);
const changeCallback = () => manager.evaluate();
// Registration order determines both retry priority and full-card display
// priority. For retries, issues are retried in order and an exclusive retry
// stops the loop. For display, getFullCardIssue() returns the first active
// full-card issue. Register broader/more critical issues first.
manager.addIssue(new ConfigErrorIssue());
manager.addIssue(new ConfigUpgradeIssue(api));
manager.addIssue(new ViewIncompatibleIssue(api));
manager.addIssue(new ConnectionIssue());
manager.addIssue(new InitializationIssue(api));
manager.addIssue(new LegacyResourceIssue(changeCallback));
manager.addIssue(new MediaQueryIssue(api));
manager.addIssue(new MediaLoadIssue(api, changeCallback));
return manager;
};
+246
View File
@@ -0,0 +1,246 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionStateChange } from '../../conditions/types';
import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode';
import { Timer } from '../../utils/timer';
import { CardIssueManagerAPI } from '../types';
import { IssueStateManager } from './state-manager';
import { Issue, IssueKey, IssueReadOnlyState, IssueTriggerContextKey } from './types';
// Exponential backoff schedule for 'auto' retry. The base is set above the
// per-media retry threshold (~10s) so the issue-level backoff kicks in *after*
// lower-level recovery has had a chance to work, not in parallel with it.
export const RETRY_EXPONENTIAL_BASE_SECONDS = 30;
export const RETRY_EXPONENTIAL_MAX_SECONDS = 600;
const RETRY_EXPONENTIAL_JITTER_MIN = 0.5;
const RETRY_EXPONENTIAL_JITTER_MAX = 1.0;
// Wraps the passive IssueStateManager with reaction logic. A single
// condition-state listener drives everything: it runs one-shot static
// detection when mandatory-init completes (`initialized` transitions to
// true), then evaluates dynamic issues on every subsequent state change,
// schedules retries, and updates the card. Full-card issues are rendered by
// card.ts via getStateManager().getFullCardIssue(). Non-full-card issue
// notifications are shown on demand via showNotification().
export class IssueManager {
private _api: CardIssueManagerAPI;
private _stateManager = new IssueStateManager();
private _retryTimer = new Timer();
private _retryAttempt = 0;
private _suspended = false;
// Reentrancy guard: evaluate() calls setState() on the condition state
// manager, which fires listeners synchronously — including the one
// registered in this constructor. Without this guard, detectDynamic()
// and presence computation would run twice per evaluation.
private _evaluating = false;
constructor(api: CardIssueManagerAPI) {
this._api = api;
api.getConditionStateManager().addListener((change) => this._onStateChange(change));
}
// =========================================================================
// Setup.
// =========================================================================
public addIssue(issue: Issue): void {
this._stateManager.addIssue(issue);
}
public getStateManager(): IssueReadOnlyState {
return this._stateManager;
}
// =========================================================================
// Detection & reaction.
// =========================================================================
// Called by components that detect an issue directly (e.g. a provider
// error event), bypassing the condition-state polling loop.
public trigger<K extends IssueTriggerContextKey>(
key: K,
context: IssueTriggerContext[K],
): void {
this._stateManager.trigger(key, context);
this.evaluate();
}
// Evaluate all dynamic issues against current state, then react to any
// changes: notify, update condition state, and schedule retries.
//
// Detection of "anything changed" is delegated to the condition state
// manager: IssuePresence is a Map<IssueKey, IssueDescription>, so its
// deep equality check naturally catches both presence-set churn (issues
// appearing/disappearing) and content-level churn (an issue swapping
// sub-states without changing its key, e.g. ConnectionIssue going from
// 'lost' to 'starting').
public evaluate(): void {
if (this._suspended || this._evaluating) {
return;
}
this._evaluating = true;
try {
const state = this._api.getConditionStateManager().getState();
this._stateManager.detectDynamic(state);
if (
this._api.getConditionStateManager().setState({
issues: this._stateManager.getIssuePresence(),
})
) {
this._api.getCardElementManager().update();
}
this._scheduleRetryIfNeeded();
} finally {
this._evaluating = false;
}
}
// Attempts a retry for the given issue. Pass `force = true` for user-
// initiated retries (e.g. clicking the retry button on a notification): it
// bypasses the `needsRetry()` gate that scheduled auto-retries must
// respect, so even an issue that doesn't currently want a retry will run
// its `retry()` method. Also stops the pending auto-retry timer so the
// user action resets the backoff schedule.
public retry(key: IssueKey, force?: boolean): void {
this._stateManager.retry(key, force);
this._retryTimer.stop();
this.evaluate();
}
// Show the notification for an issue on demand (e.g. user clicks a loading
// icon) regardless of whether the issue is currently active.
public showNotification(key: IssueKey): void {
const notification = this._stateManager.getNotification(key);
if (notification) {
this._api.getNotificationManager().setNotification(notification);
}
}
// =========================================================================
// Lifecycle.
// =========================================================================
public reset(key?: IssueKey): void {
// When resetting a specific key that has no active issue, skip the
// reset+evaluate cycle entirely to avoid unnecessary work.
if (key && !this._stateManager.getIssuePresence().has(key)) {
return;
}
this._stateManager.reset(key);
this.evaluate();
}
// Gate evaluation while the card is detached so timers don't arm or mature
// offscreen. Issue state is preserved (including full-card issues like
// config_error). Issue-internal timers are stopped via Issue.suspend so
// offscreen time doesn't count against age-based thresholds (e.g. media
// loading timeout). Evaluation resumes on resume().
public suspend(): void {
this._suspended = true;
this._retryTimer.stop();
this._stateManager.suspend();
}
public resume(): void {
this._suspended = false;
this.evaluate();
}
public destroy(): void {
this._retryTimer.stop();
this._stateManager.destroy();
}
// =========================================================================
// Private helpers.
// =========================================================================
// Drives both one-shot static detection (on mandatory-init completion) and
// normal re-evaluation (on any condition-state change).
//
// `initialized: true` in the change payload means mandatory initialization
// just finished — see InitializationManager._initializeMandatory. That's
// also the earliest point at which the full HASS object is guaranteed
// ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources
// fetch). Because `initialized` is latched (its comment notes it never
// changes again), this block fires exactly once per IssueManager life.
private _onStateChange(change: ConditionStateChange): void {
if (change.change.initialized === true && change.new.hass) {
/* async */ this._stateManager
.detectStatic(change.new.hass)
.then(() => this.evaluate());
}
this.evaluate();
}
private _scheduleRetryIfNeeded(): void {
if (!this._stateManager.needsRetry()) {
this._retryTimer.stop();
this._retryAttempt = 0;
return;
}
if (this._retryTimer.isRunning()) {
return;
}
const config = this._api.getConfigManager().getConfig();
if (!config) {
this._retryAttempt = 0;
return;
}
const delaySeconds = this._nextRetryDelaySeconds(config.view.issues.retry_seconds);
if (delaySeconds === null) {
this._retryAttempt = 0;
return;
}
this._retryTimer.start(delaySeconds, () => {
if (!this._stateManager.needsRetry()) {
this._retryAttempt = 0;
return;
}
if (this._isScheduledRetryAllowed()) {
this._stateManager.retry();
this._retryAttempt++;
// evaluate() re-arms the timer via _scheduleRetryIfNeeded.
this.evaluate();
} else {
// Retry was gated (e.g. user interaction). This isn't a failed attempt
// so don't increment — re-arm at the same delay.
this._scheduleRetryIfNeeded();
}
});
}
private _nextRetryDelaySeconds(retryConfig: 'auto' | number): number | null {
if (typeof retryConfig === 'number') {
return retryConfig === 0 ? null : retryConfig;
}
// 'auto': exponential backoff, capped, with jitter to avoid thundering-herd
// when multiple cards retry the same backend in lockstep.
const exp = Math.min(
RETRY_EXPONENTIAL_MAX_SECONDS,
RETRY_EXPONENTIAL_BASE_SECONDS * 2 ** this._retryAttempt,
);
const jitter =
RETRY_EXPONENTIAL_JITTER_MIN +
Math.random() * (RETRY_EXPONENTIAL_JITTER_MAX - RETRY_EXPONENTIAL_JITTER_MIN);
return exp * jitter;
}
private _isScheduledRetryAllowed(): boolean {
const interactionMode = this._api.getConfigManager().getConfig()?.view
.issues.interaction_mode;
return (
!!interactionMode &&
isActionAllowedBasedOnInteractionState(
interactionMode,
this._api.getInteractionManager().hasInteraction(),
)
);
}
}
@@ -0,0 +1,30 @@
import { Issue, IssueDescription, IssueKey } from '../types.js';
// Shared base for issues whose only state is a single triggered error. Subclasses
// define the key and how an error renders; the base handles trigger/reset and
// gates getIssue() on error presence.
export abstract class AbstractErrorIssue implements Issue {
public abstract readonly key: IssueKey;
protected _error: unknown = null;
public trigger(context: { error: unknown }): void {
this._error = context.error ?? null;
}
public hasIssue(): boolean {
return this._error !== null;
}
public getIssue(): IssueDescription | null {
if (this._error == null) {
return null;
}
return this._buildDescription(this._error);
}
public reset(): void {
this._error = null;
}
protected abstract _buildDescription(error: NonNullable<unknown>): IssueDescription;
}
@@ -0,0 +1,28 @@
import { createNotificationFromError } from '../../../components-lib/notification/factory.js';
import { localize } from '../../../localize/localize.js';
import { IssueDescription } from '../types.js';
import { AbstractErrorIssue } from './abstract-error-issue.js';
declare module 'issue' {
interface IssueTriggerContext {
config_error: { error: unknown };
}
}
export class ConfigErrorIssue extends AbstractErrorIssue {
public readonly key = 'config_error' as const;
public isFullCardIssue(): boolean {
return true;
}
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
return {
icon: 'mdi:alert',
severity: 'high',
notification: createNotificationFromError(error, {
heading: { text: localize('issues.config_error.heading') },
}),
};
}
}
@@ -1,29 +1,29 @@
import { isConfigUpgradeable } from '../../../config/management.js';
import { RawAdvancedCameraCardConfig } from '../../../config/types.js';
import { TROUBLESHOOTING_CONFIG_UPGRADE_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js';
import { Problem, ProblemResult } from '../types';
import { CardIssueManagerAPI } from '../../types';
import { Issue, IssueDescription } from '../types';
export class ConfigUpgradeProblem implements Problem {
export class ConfigUpgradeIssue implements Issue {
public readonly key = 'config_upgrade' as const;
private _api: CardIssueManagerAPI;
private _upgradeable = false;
private _getRawConfig: () => RawAdvancedCameraCardConfig | null;
constructor(getRawConfig: () => RawAdvancedCameraCardConfig | null) {
this._getRawConfig = getRawConfig;
constructor(api: CardIssueManagerAPI) {
this._api = api;
}
public async detectStatic(): Promise<void> {
const rawConfig = this._getRawConfig();
const rawConfig = this._api.getConfigManager().getRawConfig();
this._upgradeable = !!rawConfig && isConfigUpgradeable(rawConfig);
}
public hasResult(): boolean {
public hasIssue(): boolean {
return this._upgradeable;
}
public getResult(): ProblemResult | null {
public getIssue(): IssueDescription | null {
if (!this._upgradeable) {
return null;
}
@@ -32,14 +32,14 @@ export class ConfigUpgradeProblem implements Problem {
severity: 'medium',
notification: {
heading: {
text: localize('problems.config_upgrade.heading'),
text: localize('issues.config_upgrade.heading'),
icon: 'mdi:update',
severity: 'medium',
},
text: localize('problems.config_upgrade.text'),
body: { text: localize('issues.config_upgrade.text') },
link: {
url: TROUBLESHOOTING_CONFIG_UPGRADE_URL,
title: localize('problems.troubleshooting_guide'),
title: localize('issues.troubleshooting_guide'),
},
},
};
@@ -0,0 +1,79 @@
import { STATE_RUNNING } from 'home-assistant-js-websocket';
import { ConditionState } from '../../../conditions/types.js';
import { localize } from '../../../localize/localize.js';
import { Issue, IssueDescription } from '../types.js';
// Tracks "is HA fully ready to talk to". Active in two sub-states:
// - 'lost' : the WebSocket is disconnected
// - 'starting' : the WebSocket is connected but HA hasn't finished loading
// integrations yet (hass.config.state !== STATE_RUNNING)
// Both sub-states render as a full-card notification with a spinner. The card
// only attempts re-initialization when the issue clears (i.e. HA is fully
// ready), so integration-specific WS calls (e.g. Frigate event subscriptions)
// don't fail with "Unknown command" against a half-loaded HA.
type ConnectionState = 'ready' | 'lost' | 'starting';
export class ConnectionIssue implements Issue {
public readonly key = 'connection' as const;
private _state: ConnectionState = 'ready';
public detectDynamic(state: ConditionState): void {
// Before HASS is ever provided, leave state untouched — undefined hass is
// not a disconnection, just "not yet initialized".
if (state.hass === undefined) {
return;
}
if (!state.hass.connected) {
this._state = 'lost';
} else if (state.hass.config?.state !== STATE_RUNNING) {
this._state = 'starting';
} else {
this._state = 'ready';
}
}
public hasIssue(): boolean {
return this._state !== 'ready';
}
public isFullCardIssue(): boolean {
return true;
}
public getIssue(): IssueDescription | null {
return this._state === 'lost'
? {
icon: 'mdi:lan-disconnect',
severity: 'high',
notification: {
heading: {
text: localize('issues.connection.lost.heading'),
icon: 'mdi:lan-disconnect',
severity: 'high',
},
body: { text: localize('issues.connection.lost.text') },
in_progress: true,
},
}
: this._state === 'starting'
? {
icon: 'mdi:home-assistant',
severity: 'medium',
notification: {
heading: {
text: localize('issues.connection.starting.heading'),
icon: 'mdi:home-assistant',
severity: 'medium',
},
body: { text: localize('issues.connection.starting.text') },
in_progress: true,
},
}
: null;
}
public reset(): void {
this._state = 'ready';
}
}
@@ -0,0 +1,68 @@
import { createNotificationFromError } from '../../../components-lib/notification/factory.js';
import { localize } from '../../../localize/localize.js';
import { CardIssueManagerAPI } from '../../types';
import { createRetryControl } from '../retry-control.js';
import { IssueDescription } from '../types';
import { AbstractErrorIssue } from './abstract-error-issue.js';
declare module 'issue' {
interface IssueTriggerContext {
initialization: { error: unknown };
}
}
export class InitializationIssue extends AbstractErrorIssue {
public readonly key = 'initialization' as const;
private _api: CardIssueManagerAPI;
constructor(api: CardIssueManagerAPI) {
super();
this._api = api;
}
public detectDynamic(): void {
if (
this._error !== null &&
this._api.getInitializationManager().isInitializedMandatory()
) {
this._error = null;
}
}
public needsRetry(): boolean {
return this._error !== null;
}
public retry(): boolean {
// Clear the error so the full-card issue is removed and shouldUpdate()
// no longer short-circuits before initializeMandatory().
this._error = null;
// Reset init state so initializeMandatory() re-attempts on the next
// render cycle. destroy() releases the existing CameraManager's held
// resources (WebSocket subscriptions, listeners) before the CAMERAS
// init aspect replaces the instance via createCameraManager().
this._api.getInitializationManager().uninitializeMandatory();
this._api.getCameraManager().destroy();
return false;
}
public isFullCardIssue(): boolean {
return true;
}
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
const notification = createNotificationFromError(error, {
heading: { text: localize('issues.initialization.heading') },
});
return {
icon: 'mdi:alert',
severity: 'high',
notification: {
...notification,
controls: [createRetryControl(this.key)],
},
};
}
}
@@ -4,7 +4,7 @@ import { HomeAssistant } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import { createInternalCallbackAction } from '../../../utils/action';
import { CardActionsAPI } from '../../types';
import { Problem, ProblemResult } from '../types';
import { Issue, IssueDescription } from '../types';
const LEGACY_RESOURCE_FILENAME = 'frigate-hass-card.js';
@@ -28,16 +28,16 @@ const resourcesSchema = z.array(
}),
);
export class LegacyResourceProblem implements Problem {
export class LegacyResourceIssue implements Issue {
public readonly key = 'legacy_resource' as const;
private _legacyResourceIDs: string[] = [];
private _hasCorrectResource = false;
private _checked = false;
private _triggerUpdate: () => void;
private _changeCallback: (() => void) | null;
constructor(triggerUpdate: () => void) {
this._triggerUpdate = triggerUpdate;
constructor(changeCallback?: () => void) {
this._changeCallback = changeCallback ?? null;
}
public async detectStatic(hass: HomeAssistant): Promise<void> {
@@ -76,38 +76,38 @@ export class LegacyResourceProblem implements Problem {
}
}
public hasResult(): boolean {
public hasIssue(): boolean {
return this._checked && this._legacyResourceIDs.length > 0;
}
public getResult(): ProblemResult | null {
if (!this.hasResult()) {
public getIssue(): IssueDescription | null {
if (!this.hasIssue()) {
return null;
}
const text = this._hasCorrectResource
? localize('problems.legacy_resource.text_both')
: localize('problems.legacy_resource.text_only_legacy');
? localize('issues.legacy_resource.text_both')
: localize('issues.legacy_resource.text_only_legacy');
return {
icon: 'mdi:alert',
severity: 'high',
notification: {
heading: {
text: localize('problems.legacy_resource.heading'),
text: localize('issues.legacy_resource.heading'),
icon: 'mdi:alert',
severity: 'high',
},
text,
body: { text },
link: {
url: TROUBLESHOOTING_LEGACY_RESOURCE_URL,
title: localize('problems.troubleshooting_guide'),
title: localize('issues.troubleshooting_guide'),
},
...(this._hasCorrectResource
? {
controls: [
{
tooltip: localize('problems.legacy_resource.remove'),
tooltip: localize('issues.legacy_resource.remove'),
icon: 'mdi:delete',
severity: 'high',
actions: {
@@ -152,9 +152,14 @@ export class LegacyResourceProblem implements Problem {
this._checked = false;
await this.detectStatic(hass);
const fixed = !this.hasResult();
// Success requires: detection completed cleanly AND found zero legacy
// resources. detectStatic swallows WS / schema failures and leaves
// _checked false; treating that as "issue gone" would let a failed
// verification fetch masquerade as a successful fix. Demand the
// positive signal instead.
const fixed = this._checked && this._legacyResourceIDs.length === 0;
if (fixed) {
this._triggerUpdate();
this._changeCallback?.();
}
return fixed;
} catch {
@@ -0,0 +1,238 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionState } from '../../../conditions/types.js';
import { Notification } from '../../../config/schema/actions/types.js';
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js';
import { Timer } from '../../../utils/timer.js';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js';
import { isAnyMediaViewName } from '../../../view/view.js';
import { CardIssueManagerAPI } from '../../types.js';
import { createRetryControl } from '../retry-control.js';
import { Issue, IssueDescription } from '../types.js';
declare module 'issue' {
interface IssueTriggerContext {
media_load: { targetID: string };
}
}
const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
export class MediaLoadIssue implements Issue {
public readonly key = 'media_load' as const;
private _issueActive = false;
private _erroredTargetIDs = new Set<string>();
// Timer fires when a target has been loading too long without success.
private _timer = new Timer();
private _timerTargetID: string | null = null;
private _api: CardIssueManagerAPI;
private _onChange: (() => void) | null;
constructor(api: CardIssueManagerAPI, onChange?: () => void) {
this._api = api;
this._onChange = onChange ?? null;
}
// =========================================================================
// Explicit trigger — called when a component fires an issue:trigger event.
// =========================================================================
public trigger(context: IssueTriggerContext['media_load']): void {
this._erroredTargetIDs.add(context.targetID);
}
// =========================================================================
// Detection — called by the manager on every state change.
// =========================================================================
public detectDynamic(state: ConditionState): void {
if (!isAnyMediaViewName(state.view)) {
this._deactivate();
return;
}
if (state.mediaLoadedInfo) {
this._handleMediaLoaded(state);
} else {
this._handleMediaNotLoaded(state);
}
}
// =========================================================================
// State queries — called by the manager to read current state.
// =========================================================================
public hasIssue(): boolean {
return this._issueActive;
}
public getIssue(): IssueDescription | null {
if (!this._issueActive) {
return null;
}
return {
icon: 'mdi:cctv-off',
severity: 'high',
notification: this.getNotification(),
};
}
public getNotification(): Notification {
const targets = new Set(this._erroredTargetIDs);
if (this._timerTargetID) {
targets.add(this._timerTargetID);
}
return {
heading: {
text: localize('issues.media_load.heading'),
icon: 'mdi:cctv-off',
severity: 'high' as const,
},
body: {
text: localize('issues.media_load.text'),
},
...(targets.size && {
metadata: Array.from(targets).map((id) => ({
text:
id === IMAGE_VIEW_TARGET_ID_SENTINEL
? localize('editor.image')
: this._api.getCameraManager().getCameraMetadata(id)?.title ?? id,
icon: id === IMAGE_VIEW_TARGET_ID_SENTINEL ? 'mdi:image' : 'mdi:cctv',
})),
}),
link: {
url: TROUBLESHOOTING_MEDIA_URL,
title: localize('issues.troubleshooting_guide'),
},
controls: [createRetryControl(this.key)],
};
}
// =========================================================================
// Retry — called by the manager to schedule a media reload.
// =========================================================================
public needsRetry(): boolean {
return this._issueActive;
}
public retry(): boolean {
// Build the set of targets to retry: all errored targets plus the
// target the pending timer was tracking (so a user-initiated retry
// works even before the timeout fires).
const retryTargets = new Set(this._erroredTargetIDs);
if (this._timerTargetID) {
retryTargets.add(this._timerTargetID);
}
if (!retryTargets.size) {
return false;
}
const view = this._api.getViewManager().getView();
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
for (const id of retryTargets) {
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
}
// Intentionally keep _issueActive, _erroredTargetIDs, and the pending
// timer in place. The issue stays visible while the provider
// re-attempts loading underneath. If the retry succeeds,
// _handleMediaLoaded will clear everything when media:loaded fires. If
// it fails silently (e.g. bogus stream name), the error stays visible
// immediately — no new 10s grace period.
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
return false;
}
// =========================================================================
// Lifecycle.
// =========================================================================
public reset(): void {
this._deactivate();
this._erroredTargetIDs.clear();
}
// Stop the pending-load timer so offscreen time doesn't count toward the
// 10s threshold. Preserve _issueActive, _erroredTargetIDs, and
// _timerTargetID: already-visible errors remain visible on reattach, and
// retaining _timerTargetID lets the existing active/target-mismatch guard
// in _handleMediaNotLoaded avoid spuriously deactivating the preserved
// issue when the same target is still loading on resume. The timer is
// re-armed with a fresh window by the next detectDynamic pass.
public suspend(): void {
this._timer.stop();
}
// =========================================================================
// Private helpers.
// =========================================================================
// Media loaded successfully: deactivate and clear the error for this target
// so it won't immediately re-trigger on the next evaluation.
private _handleMediaLoaded(state: ConditionState): void {
this._deactivate();
if (state.targetID) {
this._erroredTargetIDs.delete(state.targetID);
}
}
// Media not yet loaded: activate immediately if there is a known provider
// error for this target, otherwise start a timeout to detect slow loads.
private _handleMediaNotLoaded(state: ConditionState): void {
// No targetID means no provider is actively rendering media (e.g. the
// viewer shows "No media to display" instead of showing a player). Don't
// start the timeout as there's nothing to wait for.
if (!state.targetID) {
this._deactivate();
return;
}
if (this._hasError(state)) {
this._activate();
return;
}
const targetID = state.targetID;
// When the target changes to one without a known error, clear the active
// state so the new target gets its own timeout window instead of
// inheriting the previous target's error.
if (this._issueActive && this._timerTargetID !== targetID) {
this._deactivate();
}
// Start (or restart) the timer for this target.
if (!this._timer.isRunning() || this._timerTargetID !== targetID) {
this._timerTargetID = targetID;
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
// Record the error on timeout so retry() knows which epoch to bump.
// targetID is guaranteed non-null here — the null case bails at the
// top of _handleMediaNotLoaded.
this._erroredTargetIDs.add(targetID);
this._activate();
this._onChange?.();
});
}
}
private _hasError(state: ConditionState): boolean {
return !!state.targetID && this._erroredTargetIDs.has(state.targetID);
}
private _activate(): void {
this._timer.stop();
this._issueActive = true;
}
private _deactivate(): void {
this._timer.stop();
this._timerTargetID = null;
this._issueActive = false;
}
}
@@ -0,0 +1,57 @@
import { createNotificationFromError } from '../../../components-lib/notification/factory.js';
import { Notification } from '../../../config/schema/actions/types.js';
import { localize } from '../../../localize/localize.js';
import { CardIssueManagerAPI } from '../../types.js';
import { createRetryControl } from '../retry-control.js';
import { IssueDescription } from '../types.js';
import { AbstractErrorIssue } from './abstract-error-issue.js';
declare module 'issue' {
interface IssueTriggerContext {
media_query: { error: unknown };
}
}
export class MediaQueryIssue extends AbstractErrorIssue {
public readonly key = 'media_query' as const;
private _api: CardIssueManagerAPI;
constructor(api: CardIssueManagerAPI) {
super();
this._api = api;
}
public needsRetry(): boolean {
return this._error !== null;
}
public retry(): boolean {
if (this._error === null) {
return false;
}
this._error = null;
this._api.getViewManager().setViewByParametersWithNewQuery();
// Exclusive retry. No other issue should attempt to retry until the next
// evaluation cycle, when we'll know if this was successful.
return true;
}
public getNotification(): Notification | null {
return this.getIssue()?.notification ?? null;
}
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
return {
icon: 'mdi:alert',
severity: 'high',
notification: {
...createNotificationFromError(error, {
heading: { text: localize('issues.media_query.heading') },
}),
controls: [createRetryControl(this.key)],
},
};
}
}
@@ -0,0 +1,48 @@
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
import { localize } from '../../../localize/localize.js';
import { getContextFromError } from '../../../utils/error-context.js';
import { CardIssueManagerAPI } from '../../types.js';
import { IssueDescription } from '../types.js';
import { AbstractErrorIssue } from './abstract-error-issue.js';
declare module 'issue' {
interface IssueTriggerContext {
view_incompatible: { error: unknown };
}
}
export class ViewIncompatibleIssue extends AbstractErrorIssue {
public readonly key = 'view_incompatible' as const;
private _api: CardIssueManagerAPI;
constructor(api: CardIssueManagerAPI) {
super();
this._api = api;
}
// Full-card when no view is available to anchor a popup to (initial load
// with an unrealizable default view); popup when an existing view remains
// visible underneath (mid-session user action that can't resolve).
public isFullCardIssue(): boolean {
return !this._api.getViewManager().getView();
}
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
return {
icon: 'mdi:video-off',
severity: 'high',
notification: createNotificationFromText(
localize('issues.view_incompatible.text'),
{
heading: {
text: localize('issues.view_incompatible.heading'),
icon: 'mdi:video-off',
severity: 'high',
},
context: getContextFromError(error) ?? undefined,
},
),
};
}
}
@@ -0,0 +1,17 @@
import { NotificationControl } from '../../config/schema/actions/types.js';
import { localize } from '../../localize/localize.js';
import { createInternalCallbackAction } from '../../utils/action.js';
import { IssueKey } from './types.js';
export function createRetryControl(key: IssueKey): NotificationControl {
return {
icon: 'mdi:refresh',
tooltip: localize('common.retry'),
dismiss: true,
actions: {
tap_action: createInternalCallbackAction(async (api) => {
api.getIssueManager().retry(key, true);
}),
},
};
}
+172
View File
@@ -0,0 +1,172 @@
import type { IssueTriggerContext } from 'issue';
import { summarizeNotification } from '../../components-lib/notification/summarize';
import { ConditionState } from '../../conditions/types';
import { Notification } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import { isTruthy } from '../../utils/basic';
import {
Issue,
IssueDescription,
IssueKey,
IssuePresence,
IssueReadOnlyState,
IssueTriggerContextKey,
KeyedIssueDescription,
} from './types';
export class IssueStateManager implements IssueReadOnlyState {
private _issues = new Map<IssueKey, Issue>();
private _loggedKeys = new Set<IssueKey>();
// =========================================================================
// Setup.
// =========================================================================
public addIssue(issue: Issue): void {
this._issues.set(issue.key, issue);
}
// =========================================================================
// Detection — static (one-shot on init) and dynamic (on every state change).
// =========================================================================
public async detectStatic(hass: HomeAssistant): Promise<void> {
for (const issue of this._issues.values()) {
await issue.detectStatic?.(hass);
this._logIfNew(issue);
}
}
public trigger<K extends IssueTriggerContextKey>(
key: K,
context: IssueTriggerContext[K],
): void {
const issue = this._issues.get(key);
if (!issue) {
return;
}
issue.trigger?.(context);
this._logIfNew(issue);
}
public detectDynamic(context: ConditionState): void {
for (const issue of this._issues.values()) {
issue.detectDynamic?.(context);
this._logIfNew(issue);
}
}
// =========================================================================
// Queries — read active issue state.
// =========================================================================
public getFullCardIssue(): IssueDescription | null {
for (const issue of this._issues.values()) {
if (issue.hasIssue() && issue.isFullCardIssue?.()) {
return issue.getIssue();
}
}
return null;
}
public hasFullCardIssue(): boolean {
return !!this.getFullCardIssue();
}
public getIssueDescriptions(): KeyedIssueDescription[] {
const descriptions: KeyedIssueDescription[] = [];
for (const issue of this._issues.values()) {
const description = issue.getIssue();
if (description) {
descriptions.push({ key: issue.key, issue: description });
}
}
return descriptions;
}
public getIssuePresence(): IssuePresence {
const presence: IssuePresence = new Map();
for (const issue of this._issues.values()) {
const description = issue.getIssue();
if (description) {
presence.set(issue.key, description);
}
}
return presence;
}
public getNotification(key: IssueKey): Notification | null {
return this._issues.get(key)?.getNotification?.() ?? null;
}
// =========================================================================
// Retry.
// =========================================================================
public needsRetry(): boolean {
return [...this._issues.values()].some((issue) => issue.needsRetry?.());
}
public retry(key?: IssueKey, force?: boolean): void {
const issues = key
? [this._issues.get(key)].filter(isTruthy)
: [...this._issues.values()];
for (const issue of issues) {
if (!force && !issue.needsRetry?.()) {
continue;
}
if (issue.retry?.()) {
return;
}
}
}
// =========================================================================
// Lifecycle.
// =========================================================================
public reset(key?: IssueKey): void {
const issues = key
? [this._issues.get(key)].filter(isTruthy)
: [...this._issues.values()];
for (const issue of issues) {
issue.reset?.();
}
}
public suspend(): void {
for (const issue of this._issues.values()) {
issue.suspend?.();
}
}
public destroy(): void {
this.reset();
this._issues.clear();
this._loggedKeys.clear();
}
// =========================================================================
// Private helpers.
// =========================================================================
private _logIfNew(issue: Issue): void {
const description = issue.getIssue();
if (!description) {
// Issue cleared (explicit reset, or self-clear via detectDynamic).
// Release the dedupe so the next activation is logged as a new episode.
this._loggedKeys.delete(issue.key);
return;
}
if (this._loggedKeys.has(issue.key)) {
return;
}
this._loggedKeys.add(issue.key);
const summary = summarizeNotification(description.notification);
if (summary) {
console.warn(`Advanced Camera Card [issue=${issue.key}]: ${summary}`);
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { IssueTriggerContext } from 'issue';
import { ConditionState } from '../../conditions/types';
import { Notification } from '../../config/schema/actions/types';
import { HomeAssistant } from '../../ha/types';
import { Severity } from '../../severity';
export type IssueKey =
| 'config_error'
| 'config_upgrade'
| 'connection'
| 'initialization'
| 'legacy_resource'
| 'media_load'
| 'media_query'
| 'view_incompatible';
export interface IssueDescription {
icon: string;
severity: Severity;
notification: Notification;
}
export interface KeyedIssueDescription {
key: IssueKey;
issue: IssueDescription;
}
// Map of currently active issues keyed by IssueKey, with each entry's value
// being the issue's current rendered description. Stored as a Map (not just
// a Set of keys) so that sub-state changes within an issue — e.g.
// ConnectionIssue swapping between 'lost' and 'starting' — are reflected as
// real value-level diffs to the condition state, triggering re-renders and
// any user-defined conditions that depend on issue state.
export type IssuePresence = Map<IssueKey, IssueDescription>;
export interface IssueReadOnlyState {
hasFullCardIssue(): boolean;
getFullCardIssue(): IssueDescription | null;
getIssueDescriptions(): KeyedIssueDescription[];
getIssuePresence(): IssuePresence;
getNotification(key: IssueKey): Notification | null;
}
export type IssueTriggerContextKey = keyof IssueTriggerContext;
export type IssueTriggerEventData = {
[K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K];
}[IssueTriggerContextKey];
export interface Issue {
readonly key: IssueKey;
// One-time async detection (WS calls, config checks).
detectStatic?(hass?: HomeAssistant): Promise<void>;
// Ongoing sync evaluation, called on state changes.
detectDynamic?(context: ConditionState): void;
// Explicitly trigger this issue with key-specific context.
trigger?(context: IssueTriggerContext[IssueTriggerContextKey]): void;
hasIssue(): boolean;
getIssue(): IssueDescription | null;
// Whether this issue renders as a full-card display when active. Defaults
// to false when absent (popup notification). Issues that take over the
// entire card must explicitly return true.
isFullCardIssue?(): boolean;
// Return notification content regardless of active state, for user-initiated
// queries (e.g. clicking a loading icon). May return null when content
// depends on transient state (e.g. no current error to show).
getNotification?(): Notification | null;
// Whether this issue wants the manager to schedule a retry. Gates
// scheduled retries; user-initiated (forced) retries bypass this check.
needsRetry?(): boolean;
// Called by the manager when a retry is due. Returns true to stop the retry
// loop (exclusive), false to allow subsequent issues to also retry.
retry?(): boolean;
// Optional user-initiated fix. Not called by the issue infrastructure —
// callers (e.g. notification control actions) invoke this directly.
fix?(hass: HomeAssistant): Promise<boolean>;
// Reset internal state (clear errors, stop timers, etc.).
reset?(): void;
// Called when the card is detached. Issues with age-based timers (e.g.
// loading-timeout timers) must stop them here so that time spent offscreen
// doesn't count against the user. Must preserve already-active issue state
// — a full-card issue visible at detach should still be visible on
// reattach. No `resume` hook: IssueManager.resume() triggers a normal
// evaluate(), so any timer that should restart is re-armed via
// detectDynamic against the current condition state.
suspend?(): void;
}
+13 -2
View File
@@ -21,6 +21,14 @@ export class KeyboardStateManager {
element.removeEventListener('keydown', this._handleKeydown);
element.removeEventListener('keyup', this._handleKeyup);
element.removeEventListener('blur', this._handleBlur);
// Clear state on disconnect. Without listeners the card cannot know
// whether a key was released while detached, and stale "down" state
// would suppress the next real keydown (e.g. PTZ stop shortcuts).
if (Object.keys(this._state).length) {
this._state = {};
this._processStateChange();
}
}
private _handleKeydown = (ev: KeyboardEvent): void => {
@@ -40,7 +48,7 @@ export class KeyboardStateManager {
private _handleKeyup = (ev: KeyboardEvent): void => {
if (ev.key in this._state && this._state[ev.key].state === 'down') {
this._state[ev.key].state = 'up';
this._state[ev.key] = { ...this._state[ev.key], state: 'up' as const };
this._processStateChange();
}
};
@@ -53,7 +61,10 @@ export class KeyboardStateManager {
}
};
// Clone before passing to ConditionStateManager so that subsequent
// in-place mutations to this._state don't affect the stored reference,
// which would make isEqual comparisons always see the same object.
private _processStateChange(): void {
this._api.getConditionStateManager().setState({ keys: this._state });
this._api.getConditionStateManager().setState({ keys: { ...this._state } });
}
}
+3 -6
View File
@@ -7,7 +7,6 @@ import {
} from '../const';
import { Entity } from '../ha/registry/entity/types';
import { supportsFeature } from '../ha/supports-feature';
import { localize } from '../localize/localize';
import { errorToConsole } from '../utils/basic';
import { ViewMedia } from '../view/item';
import { ViewItemClassifier } from '../view/item-classifier';
@@ -165,12 +164,10 @@ export class MediaPlayerManager {
}
const dashboardConfig = cameraConfig.cast?.dashboard;
// Guaranteed by schema refinement when cast.method is 'dashboard', but
// needed for TypeScript narrowing since refine() doesn't narrow types.
if (!dashboardConfig?.dashboard_path || !dashboardConfig?.view_path) {
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
icon: 'mdi:cast',
message: localize('error.no_dashboard_or_view'),
});
return;
}
-88
View File
@@ -1,88 +0,0 @@
import { AdvancedCameraCardError, Message, MessageType } from '../types';
import { errorToConsole } from '../utils/basic';
import { CardMessageAPI } from './types';
type MessagePriority = {
[type in MessageType]: number;
};
const MESSAGE_TYPE_PRIORITIES: MessagePriority = {
info: 10,
error: 20,
connection: 30,
diagnostics: 40,
};
export class MessageManager {
private _message: Message | null = null;
private _api: CardMessageAPI;
constructor(api: CardMessageAPI) {
this._api = api;
}
public getMessage(): Message | null {
return this._message;
}
public hasMessage(): boolean {
return !!this._message;
}
public hasErrorMessage(): boolean {
return this._message?.type === 'error';
}
public reset(): void {
const hadMessage = this.hasMessage();
this._message = null;
if (hadMessage) {
this._api.getCardElementManager().update();
}
}
public resetType(type: MessageType): void {
if (this._message?.type === type) {
this.reset();
}
}
public setErrorIfHigherPriority(error: unknown, prefix?: string): void {
// This object should accept unknown objects to be able to seamlessly
// process arguments to catch() which can only be unknown/any. HA may throw
// non Error() based errors.
if (!error || typeof error !== 'object' || !('message' in error)) {
return;
}
errorToConsole(error);
this.setMessageIfHigherPriority({
message: prefix ? `${prefix}: ${error.message}` : String(error.message),
type: 'error',
...(error instanceof AdvancedCameraCardError && { context: error.context }),
});
}
public setMessageIfHigherPriority(message: Message): boolean {
const resolveMessageType = (message: Message): MessageType => {
return message.type ?? 'info';
};
const currentPriority = this._message
? MESSAGE_TYPE_PRIORITIES[resolveMessageType(this._message)]
: 0;
const newPriority = MESSAGE_TYPE_PRIORITIES[resolveMessageType(message)];
if (this._message && newPriority < currentPriority) {
return false;
}
this._message = message;
// When a message is displayed it effectively unloads the media.
this._api.getMediaLoadedInfoManager().clear();
this._api.getCardElementManager().scrollReset();
this._api.getCardElementManager().update();
return true;
}
}
+11 -7
View File
@@ -1,7 +1,14 @@
import { errorToConsole } from '../utils/basic';
import { localize } from '../localize/localize';
import { AdvancedCameraCardError } from '../types';
import { Timer } from '../utils/timer';
import { CardMicrophoneAPI, MicrophoneState } from './types';
export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
constructor() {
super(localize('error.microphone_not_supported'));
}
}
export class MicrophoneManager {
private _api: CardMicrophoneAPI;
private _stream?: MediaStream | null;
@@ -46,9 +53,9 @@ export class MicrophoneManager {
return !!navigator.mediaDevices?.getUserMedia;
}
public async connect(): Promise<boolean> {
public async connect(): Promise<void> {
if (!this.isSupported()) {
return false;
throw new MicrophoneNotSupportedError();
}
try {
@@ -57,15 +64,12 @@ export class MicrophoneManager {
video: false,
});
} catch (e: unknown) {
errorToConsole(e as Error);
this._stream = null;
this._setState();
return false;
throw e;
}
this._setDesiredMuteOnStream();
this._setState();
return true;
}
public disconnect(): void {
-143
View File
@@ -1,143 +0,0 @@
import { ConditionStateChange } from '../../conditions/types';
import { HomeAssistant } from '../../ha/types';
import { CardProblemAPI } from '../types';
import { ConfigUpgradeProblem } from './problems/config-upgrade';
import { LegacyResourceProblem } from './problems/legacy-resource';
import { StreamNotLoadingProblem } from './problems/stream-not-loading';
import {
KeyedProblemResult,
Problem,
ProblemDynamicContext,
ProblemKey,
ProblemPresence,
ProblemTriggerContext,
} from './types';
export class ProblemManager {
private _api: CardProblemAPI;
private _problems = new Map<ProblemKey, Problem>();
private _loggedKeys = new Set<ProblemKey>();
constructor(api: CardProblemAPI) {
this._api = api;
this._addProblem(
new ConfigUpgradeProblem(() => this._api.getConfigManager().getRawConfig()),
);
this._addProblem(
new LegacyResourceProblem(() => this._api.getCardElementManager().update()),
);
this._addProblem(
new StreamNotLoadingProblem(() => this._api.getCardElementManager().update()),
);
}
public initialize(): void {
this._api.getConditionStateManager().addListener(this._stateChangeHandler);
}
public uninitialize(): void {
this._api.getConditionStateManager().removeListener(this._stateChangeHandler);
}
private _addProblem(problem: Problem): void {
this._problems.set(problem.key, problem);
}
public async detectStatic(hass: HomeAssistant): Promise<void> {
for (const problem of this._problems.values()) {
await problem.detectStatic?.(hass);
this._logIfNew(problem);
}
this._api.getCardElementManager().update();
}
// Silently trigger a problem by key, updating state without user
// interaction. Use this for system-originated events (e.g. provider errors).
public trigger(key: ProblemKey, context?: ProblemTriggerContext): void {
const problem = this._problems.get(key);
if (!problem) {
return;
}
problem.trigger?.(context);
// Re-evaluate dynamic state so the trigger could take effect immediately.
// trigger() only records context (e.g. marking a camera as errored);
// detectDynamic() decides whether to activate based on current state (e.g.
// whether it is the selected camera with the error).
const state = this._api.getConditionStateManager().getState();
this._detectAllDynamic({
cameraID: state.camera,
view: state.view,
mediaLoaded: !!state.mediaLoadedInfo,
});
}
// Show the notification popup for a problem, regardless of whether or not
// that problem has triggered (example usecase: the stream is loading and the
// user clicks the blue loading icon).
public forceNotify(key: ProblemKey): void {
const notification = this._problems.get(key)?.getNotification?.();
if (notification) {
this._api.getNotificationManager().setNotification(notification);
}
}
public getProblemResults(): KeyedProblemResult[] {
const results: KeyedProblemResult[] = [];
for (const problem of this._problems.values()) {
const result = problem.getResult();
if (result) {
results.push({ key: problem.key, problem: result });
}
}
return results;
}
public getProblemPresence(): ProblemPresence {
const presence: ProblemPresence = {};
for (const problem of this._problems.values()) {
presence[problem.key] = problem.hasResult();
}
return presence;
}
public destroy(): void {
this.uninitialize();
for (const problem of this._problems.values()) {
problem.destroy?.();
}
this._problems.clear();
}
private _stateChangeHandler = (change: ConditionStateChange): void => {
this._detectAllDynamic({
cameraID: change.new.camera,
view: change.new.view,
mediaLoaded: !!change.new.mediaLoadedInfo,
});
};
private _detectAllDynamic(context: ProblemDynamicContext): void {
let stateChanged = false;
for (const problem of this._problems.values()) {
const hadResult = problem.hasResult();
problem.detectDynamic?.(context);
stateChanged ||= problem.hasResult() !== hadResult;
this._logIfNew(problem);
}
if (stateChanged) {
this._api.getCardElementManager().update();
}
}
private _logIfNew(problem: Problem): void {
if (problem.hasResult() && !this._loggedKeys.has(problem.key)) {
this._loggedKeys.add(problem.key);
const text = problem.getResult()?.notification.text;
if (text) {
console.warn(`Advanced Camera Card: ${text}`);
}
}
}
}
@@ -1,122 +0,0 @@
import { Notification } from '../../../config/schema/actions/types.js';
import { TROUBLESHOOTING_STREAM_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js';
import { Timer } from '../../../utils/timer.js';
import {
Problem,
ProblemDynamicContext,
ProblemResult,
ProblemTriggerContext,
} from '../types.js';
const STREAM_LOADING_TIMEOUT_SECONDS = 10;
export class StreamNotLoadingProblem implements Problem {
public readonly key = 'stream_not_loading' as const;
private _problemActive = false;
private _cameraIDsWithErrors = new Set<string>();
private _timer = new Timer();
private _timerCameraID: string | null = null;
private _triggerUpdate: () => void;
constructor(triggerUpdate: () => void) {
this._triggerUpdate = triggerUpdate;
}
public trigger(context?: ProblemTriggerContext): void {
if (context?.cameraID) {
this._cameraIDsWithErrors.add(context.cameraID);
}
}
public detectDynamic(context: ProblemDynamicContext): void {
if (context.view !== 'live') {
this._deactivate();
return;
}
if (context.mediaLoaded) {
this._handleStreamLoaded(context.cameraID);
} else {
this._handleStreamNotLoaded(context.cameraID);
}
}
// Stream loaded successfully. Deactivate and clear any prior provider error
// for this camera so it won't re-trigger on the next evaluation.
private _handleStreamLoaded(cameraID?: string): void {
this._deactivate();
if (cameraID) {
this._cameraIDsWithErrors.delete(cameraID);
}
}
// Stream not yet loaded. Activate immediately if this camera has a known
// provider error, otherwise start a timeout to detect slow loads.
private _handleStreamNotLoaded(cameraID?: string): void {
if (this._hasCameraError(cameraID)) {
this._activate();
} else if (!this._problemActive) {
// Restart the timer when the selected camera changes so each camera
// gets its own timeout window.
if (!this._timer.isRunning() || this._timerCameraID !== (cameraID ?? null)) {
this._timerCameraID = cameraID ?? null;
this._timer.start(STREAM_LOADING_TIMEOUT_SECONDS, () => {
this._activate();
this._triggerUpdate();
});
}
}
}
public hasResult(): boolean {
return this._problemActive;
}
public getNotification(): Notification {
return {
heading: {
text: localize('problems.stream_not_loading.heading'),
icon: 'mdi:cctv-off',
severity: 'high',
},
text: localize('problems.stream_not_loading.text'),
link: {
url: TROUBLESHOOTING_STREAM_URL,
title: localize('problems.troubleshooting_guide'),
},
};
}
public getResult(): ProblemResult | null {
if (!this._problemActive) {
return null;
}
return {
icon: 'mdi:cctv-off',
severity: 'high',
notification: this.getNotification(),
};
}
public destroy(): void {
this._deactivate();
this._cameraIDsWithErrors.clear();
}
private _activate(): void {
this._timer.stop();
this._problemActive = true;
}
private _deactivate(): void {
this._timer.stop();
this._timerCameraID = null;
this._problemActive = false;
}
private _hasCameraError(camera?: string): boolean {
return !!camera && this._cameraIDsWithErrors.has(camera);
}
}
-57
View File
@@ -1,57 +0,0 @@
import { Notification } from '../../config/schema/actions/types';
import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { HomeAssistant } from '../../ha/types';
import { Severity } from '../../severity';
export type ProblemKey = 'config_upgrade' | 'legacy_resource' | 'stream_not_loading';
export interface ProblemResult {
icon: string;
severity: Severity;
notification: Notification;
}
export interface KeyedProblemResult {
key: ProblemKey;
problem: ProblemResult;
}
export type ProblemPresence = Partial<Record<ProblemKey, boolean>>;
export interface ProblemDynamicContext {
cameraID?: string;
view?: AdvancedCameraCardView;
mediaLoaded: boolean;
}
export interface ProblemTriggerContext {
cameraID?: string;
}
export type ProblemTriggerEventData = { key: ProblemKey } & ProblemTriggerContext;
export interface Problem {
readonly key: ProblemKey;
// One-time async detection (WS calls, config checks).
detectStatic?(hass?: HomeAssistant): Promise<void>;
// Ongoing sync evaluation, called on state changes.
detectDynamic?(context: ProblemDynamicContext): void;
// Explicitly trigger this problem.
trigger?(context?: ProblemTriggerContext): void;
hasResult(): boolean;
getResult(): ProblemResult | null;
// Return notification content regardless of active state, for
// user-initiated queries (e.g. clicking a loading icon).
getNotification?(): Notification | null;
// Optional automatic fixing.
fix?(hass: HomeAssistant): Promise<boolean>;
// Cleanup.
destroy?(): void;
}
+14 -22
View File
@@ -5,15 +5,11 @@ import { StatusBarConfig } from '../config/schema/status-bar';
import { MediaLoadedInfo } from '../types';
import { createNotificationAction } from '../utils/action';
import { View } from '../view/view';
import { KeyedProblemResult, ProblemKey } from './problems/types';
import { KeyedIssueDescription } from './issues/types';
import { CardStatusBarAPI } from './types';
const RESOLUTION_TOLERANCE_PCT = 0.01;
const problemKeyToStatusBarKey = (key: ProblemKey): keyof StatusBarConfig['items'] => {
return `problem_${key}`;
};
export class StatusBarItemManager {
private _api: CardStatusBarAPI;
@@ -48,7 +44,7 @@ export class StatusBarItemManager {
cameraManager?: CameraManager | null;
view?: View | null;
mediaLoadedInfo?: MediaLoadedInfo | null;
problems?: KeyedProblemResult[] | null;
issues?: KeyedIssueDescription[] | null;
}): StatusBarItem[] {
const cameraMetadata = options?.view?.camera
? options?.cameraManager?.getCameraMetadata(options.view.camera)
@@ -132,22 +128,18 @@ export class StatusBarItemManager {
]
: []),
...(options?.problems ?? [])
.filter(
({ key }) =>
options?.statusConfig?.items[problemKeyToStatusBarKey(key)]?.enabled !==
false,
)
.map(({ key, problem }) => ({
type: 'custom:advanced-camera-card-status-bar-icon' as const,
icon: problem.icon,
severity: problem.severity,
title: problem.notification.heading?.text,
actions: {
tap_action: createNotificationAction(problem.notification),
},
...options?.statusConfig?.items[problemKeyToStatusBarKey(key)],
})),
...(options?.statusConfig?.items.issues?.enabled === false
? []
: (options?.issues ?? []).map(({ issue }) => ({
type: 'custom:advanced-camera-card-status-bar-icon' as const,
icon: issue.icon,
severity: issue.severity,
title: issue.notification.heading?.text,
actions: {
tap_action: createNotificationAction(issue.notification),
},
...options?.statusConfig?.items.issues,
}))),
...this._dynamicItems,
];
}
+15 -1
View File
@@ -64,7 +64,7 @@ export class TriggersManager {
const hass = this._api.getHASSManager().getHASS();
let triggered = false;
let startupActionEvent: CameraEvent | null = null;
this._states.clear();
this.reset();
for (const [cameraID, camera] of this._api
.getCameraManager()
@@ -395,6 +395,20 @@ export class TriggersManager {
}
}
private _stopAllTimers(): void {
for (const [cameraID] of this._states) {
this._deleteUntriggerDelayTimer(cameraID);
this._deleteForceUntriggerTimer(cameraID);
}
}
public reset(): void {
this._throttledTriggerAction.cancel();
this._stopAllTimers();
this._states.clear();
this._setConditionStateIfNecessary();
}
private _isStateTriggered(state: CameraTriggerState): boolean {
return !!(state.sources.size || state.untriggerDelayTimer);
}
+17 -22
View File
@@ -17,14 +17,13 @@ import type { FullscreenManager } from './fullscreen/fullscreen-manager';
import type { HASSManager } from './hass/hass-manager';
import type { InitializationManager } from './initialization-manager';
import type { InteractionManager } from './interaction-manager';
import type { IssueManager } from './issues/issue-manager';
import type { KeyboardStateManager } from './keyboard-state-manager';
import type { MediaLoadedInfoManager } from './media-info-manager';
import type { MediaPlayerManager } from './media-player-manager';
import type { MessageManager } from './message-manager';
import type { MicrophoneManager } from './microphone-manager';
import type { NotificationManager } from './notification-manager';
import type { PIPManager } from './pip-manager';
import type { ProblemManager } from './problems/manager';
import type { QueryStringManager } from './query-string-manager';
import type { StatusBarItemManager } from './status-bar-item-manager';
import type { StyleManager } from './style-manager';
@@ -53,10 +52,10 @@ export interface CardActionsAPI {
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getMicrophoneManager(): MicrophoneManager;
getNotificationManager(): NotificationManager;
getPIPManager(): PIPManager;
getIssueManager(): IssueManager;
getStatusBarItemManager(): StatusBarItemManager;
getTriggersManager(): TriggersManager;
getViewItemManager(): ViewItemManager;
@@ -70,7 +69,8 @@ export interface CardAutomationsAPI {
getConditionStateManager(): ConditionStateManager;
getHASSManager(): HASSManager;
getInitializationManager(): InitializationManager;
getMessageManager(): MessageManager;
getNotificationManager(): NotificationManager;
getIssueManager(): IssueManager;
}
export interface CardCameraAPI {
@@ -79,7 +79,6 @@ export interface CardCameraAPI {
getDeviceRegistryManager(): DeviceRegistryManager;
getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager;
getMessageManager(): MessageManager;
getResolvedMediaCache(): ResolvedMediaCache;
getTriggersManager(): TriggersManager;
}
@@ -106,8 +105,8 @@ export interface CardConfigAPI {
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getMicrophoneManager(): MicrophoneManager;
getIssueManager(): IssueManager;
getStatusBarItemManager(): StatusBarItemManager;
getStyleManager(): StyleManager;
getViewManager(): ViewManager;
@@ -118,7 +117,7 @@ export interface CardConfigLoaderAPI {
getConfigManager(): ConfigManager;
getFoldersManager(): FoldersManager;
getHASSManager(): HASSManager;
getMessageManager(): MessageManager;
getIssueManager(): IssueManager;
}
export interface CardDefaultManagerAPI {
@@ -134,7 +133,7 @@ export interface CardDownloadAPI {
getCameraManager(): CameraManager;
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getNotificationManager(): NotificationManager;
getViewManager(): ViewManager;
}
@@ -154,8 +153,9 @@ export interface CardElementAPI {
getMediaPlayerManager(): MediaPlayerManager;
getMicrophoneManager(): MicrophoneManager;
getPIPManager(): PIPManager;
getProblemManager(): ProblemManager;
getIssueManager(): IssueManager;
getQueryStringManager(): QueryStringManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
}
@@ -193,7 +193,6 @@ export interface CardHASSAPI {
getInitializationManager(): InitializationManager;
getInteractionManager(): InteractionManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
@@ -209,13 +208,11 @@ export interface CardInitializerAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getConditionStateManager(): ConditionStateManager;
getDefaultManager(): DefaultManager;
getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getProblemManager(): ProblemManager;
getIssueManager(): IssueManager;
getQueryStringManager(): QueryStringManager;
getResolvedMediaCache(): ResolvedMediaCache;
getTriggersManager(): TriggersManager;
@@ -250,25 +247,22 @@ export interface CardMediaPlayerAPI {
getConfigManager(): ConfigManager;
getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager;
getMessageManager(): MessageManager;
getQueryStringManager(): QueryStringManager;
}
export interface CardMessageAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
}
export interface CardNotificationAPI {
getCardElementManager(): CardElementManager;
}
export interface CardProblemAPI {
export interface CardIssueManagerAPI {
getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getInitializationManager(): InitializationManager;
getInteractionManager(): InteractionManager;
getNotificationManager(): NotificationManager;
getViewManager(): ViewManager;
}
export interface CardMicrophoneAPI {
@@ -318,7 +312,8 @@ export interface CardViewAPI {
getHASSManager(): HASSManager;
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getNotificationManager(): NotificationManager;
getIssueManager(): IssueManager;
getQueryStringManager(): QueryStringManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
+5 -7
View File
@@ -1,7 +1,6 @@
import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { ViewDisplayMode } from '../../config/schema/common/display';
import { AdvancedCameraCardConfig } from '../../config/schema/types';
import { localize } from '../../localize/localize';
import { resolveViewName } from '../../view/utils/resolve-default';
import { View, ViewParameters } from '../../view/view';
import {
@@ -182,16 +181,16 @@ export class ViewFactory {
if (options?.failSafe && !doesViewRequireCamera(defaultViewName)) {
return { viewName: defaultViewName, cameraID: null };
}
const cameraID = this._api.getCameraManager().getStore().getDefaultCameraID();
if (options?.failSafe) {
return {
viewName: defaultViewName,
cameraID: this._api.getCameraManager().getStore().getDefaultCameraID(),
cameraID,
};
}
throw new ViewIncompatible(localize('error.no_supported_cameras'), {
throw new ViewIncompatible({
view: viewName,
camera: null,
default_view: defaultViewName,
camera: cameraID,
});
}
@@ -221,10 +220,9 @@ export class ViewFactory {
?.getCapabilities()
?.getRawCapabilities();
throw new ViewIncompatible(localize('error.no_supported_camera'), {
throw new ViewIncompatible({
view: viewName,
camera: cameraID,
default_view: defaultViewName,
...(capabilities && { camera_capabilities: capabilities }),
});
}
+10 -1
View File
@@ -1,4 +1,5 @@
import { format } from 'date-fns';
import { createNotificationFromError } from '../../components-lib/notification/factory';
import { homeAssistantGetSignedURLIfNecessary } from '../../ha/sign-path';
import { localize } from '../../localize/localize';
import { AdvancedCameraCardError } from '../../types';
@@ -37,7 +38,15 @@ export class ViewItemManager {
try {
await this._download(item);
} catch (error: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
/* istanbul ignore if: catch always provides a non-null error -- @preserve */
if (error == null) {
return false;
}
this._api.getNotificationManager().setNotification(
createNotificationFromError(error, {
heading: { text: localize('error.download_failed'), icon: 'mdi:download-off' },
}),
);
return false;
}
return true;
+5 -1
View File
@@ -75,4 +75,8 @@ export interface ViewManagerInterface {
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
}
export class ViewIncompatible extends AdvancedCameraCardError {}
export class ViewIncompatible extends AdvancedCameraCardError {
constructor(context?: unknown) {
super('', context);
}
}
+51 -11
View File
@@ -2,6 +2,7 @@ import { ViewContext } from 'view';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { View } from '../../view/view';
import { getViewTargetID } from '../../view/target-id';
import { InitializationAspect } from '../initialization-manager';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
@@ -111,19 +112,39 @@ export class ViewManager implements ViewManagerInterface {
baseView: this._view,
...options,
});
// A non-throwing factory call clears any prior view_incompatible /
// media_query state — ensures a previously-dismissed mid-session popup
// does not linger invisibly and re-pop on the next evaluation cycle,
// and that a stale media_query failure from an abandoned gallery /
// viewer doesn't follow the user into an unrelated view.
this._api.getIssueManager().reset('view_incompatible');
this._api.getIssueManager().reset('media_query');
} catch (e) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
if (!this._view) {
view = this._getFailSafeView(viewFactoryFunc);
}
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (view) {
this._setView(view);
}
}
private _markViewLoadingQuery(view: View, index: number): View {
return view.mergeInContext({ loading: { query: index } });
private _getFailSafeView(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
): View | null {
try {
return viewFactoryFunc({ baseView: null, failSafe: true });
} catch {
return null;
}
}
private _markViewAsNotLoadingQuery(view: View): View {
return view.removeContextProperty('loading', 'query');
private _markViewLoadingQuery(view: View, index: number): void {
view.mergeInContext({ loading: { query: index } });
}
private _markViewAsNotLoadingQuery(view: View): void {
view.removeContextProperty('loading', 'query');
}
private _isAllowedToSetView(): boolean {
@@ -162,8 +183,16 @@ export class ViewManager implements ViewManagerInterface {
...options?.params,
},
});
this._api.getIssueManager().reset('view_incompatible');
// A new query is about to run, so any stale media_query error from a
// previous attempt is no longer meaningful. If this new query also
// fails, it will re-trigger below.
this._api.getIssueManager().reset('media_query');
} catch (e) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
if (!this._view) {
initialView = this._getFailSafeView(viewFactoryFunc);
}
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (!initialView) {
@@ -205,13 +234,23 @@ export class ViewManager implements ViewManagerInterface {
// the contrary, small changes such as the user zooming in are fine to
// merge into the resultant view.
if (this._view.context?.loading?.query === loadingIndex) {
this._setView(this._markViewAsNotLoadingQuery(this._view.clone()));
const view = this._view.clone();
this._markViewAsNotLoadingQuery(view);
this._setView(view);
}
return;
}
if (error) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
// Clear the loading flag before surfacing the error. Otherwise the
// view stays marked in-flight and components (gallery, viewer) keep
// rendering "Awaiting media" on top of the error notification.
if (this._view?.context?.loading?.query === loadingIndex) {
const view = this._view.clone();
this._markViewAsNotLoadingQuery(view);
this._setView(view);
}
this._api.getIssueManager().trigger('media_query', { error });
return;
}
@@ -221,6 +260,8 @@ export class ViewManager implements ViewManagerInterface {
return;
}
this._api.getIssueManager().reset('media_query');
const newView = this._view.clone();
if (this._view.context?.loading?.query === loadingIndex) {
this._markViewAsNotLoadingQuery(newView);
@@ -279,7 +320,7 @@ export class ViewManager implements ViewManagerInterface {
);
}
public initialize = async (): Promise<boolean> => {
public initialize = async (): Promise<void> => {
// If the query string contains a view related action, we don't set any view
// here and allow that action to be triggered by the next call of to execute
// query actions (called at least once per render cycle).
@@ -289,7 +330,6 @@ export class ViewManager implements ViewManagerInterface {
// query is answered.
this.setViewDefaultWithNewQuery({ failSafe: true });
}
return true;
};
private _setView(view: Readonly<View> | null): void {
@@ -312,13 +352,13 @@ export class ViewManager implements ViewManagerInterface {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionStateManager()?.setState({
view: view?.view,
camera: view?.camera ?? undefined,
displayMode: view?.displayMode ?? undefined,
targetID: view ? getViewTargetID(view) ?? undefined : undefined,
});
this._api.getCardElementManager().update();