feat: Surface the provider's error cause in the media_unavailable notification (#2599)

- Closes: #2592
This commit is contained in:
Dermot Duffy
2026-07-22 20:45:49 -07:00
committed by GitHub
parent 5a549c0326
commit 448fa2d9f1
18 changed files with 255 additions and 90 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { ProviderErrorDetector } from '../../../../../src/components-lib/live/liveness/detectors/provider-error';
import { dispatchLiveErrorEvent } from '../../../../../src/components-lib/live/utils/dispatch-live-error';
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
@@ -24,7 +25,7 @@ describe('ProviderErrorDetector', () => {
const detector = new ProviderErrorDetector(host, onChange);
detector.subscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
dispatchLiveErrorEvent(host);
// not_live but no renderPlaceholder: the provider renders its own error.
expect(detector.getVerdict()).toEqual({
@@ -35,19 +36,21 @@ describe('ProviderErrorDetector', () => {
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should adopt the specific reason carried on the event', () => {
it('should adopt the specific reason and description carried on the event', () => {
const host = createHostInDocument();
const detector = new ProviderErrorDetector(host, vi.fn());
detector.subscribe();
host.dispatchEvent(
new CustomEvent(LIVE_ERROR_EVENT, { bubbles: true, detail: 'unsupported' }),
);
dispatchLiveErrorEvent(host, {
reason: 'unsupported',
detail: 'Codec not supported',
});
expect(detector.getVerdict()).toEqual({
state: 'not_live',
authority: 'hard',
reason: 'unsupported',
description: 'Codec not supported',
});
});
@@ -57,8 +60,8 @@ describe('ProviderErrorDetector', () => {
const detector = new ProviderErrorDetector(host, onChange);
detector.subscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
dispatchLiveErrorEvent(host);
dispatchLiveErrorEvent(host);
expect(onChange).toHaveBeenCalledTimes(1);
});
@@ -70,7 +73,7 @@ describe('ProviderErrorDetector', () => {
const detector = new ProviderErrorDetector(host, vi.fn());
detector.subscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
dispatchLiveErrorEvent(host);
expect(parentListener).not.toHaveBeenCalled();
@@ -81,7 +84,7 @@ describe('ProviderErrorDetector', () => {
const host = createHostInDocument();
const detector = new ProviderErrorDetector(host, vi.fn());
detector.subscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
dispatchLiveErrorEvent(host);
expect(detector.getVerdict().state).toBe('not_live');
detector.reset();
@@ -96,7 +99,7 @@ describe('ProviderErrorDetector', () => {
detector.subscribe();
detector.unsubscribe();
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
dispatchLiveErrorEvent(host);
expect(detector.getVerdict().state).toBe('unknown');
expect(onChange).not.toHaveBeenCalled();
@@ -5,6 +5,10 @@ import type { Camera } from '../../../../src/camera-manager/camera';
import type { StateWatcherSubscriptionInterface } from '../../../../src/card-controller/hass/state-watcher';
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
import { StreamLivenessController } from '../../../../src/components-lib/live/liveness/stream-liveness-controller';
import {
dispatchLiveErrorEvent,
type LiveError,
} from '../../../../src/components-lib/live/utils/dispatch-live-error';
import type { LivenessCallback, MediaPlayerController } from '../../../../src/types';
import {
callIntersectionHandler,
@@ -18,7 +22,6 @@ import {
IntersectionObserverMock,
} from '../../../test-utils';
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
const setup = (options?: { targetID?: string | null }) => {
@@ -38,8 +41,8 @@ const setup = (options?: { targetID?: string | null }) => {
issueTriggers.push((ev as CustomEvent).detail),
);
const failViaProviderError = (): void => {
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
const failViaProviderError = (error?: LiveError): void => {
dispatchLiveErrorEvent(host, error);
};
return { host, controller, issueTriggers, failViaProviderError };
@@ -132,6 +135,27 @@ describe('StreamLivenessController', () => {
});
});
it("should carry the provider's error description into the failure and the issue", () => {
const { controller, issueTriggers, failViaProviderError } = setup();
controller.hostConnected();
failViaProviderError({ detail: 'Failed to start WebRTC stream: no candidates' });
expect(controller.getFailure()).toEqual({
reason: 'playback_error',
description: 'Failed to start WebRTC stream: no candidates',
renderPlaceholder: false,
});
expect(issueTriggers).toEqual([
{
key: 'media_unavailable',
targetID: 'camera.office',
reason: 'playback_error',
description: 'Failed to start WebRTC stream: no candidates',
},
]);
});
it('should not fire the issue without a target', () => {
const { host, controller, issueTriggers, failViaProviderError } = setup({
targetID: null,