fix: Substantially reduce cases where errors block whole card (#1764)

This commit is contained in:
Dermot Duffy
2024-12-15 20:39:18 -08:00
committed by GitHub
parent b6fb821074
commit dab9ff3045
25 changed files with 329 additions and 407 deletions
+2 -35
View File
@@ -1,6 +1,6 @@
import { LitElement, ReactiveController } from 'lit';
import { FrigateCardMessageEventTarget } from '../../components/message.js';
import { MediaLoadedInfo, Message } from '../../types.js';
import { MediaLoadedInfo } from '../../types.js';
import {
FrigateCardMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
@@ -38,16 +38,11 @@ export class LiveController implements ReactiveController {
// foreground and background (in preload mode).
protected _intersectionObserver: IntersectionObserver;
// Whether or not to allow updates.
protected _messageReceived = false;
// MediaLoadedInfo object and target from the underlying live media. In the
// case of pre-loading these may be propagated later (from the original
// source).
protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null;
protected _renderEpoch = 0;
constructor(host: LiveControllerHost) {
this._host = host;
@@ -58,50 +53,22 @@ export class LiveController implements ReactiveController {
);
}
public shouldUpdate(): boolean {
// Don't process updates if it's in the background and a message was
// received (otherwise an error message thrown by the background live
// component may continually be re-spammed hitting performance).
return !(this._inBackground && this._messageReceived);
}
public hostConnected(): void {
this._intersectionObserver.observe(this._host);
this._host.addEventListener('frigate-card:media:loaded', this._handleMediaLoaded);
this._host.addEventListener('frigate-card:message', this._handleMessage);
}
public hostDisconnected(): void {
this._intersectionObserver.disconnect();
this._host.removeEventListener('frigate-card:media:loaded', this._handleMediaLoaded);
this._host.removeEventListener('frigate-card:message', this._handleMessage);
}
public clearMessageReceived(): void {
this._messageReceived = false;
}
public isInBackground(): boolean {
return this._inBackground;
}
public getRenderEpoch(): number {
return this._renderEpoch;
}
protected _handleMessage = (ev: CustomEvent<Message>): void => {
this._messageReceived = true;
if (this._inBackground) {
ev.stopPropagation();
// Force the whole DOM to re-render next time.
this._renderEpoch++;
}
};
protected _handleMediaLoaded = (ev: CustomEvent<MediaLoadedInfo>): void => {
this._lastMediaLoadedInfo = {
source: ev.composedPath()[0],
@@ -117,7 +84,7 @@ export class LiveController implements ReactiveController {
const wasInBackground = this._inBackground;
this._inBackground = !entries.some((entry) => entry.isIntersecting);
if (!this._inBackground && !this._messageReceived && this._lastMediaLoadedInfo) {
if (!this._inBackground && this._lastMediaLoadedInfo) {
// If this isn't being rendered in the background, the last render did not
// generate a message and there's a saved MediaInfo, dispatch it upwards.
dispatchExistingMediaLoadedInfoAsEvent(
@@ -0,0 +1,5 @@
import { dispatchFrigateCardEvent } from '../../../utils/basic';
export function dispatchLiveErrorEvent(element: EventTarget): void {
dispatchFrigateCardEvent(element, 'live:error');
}
+19 -5
View File
@@ -8,7 +8,7 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
@@ -18,7 +18,7 @@ import { CameraConfig, ImageMode, ImageViewConfig } from '../config/types.js';
import defaultImage from '../images/frigate-bird-in-sky.jpg';
import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss';
import { FrigateCardMediaPlayer, MediaLoadedInfo } from '../types.js';
import { FrigateCardMediaPlayer, MediaLoadedInfo, Message } from '../types.js';
import { contentsChanged } from '../utils/basic.js';
import { isHassDifferent } from '../utils/ha';
import {
@@ -28,7 +28,7 @@ import {
dispatchMediaPlayEvent,
} from '../utils/media-info.js';
import { View } from '../view/view.js';
import { dispatchErrorMessageEvent } from './message.js';
import { renderMessage } from './message.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;
@@ -53,6 +53,9 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
@property({ attribute: false, hasChanged: contentsChanged })
public imageConfig?: ImageViewConfig;
@state()
protected _message: Message | null = null;
protected _refImage: Ref<HTMLImageElement> = createRef();
protected _cachedValueController?: CachedValueController<string>;
@@ -175,6 +178,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
if (!this._cachedValueController?.value) {
this._cachedValueController?.updateValue();
}
if (['imageConfig', 'view'].some((prop) => changedProps.has(prop))) {
this._message = null;
}
}
/**
@@ -211,6 +218,7 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
*/
disconnectedCallback(): void {
this._cachedValueController?.stopTimer();
this._message = null;
document.removeEventListener('visibilitychange', this._boundVisibilityHandler);
super.disconnectedCallback();
}
@@ -329,6 +337,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
}
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
}
const src = this._cachedValueController?.value;
// Note the use of live() below to ensure the update will restore the image
// src if it's been changed via _forceSafeImage().
@@ -363,9 +375,11 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
} else if (mode === 'url') {
// In url mode, the user likely specified a URL that cannot be
// resolved. Show an error message.
dispatchErrorMessageEvent(this, localize('error.image_load_error'), {
this._message = {
type: 'error',
message: localize('error.image_load_error'),
context: this.imageConfig,
});
};
}
}}
/>
+17 -40
View File
@@ -1,13 +1,5 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ConditionsManagerEpoch } from '../../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
@@ -53,15 +45,6 @@ export class FrigateCardLive extends LitElement {
protected _controller = new LiveController(this);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_changedProps: PropertyValues): boolean {
return this._controller.shouldUpdate();
}
protected willUpdate(): void {
this._controller.clearMessageReceived();
}
protected render(): TemplateResult | void {
if (!this.hass || !this.nonOverriddenLiveConfig || !this.cameraManager) {
return;
@@ -73,28 +56,22 @@ export class FrigateCardLive extends LitElement {
// carousel (not necessarily the selected camera).
// - Various events are captured to prevent them propagating upwards if the
// card is in the background.
// - The entire returned template is keyed to allow for the whole template
// to be re-rendered in certain circumstances (specifically: if a message
// is received when the card is in the background).
return html`${keyed(
this._controller.getRenderEpoch(),
html`
<frigate-card-live-grid
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this._controller.isInBackground()}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.overrides=${this.overrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneManager=${this.microphoneManager}
.triggeredCameraIDs=${this.triggeredCameraIDs}
>
</frigate-card-live-grid>
`,
)}`;
return html`
<frigate-card-live-grid
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this._controller.isInBackground()}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.overrides=${this.overrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneManager=${this.microphoneManager}
.triggeredCameraIDs=${this.triggeredCameraIDs}
>
</frigate-card-live-grid>
`;
}
static get styles(): CSSResultGroup {
+38 -11
View File
@@ -23,7 +23,6 @@ import { localize } from '../../localize/localize.js';
import liveProviderStyle from '../../scss/live-provider.scss';
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js';
import { aspectRatioToString } from '../../utils/basic.js';
import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js';
@@ -31,6 +30,7 @@ import { renderMessage } from '../message.js';
import '../next-prev-control.js';
import '../ptz.js';
import '../surround.js';
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
@customElement('frigate-card-live-provider')
export class FrigateCardLiveProvider
@@ -71,6 +71,9 @@ export class FrigateCardLiveProvider
@state()
protected _isVideoMediaLoaded = false;
@state()
protected _hasProviderError = false;
protected _refProvider: Ref<LitElement & FrigateCardMediaPlayer> = createRef();
// A note on dynamic imports:
@@ -165,7 +168,9 @@ export class FrigateCardLiveProvider
return (
!!this.cameraConfig?.camera_entity &&
!!this.hass &&
!!this.liveConfig?.show_image_during_load
!!this.liveConfig?.show_image_during_load &&
// Do not continue to show image during loading if an error has occurred.
!this._hasProviderError
);
}
@@ -177,6 +182,10 @@ export class FrigateCardLiveProvider
this._isVideoMediaLoaded = true;
}
protected _providerErrorHandler(): void {
this._hasProviderError = true;
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('load')) {
if (!this.load) {
@@ -263,17 +272,30 @@ export class FrigateCardLiveProvider
};
if (provider === 'ha' || provider === 'image') {
const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
if (!stateObj) {
return;
if (!this.cameraConfig?.camera_entity) {
dispatchLiveErrorEvent(this);
return renderMessage({
message: localize('error.no_live_camera'),
type: 'error',
icon: 'mdi:camera',
context: this.cameraConfig,
});
}
if (stateObj.state === 'unavailable') {
dispatchMediaUnloadedEvent(this);
// An unavailable camera gets a message rendered in place vs dispatched,
// as this may be a common occurrence (e.g. Frigate cameras that stop
// receiving frames). Otherwise a single temporarily unavailable camera
// would render a whole carousel inoperable.
const stateObj = this.hass.states[this.cameraConfig.camera_entity];
if (!stateObj) {
dispatchLiveErrorEvent(this);
return renderMessage({
message: localize('error.live_camera_not_found'),
type: 'error',
icon: 'mdi:camera',
context: this.cameraConfig,
});
}
if (stateObj.state === 'unavailable') {
dispatchLiveErrorEvent(this);
dispatchMediaUnloadedEvent(this);
return renderMessage({
message: `${localize('error.live_camera_unavailable')}${
this.label ? `: ${this.label}` : ''
@@ -291,6 +313,7 @@ export class FrigateCardLiveProvider
${ref(this._refProvider)}
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
@frigate-card:live:error=${() => this._providerErrorHandler()}
@frigate-card:media:loaded=${(ev: Event) => {
if (provider === 'image') {
// Only count the media has loaded if the required provider is
@@ -311,6 +334,7 @@ export class FrigateCardLiveProvider
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
?controls=${this.liveConfig.controls.builtin}
@frigate-card:live:error=${() => this._providerErrorHandler()}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-ha>`
@@ -324,6 +348,7 @@ export class FrigateCardLiveProvider
.microphoneStream=${this.microphoneStream}
.microphoneConfig=${this.liveConfig.microphone}
?controls=${this.liveConfig.controls.builtin}
@frigate-card:live:error=${() => this._providerErrorHandler()}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-go2rtc>`
@@ -336,6 +361,7 @@ export class FrigateCardLiveProvider
.cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig}
?controls=${this.liveConfig.controls.builtin}
@frigate-card:live:error=${() => this._providerErrorHandler()}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-webrtc-card>`
@@ -347,6 +373,7 @@ export class FrigateCardLiveProvider
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig}
@frigate-card:live:error=${() => this._providerErrorHandler()}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
</frigate-card-live-jsmpeg>`
+34 -9
View File
@@ -6,17 +6,22 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';
import { CameraEndpoints } from '../../../../camera-manager/types.js';
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
import { CameraConfig, MicrophoneConfig } from '../../../../config/types.js';
import { localize } from '../../../../localize/localize.js';
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../../../types.js';
import { getEndpointAddressOrDispatchError } from '../../../../utils/endpoint.js';
import {
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
Message,
} from '../../../../types.js';
import { convertEndpointAddressToSignedWebsocket } from '../../../../utils/endpoint.js';
import { setControlsOnVideo } from '../../../../utils/media.js';
import { screenshotMedia } from '../../../../utils/screenshot.js';
import '../../../image.js';
import { dispatchErrorMessageEvent } from '../../../message.js';
import { renderMessage } from '../../../message.js';
import { VideoRTC } from './video-rtc.js';
customElements.define('frigate-card-live-go2rtc-player', VideoRTC);
@@ -48,6 +53,9 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
@property({ attribute: true, type: Boolean })
public controls = false;
@state()
protected _message: Message | null = null;
protected _player?: VideoRTC;
public async play(): Promise<void> {
@@ -96,6 +104,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
disconnectedCallback(): void {
this._player = undefined;
this._message = null;
}
connectedCallback(): void {
@@ -113,18 +122,27 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
const endpoint = this.cameraEndpoints?.go2rtc;
if (!endpoint) {
return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), {
this._message = {
type: 'error',
message: localize('error.live_camera_no_endpoint'),
context: this.cameraConfig,
});
};
dispatchLiveErrorEvent(this);
return;
}
const address = await getEndpointAddressOrDispatchError(
this,
const address = await convertEndpointAddressToSignedWebsocket(
this.hass,
endpoint,
GO2RTC_URL_SIGN_EXPIRY_SECONDS,
);
if (!address) {
this._message = {
type: 'error',
message: localize('error.failed_sign'),
context: this.cameraConfig,
};
dispatchLiveErrorEvent(this);
return;
}
@@ -143,7 +161,11 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
}
protected willUpdate(changedProps: PropertyValues): void {
if (!this._player || changedProps.has('cameraEndpoints')) {
if (changedProps.has('cameraEndpoints')) {
this._message = null;
}
if (!this._message && (!this._player || changedProps.has('cameraEndpoints'))) {
this._createPlayer();
}
@@ -163,6 +185,9 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
}
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
}
return html`${this._player}`;
}
+3 -16
View File
@@ -3,14 +3,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../../config/types';
import { localize } from '../../../localize/localize';
import '../../../patches/ha-camera-stream';
import '../../../patches/ha-hls-player.js';
import '../../../patches/ha-web-rtc-player.js';
import liveHAStyle from '../../../scss/live-ha.scss';
import { FrigateCardMediaPlayer } from '../../../types.js';
import { renderMessage } from '../../message';
import { getStateObjOrDispatchError } from '../../../utils/get-state-obj';
@customElement('frigate-card-live-ha')
export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer {
@@ -66,22 +63,12 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
return;
}
const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
if (!stateObj) {
return;
}
if (stateObj.state === 'unavailable') {
return renderMessage({
message: localize('error.live_camera_unavailable'),
type: 'error',
icon: 'mdi:connection',
context: this.cameraConfig,
});
}
return html` <frigate-card-ha-camera-stream
${ref(this._playerRef)}
.hass=${this.hass}
.stateObj=${stateObj}
.stateObj=${this.cameraConfig?.camera_entity
? this.hass.states[this.cameraConfig.camera_entity]
: undefined}
.controls=${this.controls}
.muted=${true}
>
-3
View File
@@ -5,7 +5,6 @@ import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../../config/types';
import basicBlockStyle from '../../../scss/basic-block.scss';
import { FrigateCardMediaPlayer } from '../../../types.js';
import { getStateObjOrDispatchError } from '../../../utils/get-state-obj';
import '../../image.js';
@customElement('frigate-card-live-image')
@@ -59,8 +58,6 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
return;
}
getStateObjOrDispatchError(this, this.hass, this.cameraConfig);
return html`
<frigate-card-image
${ref(this._refImage)}
+55 -11
View File
@@ -1,21 +1,32 @@
import JSMpeg from '@cycjimmy/jsmpeg-player';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js';
import { CameraEndpoints } from '../../../camera-manager/types.js';
import { renderProgressIndicator } from '../../message.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { CameraConfig, CardWideConfig } from '../../../config/types.js';
import { localize } from '../../../localize/localize.js';
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../../types.js';
import { getEndpointAddressOrDispatchError } from '../../../utils/endpoint.js';
import {
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
Message,
} from '../../../types.js';
import { convertEndpointAddressToSignedWebsocket } from '../../../utils/endpoint.js';
import {
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
dispatchMediaPlayEvent,
} from '../../../utils/media-info.js';
import { Timer } from '../../../utils/timer.js';
import { dispatchErrorMessageEvent } from '../../message.js';
import { renderMessage, renderProgressIndicator } from '../../message.js';
// Number of seconds a signed URL is valid for.
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@@ -40,6 +51,9 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
protected _refreshPlayerTimer = new Timer();
@state()
protected _message: Message | null = null;
public async play(): Promise<void> {
return this._jsmpegVideoPlayer?.play();
}
@@ -84,6 +98,14 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null;
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
) {
this._message = null;
}
}
/**
* Create a JSMPEG player.
* @param url The URL for the player to connect to.
@@ -150,6 +172,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
* Reset / destroy the player.
*/
protected _resetPlayer(): void {
this._message = null;
this._refreshPlayerTimer.stop();
if (this._jsmpegVideoPlayer) {
try {
@@ -199,18 +222,27 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
const endpoint = this.cameraEndpoints?.jsmpeg;
if (!endpoint) {
return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), {
this._message = {
message: localize('error.live_camera_no_endpoint'),
type: 'error',
context: this.cameraConfig,
});
};
dispatchLiveErrorEvent(this);
return;
}
const address = await getEndpointAddressOrDispatchError(
this,
const address = await convertEndpointAddressToSignedWebsocket(
this.hass,
endpoint,
JSMPEG_URL_SIGN_EXPIRY_SECONDS,
);
if (!address) {
this._message = {
type: 'error',
message: localize('error.failed_sign'),
context: this.cameraConfig,
};
dispatchLiveErrorEvent(this);
return;
}
@@ -225,11 +257,23 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
* Master render method.
*/
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
}
const _render = async (): Promise<TemplateResult | void> => {
await this._refreshPlayer();
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player'));
if (!this._message) {
this._message = {
message: localize('error.jsmpeg_no_player'),
type: 'error',
context: this.cameraConfig,
};
dispatchLiveErrorEvent(this);
}
return;
}
return html`${this._jsmpegCanvasElement}`;
};
+45 -22
View File
@@ -1,13 +1,21 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { Task } from '@lit-labs/task';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { CameraEndpoints } from '../../../camera-manager/types.js';
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
import { CameraConfig, CardWideConfig } from '../../../config/types.js';
import { localize } from '../../../localize/localize.js';
import liveWebRTCCardStyle from '../../../scss/live-webrtc-card.scss';
import { FrigateCardError, FrigateCardMediaPlayer } from '../../../types.js';
import { FrigateCardError, FrigateCardMediaPlayer, Message } from '../../../types.js';
import { mayHaveAudio } from '../../../utils/audio.js';
import {
dispatchMediaLoadedEvent,
@@ -22,7 +30,7 @@ import {
} from '../../../utils/media.js';
import { screenshotMedia } from '../../../utils/screenshot.js';
import { renderTask } from '../../../utils/task.js';
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../../message.js';
import { renderMessage, renderProgressIndicator } from '../../message.js';
import { VideoRTC } from './go2rtc/video-rtc.js';
// Create a wrapper for AlexxIT's WebRTC card
@@ -44,6 +52,9 @@ export class FrigateCardLiveWebRTCCard
@property({ attribute: true, type: Boolean })
public controls = false;
@state()
protected _message: Message | null = null;
protected hass?: HomeAssistant;
// A task to await the load of the WebRTC component.
@@ -106,6 +117,19 @@ export class FrigateCardLiveWebRTCCard
this.requestUpdate();
}
disconnectedCallback(): void {
this._message = null;
super.disconnectedCallback();
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
) {
this._message = null;
}
}
protected _getVideoRTC(): VideoRTC | null {
return (this.renderRoot?.querySelector('#webrtc') ?? null) as VideoRTC | null;
}
@@ -162,23 +186,28 @@ export class FrigateCardLiveWebRTCCard
return null;
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (this._message) {
return renderMessage(this._message);
}
const render = (): TemplateResult | void => {
let webrtcElement: HTMLElement | null;
try {
webrtcElement = this._createWebRTC();
} catch (e) {
return dispatchErrorMessageEvent(
this,
e instanceof FrigateCardError
? e.message
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
{ context: (e as FrigateCardError).context },
);
this._message = {
type: 'error',
message:
e instanceof FrigateCardError
? e.message
: localize('error.webrtc_card_reported_error') +
': ' +
(e as Error).message,
context: (e as FrigateCardError).context,
};
dispatchLiveErrorEvent(this);
return;
}
if (webrtcElement) {
// Set the id to ensure that the relevant CSS styles will have
@@ -192,7 +221,7 @@ export class FrigateCardLiveWebRTCCard
// Use a task to allow us to asynchronously wait for the WebRTC card to
// load, but yet still have the card load be followed by the updated()
// lifecycle callback (unlike just using `until`).
return renderTask(this, this._webrtcTask, render, {
return renderTask(this._webrtcTask, render, {
inProgressFunc: () =>
renderProgressIndicator({
message: localize('error.webrtc_card_waiting'),
@@ -201,9 +230,6 @@ export class FrigateCardLiveWebRTCCard
});
}
/**
* Updated lifecycle callback.
*/
public updated(): void {
// Extract the video component after it has been rendered and generate the
// media load event.
@@ -232,9 +258,6 @@ export class FrigateCardLiveWebRTCCard
});
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(liveWebRTCCardStyle);
}
+14 -5
View File
@@ -152,12 +152,15 @@ export function renderProgressIndicator(options?: {
}
/**
* Dispatch an event with a message to show to the user.
* Dispatch an event with a 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.
* @param options Optional icon and context to include.
*/
export function dispatchMessageEvent(
function dispatchMessageEvent(
element: EventTarget,
message: string,
type: MessageType,
@@ -175,12 +178,15 @@ export function dispatchMessageEvent(
}
/**
* Dispatch an event with an error message to show to the user.
* 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.
* @param options Optional context to include.
*/
export function dispatchErrorMessageEvent(
function dispatchErrorMessageEvent(
element: EventTarget,
message: string,
options?: {
@@ -193,7 +199,10 @@ export function dispatchErrorMessageEvent(
}
/**
* Dispatch an event with an error message to show to the user.
* 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.
*/
+17 -4
View File
@@ -37,6 +37,9 @@ export class FrigateCardNextPreviousControl extends LitElement {
// Label that is used for ARIA support and as tooltip.
@property() label = '';
@state()
protected _thumbnailError = false;
protected _embedThumbnailTask = createFetchThumbnailTask(
this,
() => this.hass,
@@ -49,7 +52,9 @@ export class FrigateCardNextPreviousControl extends LitElement {
}
const renderIcon =
!this.thumbnail || ['chevrons', 'icons'].includes(this._controlConfig.style);
!this.thumbnail ||
['chevrons', 'icons'].includes(this._controlConfig.style) ||
this._thumbnailError;
const classes = {
controls: true,
@@ -62,7 +67,10 @@ export class FrigateCardNextPreviousControl extends LitElement {
if (renderIcon) {
const icon =
!this.thumbnail || !this.icon || this._controlConfig.style === 'chevrons'
!this.thumbnail ||
!this.icon ||
this._controlConfig.style === 'chevrons' ||
this._thumbnailError
? this.side === 'left'
? 'mdi:chevron-left'
: 'mdi:chevron-right'
@@ -74,7 +82,6 @@ export class FrigateCardNextPreviousControl extends LitElement {
}
return renderTask(
this,
this._embedThumbnailTask,
(embeddedThumbnail: string | null) =>
embeddedThumbnail
@@ -85,7 +92,13 @@ export class FrigateCardNextPreviousControl extends LitElement {
aria-label="${this.label}"
/>`
: html``,
{ inProgressFunc: () => html`<div class=${classMap(classes)}></div>` },
{
inProgressFunc: () => html`<div class=${classMap(classes)}></div>`,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
errorFunc: (_e: Error) => {
this._thumbnailError = true;
},
},
);
}
+11 -4
View File
@@ -8,7 +8,7 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { CameraManagerCameraMetadata } from '../camera-manager/types.js';
@@ -45,6 +45,9 @@ export class FrigateCardThumbnailFeatureThumbnail extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@state()
protected _thumbnailError = false;
protected _embedThumbnailTask?: Task<FetchThumbnailTaskArgs, string | null>;
// Only load thumbnails on view in case there is a very large number of them.
@@ -107,17 +110,21 @@ export class FrigateCardThumbnailFeatureThumbnail extends LitElement {
title=${localize('thumbnail.no_thumbnail')}
></ha-icon> `;
if (!this._embedThumbnailTask) {
if (!this._embedThumbnailTask || this._thumbnailError) {
return imageOff;
}
return html`${this.thumbnail
? renderTask(
this,
this._embedThumbnailTask,
(embeddedThumbnail: string | null) =>
embeddedThumbnail ? html`<img src="${embeddedThumbnail}" />` : html``,
{ inProgressFunc: () => imageOff },
{
inProgressFunc: () => imageOff,
errorFunc: () => {
this._thumbnailError = true;
},
},
)
: imageOff} `;
}
+9 -2
View File
@@ -38,7 +38,7 @@ import { getTextDirection } from '../../utils/text-direction.js';
import { ViewMedia } from '../../view/media.js';
import '../carousel';
import type { EmblaCarouselPlugins } from '../carousel.js';
import { dispatchMessageEvent } from '../message.js';
import { renderMessage } from '../message.js';
import '../next-prev-control.js';
import '../ptz.js';
import './provider.js';
@@ -348,8 +348,15 @@ export class FrigateCardViewerCarousel extends LitElement {
protected render(): TemplateResult | void {
const mediaCount = this._media?.length ?? 0;
if (!this._media || !mediaCount) {
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
return renderMessage({
message: localize('common.no_media'),
type: 'info',
icon: 'mdi:multimedia',
...(this.viewFilterCameraID && {
context: {
camera_id: this.viewFilterCameraID,
},
}),
});
}
+1 -1
View File
@@ -1,5 +1,5 @@
export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const;
export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const;
export const TROUBLESHOOTING_URL = `https://card.camera/#/troubleshooting` as const;
export const CONF_AUTOMATIONS = 'automations' as const;
+10 -3
View File
@@ -12,7 +12,8 @@
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.js';
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
import { renderMessage } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js';
import { mayHaveAudio } from '../utils/audio.js';
@@ -97,8 +98,14 @@ customElements.whenDefined('ha-hls-player').then(() => {
protected render(): TemplateResult {
if (this._error) {
if (this._errorIsFatal) {
// Use native Frigate card error handling for fatal errors.
return dispatchErrorMessageEvent(this, this._error);
dispatchLiveErrorEvent(this);
return renderMessage({
type: 'error',
message: this._error,
context: {
entity_id: this.entityid,
},
});
} else {
errorToConsole(this._error, console.error);
}
+11 -5
View File
@@ -12,8 +12,8 @@
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js';
import { screenshotMedia } from '../utils/screenshot.js';
import { dispatchErrorMessageEvent } from '../components/message.js';
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
import { renderMessage } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js';
import { mayHaveAudio } from '../utils/audio.js';
@@ -28,6 +28,7 @@ import {
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../utils/media.js';
import { screenshotMedia } from '../utils/screenshot.js';
customElements.whenDefined('ha-web-rtc-player').then(() => {
@customElement('frigate-card-ha-web-rtc-player')
@@ -94,9 +95,14 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
// =====================================================================================
protected render(): TemplateResult | void {
if (this._error) {
// Use native Frigate card error handling, and attach the entityid to
// clarify which camera the error refers to.
return dispatchErrorMessageEvent(this, `${this._error} (${this.entityid})`);
dispatchLiveErrorEvent(this);
return renderMessage({
type: 'error',
message: this._error,
context: {
entity_id: this.entityid,
},
});
}
return html`
<video
+2 -11
View File
@@ -1,12 +1,9 @@
import { CameraEndpoint } from '../camera-manager/types';
import { dispatchErrorMessageEvent } from '../components/message';
import { localize } from '../localize/localize';
import { ExtendedHomeAssistant } from '../types';
import { errorToConsole } from './basic';
import { homeAssistantSignPath } from './ha';
export const getEndpointAddressOrDispatchError = async (
element: HTMLElement,
export const convertEndpointAddressToSignedWebsocket = async (
hass: ExtendedHomeAssistant,
endpoint: CameraEndpoint,
expires?: number,
@@ -20,13 +17,7 @@ export const getEndpointAddressOrDispatchError = async (
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
} catch (e) {
errorToConsole(e as Error);
return null;
}
if (!response) {
dispatchErrorMessageEvent(element, localize('error.failed_sign'));
return null;
}
return response.replace(/^http/i, 'ws');
return response ? response.replace(/^http/i, 'ws') : null;
};
-36
View File
@@ -1,36 +0,0 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import { CameraConfig } from '../config/types.js';
import { dispatchErrorMessageEvent } from '../components/message.js';
import { localize } from '../localize/localize.js';
/**
* Get the state object or dispatch an error. Used in `ha` and `image` live
* providers.
* @param element HTMLElement to dispatch errors from.
* @param hass Home Assistant object.
* @param cameraConfig Camera configuration.
* @returns
*/
export const getStateObjOrDispatchError = (
element: HTMLElement,
hass: HomeAssistant,
cameraConfig?: CameraConfig,
): HassEntity | null => {
if (!cameraConfig?.camera_entity) {
dispatchErrorMessageEvent(element, localize('error.no_live_camera'), {
context: cameraConfig,
});
return null;
}
const stateObj = hass.states[cameraConfig.camera_entity];
if (!stateObj) {
dispatchErrorMessageEvent(element, localize('error.live_camera_not_found'), {
context: cameraConfig,
});
return null;
}
return stateObj;
};
+5 -6
View File
@@ -1,9 +1,6 @@
import { Task } from '@lit-labs/task';
import { html, TemplateResult } from 'lit';
import {
dispatchFrigateCardErrorEvent,
renderProgressIndicator,
} from '../components/message';
import { renderProgressIndicator } from '../components/message';
import { CardWideConfig } from '../config/types';
import { errorToConsole } from './basic';
@@ -16,12 +13,12 @@ import { errorToConsole } from './basic';
* @returns A template.
*/
export const renderTask = <R>(
host: EventTarget,
task: Task<unknown[], R>,
completeFunc: (result: R) => TemplateResult | void,
options?: {
cardWideConfig?: CardWideConfig;
inProgressFunc?: () => TemplateResult | void;
errorFunc?: (e: Error) => void;
},
): TemplateResult => {
const progressConfig = {
@@ -34,7 +31,9 @@ export const renderTask = <R>(
options?.inProgressFunc?.() ?? renderProgressIndicator(progressConfig),
error: (e: unknown) => {
errorToConsole(e as Error);
dispatchFrigateCardErrorEvent(host, e as Error);
if (options?.errorFunc) {
options.errorFunc(e as Error);
}
},
complete: completeFunc,
})}`;
+2 -1
View File
@@ -43,7 +43,8 @@ const fetchThumbnail = async (
};
reader.onerror = (e) => reject(e);
reader.readAsDataURL(blob);
});
})
.catch((e) => reject(e));
});
};
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LiveController } from '../../../src/components-lib/live/live-controller';
import { dispatchMessageEvent } from '../../../src/components/message';
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../src/utils/media-info';
import {
IntersectionObserverMock,
callIntersectionHandler,
@@ -26,7 +26,7 @@ describe('LiveController', () => {
const host = createLitElement();
const parent = createParent({ children: [host] });
const eventListener = vi.fn();
parent.addEventListener('frigate-card:message', eventListener);
parent.addEventListener('frigate-card:media:loaded', eventListener);
const controller = new LiveController(host);
expect(host.addController).toBeCalled();
@@ -34,12 +34,13 @@ describe('LiveController', () => {
controller.hostConnected();
callIntersectionHandler(false);
dispatchMessageEvent(host, 'message', 'info');
dispatchExistingMediaLoadedInfoAsEvent(host, createMediaLoadedInfo());
expect(eventListener).toBeCalledTimes(0);
controller.hostDisconnected();
dispatchMessageEvent(host, 'message', 'info');
dispatchExistingMediaLoadedInfoAsEvent(host, createMediaLoadedInfo());
expect(eventListener).toBeCalledTimes(1);
});
@@ -92,70 +93,6 @@ describe('LiveController', () => {
host.dispatchEvent(createMediaLoadedInfoEvent(mediaLoadedInfo));
expect(eventListener).toBeCalledTimes(2);
callIntersectionHandler(false);
dispatchMessageEvent(host, 'message', 'info');
callIntersectionHandler(true);
expect(eventListener).toBeCalledTimes(2);
controller.clearMessageReceived();
callIntersectionHandler(true);
expect(eventListener).toBeCalledTimes(3);
});
});
describe('should correctly allow updates', () => {
it('when not in background', () => {
const controller = new LiveController(createLitElement());
expect(controller.shouldUpdate()).toBeTruthy();
});
it('when in background without message', () => {
const host = createLitElement();
const controller = new LiveController(host);
controller.hostConnected();
callIntersectionHandler(false);
expect(controller.shouldUpdate()).toBeTruthy();
});
it('when in background with message', () => {
const host = createLitElement();
const controller = new LiveController(host);
controller.hostConnected();
callIntersectionHandler(false);
dispatchMessageEvent(host, 'message', 'info');
expect(controller.shouldUpdate()).toBeFalsy();
});
});
it('should handle message', () => {
const host = createLitElement();
const parent = createParent({ children: [host] });
const eventListener = vi.fn();
parent.addEventListener('frigate-card:message', eventListener);
const controller = new LiveController(host);
controller.hostConnected();
callIntersectionHandler(false);
expect(controller.isInBackground()).toBeTruthy();
const firstRenderEpoch = controller.getRenderEpoch();
dispatchMessageEvent(host, 'message', 'info');
expect(eventListener).toBeCalledTimes(0);
const secondRenderEpoch = controller.getRenderEpoch();
expect(secondRenderEpoch).not.toBe(firstRenderEpoch);
callIntersectionHandler(true);
dispatchMessageEvent(host, 'message', 'info');
expect(eventListener).toBeCalledTimes(1);
expect(controller.getRenderEpoch()).toBe(secondRenderEpoch);
});
});
@@ -0,0 +1,12 @@
import { expect, it, vi } from 'vitest';
import { dispatchLiveErrorEvent } from '../../../../src/components-lib/live/utils/dispatch-live-error';
// @vitest-environment jsdom
it('should dispatch live error event', () => {
const element = document.createElement('div');
const handler = vi.fn();
element.addEventListener('frigate-card:live:error', handler);
dispatchLiveErrorEvent(element);
expect(handler).toBeCalled();
});
+12 -37
View File
@@ -1,22 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getEndpointAddressOrDispatchError } from '../../src/utils/endpoint';
import { convertEndpointAddressToSignedWebsocket } from '../../src/utils/endpoint';
import { homeAssistantSignPath } from '../../src/utils/ha';
import { createHASS } from '../test-utils';
vi.mock('../../src/utils/ha');
// @vitest-environment jsdom
describe('getEndpointAddressOrDispatchError', () => {
describe('convertEndpointAddressToSignedWebsocket', () => {
it('without signing', async () => {
expect(
await getEndpointAddressOrDispatchError(
document.createElement('div'),
createHASS(),
{
endpoint: 'http://example.com',
sign: false,
},
),
await convertEndpointAddressToSignedWebsocket(createHASS(), {
endpoint: 'http://example.com',
sign: false,
}),
).toBe('http://example.com');
});
@@ -29,51 +24,31 @@ describe('getEndpointAddressOrDispatchError', () => {
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed.com');
expect(
await getEndpointAddressOrDispatchError(
document.createElement('div'),
createHASS(),
{
endpoint: 'http://example.com',
sign: true,
},
),
await convertEndpointAddressToSignedWebsocket(createHASS(), {
endpoint: 'http://example.com',
sign: true,
}),
).toBe('ws://signed.com');
});
it('with null response', async () => {
const element = document.createElement('div');
const listener = vi.fn();
element.addEventListener('frigate-card:message', listener);
vi.mocked(homeAssistantSignPath).mockResolvedValue(null);
expect(
await getEndpointAddressOrDispatchError(element, createHASS(), {
await convertEndpointAddressToSignedWebsocket(createHASS(), {
endpoint: 'http://example.com',
sign: true,
}),
).toBeNull();
expect(listener).toBeCalledWith(
expect.objectContaining({
detail: expect.objectContaining({
message: 'Could not sign Home Assistant URL',
}),
}),
);
});
it('with exception on signing', async () => {
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const element = document.createElement('div');
const listener = vi.fn();
element.addEventListener('frigate-card:message', listener);
vi.mocked(homeAssistantSignPath).mockRejectedValue(new Error());
expect(
await getEndpointAddressOrDispatchError(element, createHASS(), {
await convertEndpointAddressToSignedWebsocket(createHASS(), {
endpoint: 'http://example.com',
sign: true,
}),
-72
View File
@@ -1,72 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { createCameraConfig, createHASS, createStateEntity } from '../test-utils';
import { getStateObjOrDispatchError } from '../../src/utils/get-state-obj';
// @vitest-environment jsdom
describe('getStateObjOrDispatchError', () => {
it('should retrieve valid state object', () => {
const messageHandler = vi.fn();
const element = document.createElement('div');
element.addEventListener('frigate-card:message', messageHandler);
const state = createStateEntity();
expect(
getStateObjOrDispatchError(
element,
createHASS({
'camera.test': state,
}),
createCameraConfig({
camera_entity: 'camera.test',
}),
),
).toBe(state);
expect(messageHandler).not.toBeCalled();
});
it('should dispatch unspecified entity', () => {
const messageHandler = vi.fn();
const element = document.createElement('div');
element.addEventListener('frigate-card:message', messageHandler);
expect(
getStateObjOrDispatchError(element, createHASS(), createCameraConfig()),
).toBeNull();
expect(messageHandler).toBeCalledWith(
expect.objectContaining({
detail: expect.objectContaining({
message:
'The camera_entity parameter must be set and valid for this live provider',
type: 'error',
}),
}),
);
});
it('should dispatch not found state', () => {
const messageHandler = vi.fn();
const element = document.createElement('div');
element.addEventListener('frigate-card:message', messageHandler);
expect(
getStateObjOrDispatchError(
element,
createHASS(),
createCameraConfig({
camera_entity: 'camera.will-not-be-found',
}),
),
).toBeNull();
expect(messageHandler).toBeCalledWith(
expect.objectContaining({
detail: expect.objectContaining({
message: 'The configured camera_entity was not found',
type: 'error',
}),
}),
);
});
});