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
+42 -25
View File
@@ -12,32 +12,33 @@ import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { isEqual } from 'lodash-es';
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
import { dataToContext } from '../components-lib/notification/data-to-context.js';
import { SignedURLController } from '../components-lib/signed-url-controller.js';
import { Notification } from '../config/schema/actions/types.js';
import { CameraConfig } from '../config/schema/cameras.js';
import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js';
import { EnabledProxyConfig } from '../config/schema/common/proxy.js';
import { TROUBLESHOOTING_URL } from '../const.js';
import { isHassDifferent } from '../ha/is-hass-different.js';
import { HomeAssistant } from '../ha/types.js';
import defaultImage from '../images/iris-screensaver.jpg';
import { localize } from '../localize/localize.js';
import imageUpdatingPlayerStyle from '../scss/image-updating-player.scss';
import {
MediaLoadedInfo,
MediaPlayer,
MediaPlayerController,
Message,
} from '../types.js';
import { MediaLoadedInfo, MediaPlayer, MediaPlayerController } from '../types.js';
import { contentsChanged } from '../utils/basic.js';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
} from '../utils/media-info.js';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../view/target-id.js';
import { View } from '../view/view.js';
import { renderMessage } from './message.js';
import { renderNotificationBlock } from './notification/block.js';
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
@@ -109,10 +110,21 @@ export class AdvancedCameraCardImageUpdatingPlayer
() => this._getImageSource(),
() => dispatchMediaPlayEvent(this),
() => dispatchMediaPauseEvent(this),
// Clear image load errors on each timer tick so the next render retries the
// <img>. Retries are bounded by refresh_seconds, not a tight loop.
// Clear image load errors on each timer tick so the next render retries
// the <img> — but only for modes where the underlying URL genuinely
// changes between ticks (camera/entity snapshots). For mode: url, the
// same static URL will fail the same way every time, so clearing the
// error just causes a visible flicker (notification → blank <img> →
// notification) every refresh_seconds. URL-mode retries are driven by
// mediaEpoch (user-initiated or auto-retry) instead.
() => {
this._imageLoadError = false;
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
if (mode !== 'url') {
this._imageLoadError = false;
}
},
);
@@ -393,31 +405,35 @@ export class AdvancedCameraCardImageUpdatingPlayer
}
}
private _getDisplayMessage(): Message | null {
private _getDisplayNotification(): Notification | null {
const error = this._signedURLController.getError();
if (error) {
return {
type: 'error',
message: localize(
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
),
context: this.proxyConfig,
heading: {
text: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
icon: 'mdi:alert-circle',
},
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
context: this.proxyConfig ? dataToContext(this.proxyConfig) : undefined,
};
}
if (this._imageLoadError) {
return {
type: 'error',
message: localize('error.image_load_error'),
context: this.imageConfig,
heading: {
text: localize('error.image_load_error'),
icon: 'mdi:alert-circle',
},
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
context: this.imageConfig ? dataToContext(this.imageConfig) : undefined,
};
}
return null;
}
protected render(): TemplateResult | void {
const message = this._getDisplayMessage();
if (message) {
return renderMessage(message);
const notification = this._getDisplayNotification();
if (notification) {
return renderNotificationBlock(notification);
}
const src = this._cachedValueController?.getValue();
@@ -449,13 +465,14 @@ export class AdvancedCameraCardImageUpdatingPlayer
cameraConfig: this.cameraConfig,
});
if (mode === 'camera' || mode === 'entity' || mode === 'screensaver') {
// In camera, entity, or screensaver mode the user has likely
// not made an error, but the source may be unavailable, so show
// the stock image.
this._forceSafeImage(true);
} else if (mode === 'url') {
this._imageLoadError = true;
}
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this, 'issue:trigger', {
key: 'media_load',
targetID: IMAGE_VIEW_TARGET_ID_SENTINEL,
});
}}
/>
`