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
+1 -4
View File
@@ -4,7 +4,6 @@ import {
AdvancedCameraCardMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
} from '../../utils/media-info.js';
import { AdvancedCameraCardMessageEventTarget } from '../message/dispatch.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
@@ -23,9 +22,7 @@ interface LastMediaLoadedInfo {
source: EventTarget;
}
type LiveControllerHost = LitElement &
AdvancedCameraCardMediaLoadedEventTarget &
AdvancedCameraCardMessageEventTarget;
type LiveControllerHost = LitElement & AdvancedCameraCardMediaLoadedEventTarget;
export class LiveController implements ReactiveController {
private _host: LiveControllerHost;
@@ -33,8 +33,8 @@ export interface NotificationControlsContext {
filterReviewed?: boolean;
}
export class MediaDetailsController {
private _details: NotificationDetail[] = [];
export class MediaNotificationController {
private _metadata: NotificationDetail[] = [];
private _heading: NotificationDetail | null = null;
private _item: ViewItem | null = null;
@@ -50,7 +50,7 @@ export class MediaDetailsController {
: null;
this._calculateHeading(cameraMetadata, item);
this._calculateDetails(cameraMetadata, item, seek);
this._calculateMetadata(cameraMetadata, item, seek);
}
private _calculateHeading(
@@ -99,7 +99,7 @@ export class MediaDetailsController {
this._heading = null;
}
private _calculateDetails(
private _calculateMetadata(
cameraMetadata: CameraManagerCameraMetadata | null,
item?: ViewItem,
seek?: Date,
@@ -111,7 +111,7 @@ export class MediaDetailsController {
const duration = startTime && endTime ? getDurationString(startTime, endTime) : null;
const inProgress = ViewItemClassifier.isMedia(item)
? item.inProgress()
? localize('thumbnail.in_progress')
? localize('common.in_progress')
: null
: null;
const where = ViewItemClassifier.isMedia(item)
@@ -184,7 +184,7 @@ export class MediaDetailsController {
const includeTitle =
(!ViewItemClassifier.isEvent(item) && !ViewItemClassifier.isReview(item)) ||
!startTime;
this._details = [
this._metadata = [
...(includeTitle && itemTitle
? [
{
@@ -204,18 +204,20 @@ export class MediaDetailsController {
return this._heading;
}
public getDetails(): NotificationDetail[] {
return this._details;
public getMetadata(): NotificationDetail[] {
return this._metadata;
}
public getNotification(context?: NotificationControlsContext): Notification {
const description = ViewItemClassifier.isMedia(this._item)
? this._item.getDescription()
: null;
return {
heading: this._heading ?? undefined,
controls: context ? this._getControls(context) : undefined,
details: this._details,
text: ViewItemClassifier.isMedia(this._item)
? this._item.getDescription() ?? undefined
: undefined,
metadata: this._metadata,
body: description ? { text: description } : undefined,
};
}
-45
View File
@@ -1,45 +0,0 @@
import yaml from 'js-yaml';
import { Link } from '../../config/schema/common/link.js';
import { TROUBLESHOOTING_URL } from '../../const';
import { localize } from '../../localize/localize.js';
import { Message } from '../../types';
export class MessageController {
public getMessageString(message: Message): string {
return (
message.message +
(message.context && typeof message.context === 'string'
? ': ' + message.context
: '')
);
}
public getIcon(message: Message): string {
return message.icon
? message.icon
: message.type === 'error'
? 'mdi:alert-circle'
: 'mdi:information-outline';
}
public getLink(message: Message): Link | null {
return message.link
? message.link
: message.type === 'error'
? { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') }
: null;
}
public getContextStrings(message: Message): string[] {
if (Array.isArray(message.context)) {
return message.context.map((contextItem) => yaml.dump(contextItem));
}
if (typeof message.context === 'object') {
return [yaml.dump(message.context)];
}
if (typeof message.context === 'string') {
return [message.context];
}
return [];
}
}
-53
View File
@@ -1,53 +0,0 @@
import { AdvancedCameraCardError, Message } from '../../types';
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
// Facilitates correct typing of event handlers.
export interface AdvancedCameraCardMessageEventTarget extends EventTarget {
addEventListener(
event: 'advanced-camera-card:message',
listener: (
this: AdvancedCameraCardMessageEventTarget,
ev: CustomEvent<Message>,
) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: 'advanced-camera-card:message',
listener: (
this: AdvancedCameraCardMessageEventTarget,
ev: CustomEvent<Message>,
) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
/**
* Dispatch an event with an error message to show to the user. Calling this
* method will grind the card to a halt, so should only be used for "global" /
* critical errors (i.e. not for individual errors with a given camera, since
* there may be multiple correctly functioning cameras in a grid).
* @param element The element to send the event.
* @param message The message to show.
*/
export const dispatchAdvancedCameraCardErrorEvent = (
element: EventTarget,
error: unknown,
): void => {
if (error instanceof Error) {
fireAdvancedCameraCardEvent<Message>(element, 'message', {
message: error.message,
type: 'error',
...(error instanceof AdvancedCameraCardError && { context: error.context }),
});
}
};
+23
View File
@@ -0,0 +1,23 @@
import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request.js';
import { NotificationControl } from '../../config/schema/actions/types.js';
import {
getActionConfigGivenAction,
stopEventFromActivatingCardWideActions,
} from '../../utils/action.js';
import { arrayify } from '../../utils/basic.js';
export function handleControlAction(
ev: CustomEvent<{ action: string }>,
control: NotificationControl,
host: HTMLElement,
onDismiss?: () => void,
): void {
stopEventFromActivatingCardWideActions(ev);
const action = getActionConfigGivenAction(ev.detail.action, control.actions);
if (action) {
dispatchActionExecutionRequest(host, { actions: arrayify(action) });
}
if (onDismiss && control.dismiss !== false) {
onDismiss();
}
}
@@ -0,0 +1,12 @@
import yaml from 'js-yaml';
// Converts a structured data object into preformatted text strings for a
// notification's context section. Arrays produce one string per item (strings
// pass through; objects are YAML-dumped); all other objects produce a single
// YAML-dumped string.
export const dataToContext = (data: object): string[] => {
if (Array.isArray(data)) {
return data.map((item) => (typeof item === 'string' ? item : yaml.dump(item)));
}
return [yaml.dump(data)];
};
@@ -0,0 +1,56 @@
import { Notification, NotificationDetail } from '../../config/schema/actions/types.js';
import { Link } from '../../config/schema/common/link.js';
import { getContextFromError } from '../../utils/error-context.js';
import { dataToContext } from './data-to-context.js';
const DEFAULT_ERROR_ICON = 'mdi:alert';
export interface NotificationOptions {
heading?: NotificationDetail;
icon?: string;
link?: Link;
metadata?: NotificationDetail[];
context?: object;
in_progress?: boolean;
}
export const createNotificationFromText = (
text: string,
options?: NotificationOptions,
): Notification => ({
...(options?.heading && { heading: options.heading }),
body: {
text,
...((options?.icon || !options?.heading) && {
icon: options?.icon ?? DEFAULT_ERROR_ICON,
}),
},
...(options?.metadata && { metadata: options.metadata }),
...(options?.link && { link: options.link }),
...(options?.context && {
context: dataToContext(options.context),
}),
...(options?.in_progress !== undefined && { in_progress: options.in_progress }),
});
export const createNotificationFromError = (
error: NonNullable<unknown>,
options?: NotificationOptions,
): Notification => {
const text =
typeof error === 'object' && 'message' in error
? String(error.message)
: typeof error === 'object'
? JSON.stringify(error)
: String(error);
const context = options?.context ?? getContextFromError(error) ?? undefined;
return createNotificationFromText(text, {
...options,
context,
heading: options?.heading
? { icon: DEFAULT_ERROR_ICON, severity: 'high', ...options.heading }
: undefined,
});
};
@@ -0,0 +1,10 @@
import { Notification } from '../../config/schema/actions/types.js';
export const summarizeNotification = (notification: Notification): string | null => {
const text = notification.body?.text ?? notification.heading?.text ?? null;
if (!text) {
return null;
}
const metadata = notification.metadata?.map((m) => m.text).join(', ');
return metadata ? `${text} [${metadata}]` : text;
};
+25 -5
View File
@@ -35,11 +35,25 @@ export class StatusBarController {
const sufficientBefore = this._getSufficientValues(this._items);
const sufficientAfter = this._getSufficientValues(newItems);
const hadPermanent = this._hasPermanentItems(this._items);
this._items = newItems;
if (this._config?.style === 'popup' && !isEqual(sufficientBefore, sufficientAfter)) {
this._show();
this._popupTimer.start(this._config.popup_seconds, () => this._hide());
if (this._config?.style === 'popup') {
const hasPermanent = this._hasPermanentItems(newItems);
const sufficientChanged = !isEqual(sufficientBefore, sufficientAfter);
if (hasPermanent) {
// Permanent items present: show and cancel any running timer.
this._show();
this._popupTimer.stop();
} else if (sufficientChanged) {
// Sufficient items changed without permanent items: normal popup.
this._show();
this._popupTimer.start(this._config.popup_seconds, () => this._hide());
} else if (hadPermanent && !hasPermanent) {
// Permanent items just removed: start the popup timer to fade out.
this._popupTimer.start(this._config.popup_seconds, () => this._hide());
}
}
this._host.requestUpdate();
@@ -55,7 +69,7 @@ export class StatusBarController {
this._host.setAttribute('data-style', config.style);
this._host.setAttribute('data-position', config.position);
if (this._config?.style !== 'popup') {
if (this._config?.style !== 'popup' || this._hasPermanentItems(this._items)) {
this._show();
}
@@ -67,7 +81,9 @@ export class StatusBarController {
}
public shouldRender(): boolean {
return this._items.some((item) => item.enabled !== false && item.sufficient);
return this._items.some(
(item) => item.enabled !== false && (item.sufficient || item.permanent),
);
}
public actionHandler(
@@ -109,6 +125,10 @@ export class StatusBarController {
.map((item) => this._getSufficientValue(item));
}
private _hasPermanentItems(items: StatusBarItem[]): boolean {
return items.some((item) => item.enabled !== false && item.permanent);
}
private _show(): void {
setOrRemoveAttribute(this._host, false, 'hide');
}