feat: Add hardened error handling and retries (#2451)
- Closes #1830 - Closes #2099
This commit is contained in:
committed by
dermotduffy
parent
4bc787e2b7
commit
47bcce93d3
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdvancedCameraCardError } from '../../types.js';
|
||||
|
||||
class ConfigError extends AdvancedCameraCardError {}
|
||||
|
||||
export class ConfigParseError extends ConfigError {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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') },
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -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)],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+21
-16
@@ -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);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user