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

- Closes #1830
 - Closes #2099
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 4bc787e2b7
commit 47bcce93d3
182 changed files with 7877 additions and 4043 deletions
+5 -7
View File
@@ -1,7 +1,6 @@
import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { ViewDisplayMode } from '../../config/schema/common/display';
import { AdvancedCameraCardConfig } from '../../config/schema/types';
import { localize } from '../../localize/localize';
import { resolveViewName } from '../../view/utils/resolve-default';
import { View, ViewParameters } from '../../view/view';
import {
@@ -182,16 +181,16 @@ export class ViewFactory {
if (options?.failSafe && !doesViewRequireCamera(defaultViewName)) {
return { viewName: defaultViewName, cameraID: null };
}
const cameraID = this._api.getCameraManager().getStore().getDefaultCameraID();
if (options?.failSafe) {
return {
viewName: defaultViewName,
cameraID: this._api.getCameraManager().getStore().getDefaultCameraID(),
cameraID,
};
}
throw new ViewIncompatible(localize('error.no_supported_cameras'), {
throw new ViewIncompatible({
view: viewName,
camera: null,
default_view: defaultViewName,
camera: cameraID,
});
}
@@ -221,10 +220,9 @@ export class ViewFactory {
?.getCapabilities()
?.getRawCapabilities();
throw new ViewIncompatible(localize('error.no_supported_camera'), {
throw new ViewIncompatible({
view: viewName,
camera: cameraID,
default_view: defaultViewName,
...(capabilities && { camera_capabilities: capabilities }),
});
}
+10 -1
View File
@@ -1,4 +1,5 @@
import { format } from 'date-fns';
import { createNotificationFromError } from '../../components-lib/notification/factory';
import { homeAssistantGetSignedURLIfNecessary } from '../../ha/sign-path';
import { localize } from '../../localize/localize';
import { AdvancedCameraCardError } from '../../types';
@@ -37,7 +38,15 @@ export class ViewItemManager {
try {
await this._download(item);
} catch (error: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
/* istanbul ignore if: catch always provides a non-null error -- @preserve */
if (error == null) {
return false;
}
this._api.getNotificationManager().setNotification(
createNotificationFromError(error, {
heading: { text: localize('error.download_failed'), icon: 'mdi:download-off' },
}),
);
return false;
}
return true;
+5 -1
View File
@@ -75,4 +75,8 @@ export interface ViewManagerInterface {
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
}
export class ViewIncompatible extends AdvancedCameraCardError {}
export class ViewIncompatible extends AdvancedCameraCardError {
constructor(context?: unknown) {
super('', context);
}
}
+51 -11
View File
@@ -2,6 +2,7 @@ import { ViewContext } from 'view';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { View } from '../../view/view';
import { getViewTargetID } from '../../view/target-id';
import { InitializationAspect } from '../initialization-manager';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
@@ -111,19 +112,39 @@ export class ViewManager implements ViewManagerInterface {
baseView: this._view,
...options,
});
// A non-throwing factory call clears any prior view_incompatible /
// media_query state — ensures a previously-dismissed mid-session popup
// does not linger invisibly and re-pop on the next evaluation cycle,
// and that a stale media_query failure from an abandoned gallery /
// viewer doesn't follow the user into an unrelated view.
this._api.getIssueManager().reset('view_incompatible');
this._api.getIssueManager().reset('media_query');
} catch (e) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
if (!this._view) {
view = this._getFailSafeView(viewFactoryFunc);
}
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (view) {
this._setView(view);
}
}
private _markViewLoadingQuery(view: View, index: number): View {
return view.mergeInContext({ loading: { query: index } });
private _getFailSafeView(
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
): View | null {
try {
return viewFactoryFunc({ baseView: null, failSafe: true });
} catch {
return null;
}
}
private _markViewAsNotLoadingQuery(view: View): View {
return view.removeContextProperty('loading', 'query');
private _markViewLoadingQuery(view: View, index: number): void {
view.mergeInContext({ loading: { query: index } });
}
private _markViewAsNotLoadingQuery(view: View): void {
view.removeContextProperty('loading', 'query');
}
private _isAllowedToSetView(): boolean {
@@ -162,8 +183,16 @@ export class ViewManager implements ViewManagerInterface {
...options?.params,
},
});
this._api.getIssueManager().reset('view_incompatible');
// A new query is about to run, so any stale media_query error from a
// previous attempt is no longer meaningful. If this new query also
// fails, it will re-trigger below.
this._api.getIssueManager().reset('media_query');
} catch (e) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
if (!this._view) {
initialView = this._getFailSafeView(viewFactoryFunc);
}
this._api.getIssueManager().trigger('view_incompatible', { error: e });
}
if (!initialView) {
@@ -205,13 +234,23 @@ export class ViewManager implements ViewManagerInterface {
// the contrary, small changes such as the user zooming in are fine to
// merge into the resultant view.
if (this._view.context?.loading?.query === loadingIndex) {
this._setView(this._markViewAsNotLoadingQuery(this._view.clone()));
const view = this._view.clone();
this._markViewAsNotLoadingQuery(view);
this._setView(view);
}
return;
}
if (error) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
// Clear the loading flag before surfacing the error. Otherwise the
// view stays marked in-flight and components (gallery, viewer) keep
// rendering "Awaiting media" on top of the error notification.
if (this._view?.context?.loading?.query === loadingIndex) {
const view = this._view.clone();
this._markViewAsNotLoadingQuery(view);
this._setView(view);
}
this._api.getIssueManager().trigger('media_query', { error });
return;
}
@@ -221,6 +260,8 @@ export class ViewManager implements ViewManagerInterface {
return;
}
this._api.getIssueManager().reset('media_query');
const newView = this._view.clone();
if (this._view.context?.loading?.query === loadingIndex) {
this._markViewAsNotLoadingQuery(newView);
@@ -279,7 +320,7 @@ export class ViewManager implements ViewManagerInterface {
);
}
public initialize = async (): Promise<boolean> => {
public initialize = async (): Promise<void> => {
// If the query string contains a view related action, we don't set any view
// here and allow that action to be triggered by the next call of to execute
// query actions (called at least once per render cycle).
@@ -289,7 +330,6 @@ export class ViewManager implements ViewManagerInterface {
// query is answered.
this.setViewDefaultWithNewQuery({ failSafe: true });
}
return true;
};
private _setView(view: Readonly<View> | null): void {
@@ -312,13 +352,13 @@ export class ViewManager implements ViewManagerInterface {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionStateManager()?.setState({
view: view?.view,
camera: view?.camera ?? undefined,
displayMode: view?.displayMode ?? undefined,
targetID: view ? getViewTargetID(view) ?? undefined : undefined,
});
this._api.getCardElementManager().update();