- Closes #2556 - Closes #2450 **Key intended features:** - go2rtc compatible - 100% test coverage to significantly improve ability to test, maintain and work around browser weirdnesses (e.g. Safari). - Written from the ground up in the style of the rest of the project. **To use:** - Change `live_provider` from `go2rtc` to `go2rtc-experimental`.
192 lines
7.4 KiB
TypeScript
192 lines
7.4 KiB
TypeScript
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
|
|
import type { Camera } from '../../../camera-manager/camera';
|
|
import type { StateWatcherSubscriptionInterface } from '../../../card-controller/hass/state-watcher';
|
|
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
|
|
import type { IssueTriggerEventData } from '../../../card-controller/issues/types';
|
|
import type { CameraConfig } from '../../../config/schema/cameras';
|
|
import type { HomeAssistant } from '../../../ha/types';
|
|
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
|
|
import { EntityAvailabilityDetector } from './detectors/entity-availability';
|
|
import { MediaPlayerLivenessDetector } from './detectors/media-player-liveness';
|
|
import { ProviderErrorDetector } from './detectors/provider-error';
|
|
|
|
// How far a verdict's evidence is trusted, so direct observation of the media
|
|
// outweighs an indirect signal:
|
|
// - `direct`: observed from the media itself (e.g. frames arriving or
|
|
// stalling).
|
|
// - `indirect`: inferred from a correlated signal (e.g. the camera entity's
|
|
// state).
|
|
// - `hard`: an authoritative failure (e.g. a provider error, or the user's
|
|
// always_error opt-in) that overrides even direct evidence of life.
|
|
type LivenessAuthority = 'hard' | 'direct' | 'indirect';
|
|
|
|
export type LivenessVerdict =
|
|
// No evidence: the detector is not observing, so it neither confirms nor
|
|
// denies liveness. A silent detector must never masquerade as proof of life.
|
|
| { state: 'unknown' }
|
|
|
|
// Media is confirmed to be flowing.
|
|
| { state: 'live'; authority: LivenessAuthority }
|
|
|
|
// Media is confirmed not to be flowing.
|
|
| {
|
|
state: 'not_live';
|
|
authority: LivenessAuthority;
|
|
reason: MediaUnavailableIssueReason;
|
|
|
|
// Whether the wrapper should replace the provider with a reconnecting
|
|
// placeholder (a silent freeze, e.g. an unavailable camera). Omitted when
|
|
// the provider renders its own error and should stay mounted.
|
|
renderPlaceholder?: boolean;
|
|
};
|
|
|
|
// The wrapper-facing projection of the internal LivenessVerdict: it keeps the
|
|
// detector-internal `authority` from leaking out and collapses `unknown` into
|
|
// "no failure", so a consumer sees only a confirmed failure (with its cause) or
|
|
// nothing. Modeled as the failure rather than a `live` flag because a stream
|
|
// that is merely still connecting is not a failure yet is not playing either, so
|
|
// a positive `live` boolean would misleadingly read as "media is playing".
|
|
interface StreamFailure {
|
|
reason: MediaUnavailableIssueReason;
|
|
|
|
// Whether the wrapper should replace the provider with a reconnecting
|
|
// placeholder (a silent freeze, e.g. an unavailable camera). False when the
|
|
// provider renders its own error and should stay mounted.
|
|
renderPlaceholder: boolean;
|
|
}
|
|
|
|
export interface LivenessDetector {
|
|
// Start observing the signal.
|
|
subscribe(): void;
|
|
|
|
// Stop observing (e.g. on disconnect). Accumulated state is retained so a
|
|
// later reconnect resumes where it left off; use reset() to discard it.
|
|
unsubscribe(): void;
|
|
|
|
// Discard accumulated state because the underlying stream changed (e.g. a
|
|
// substream switch), so detection restarts from scratch.
|
|
reset?(): void;
|
|
|
|
// Reports the stream's current liveness, calling `onChange` (passed at
|
|
// construction) whenever that verdict changes.
|
|
getVerdict(): LivenessVerdict;
|
|
}
|
|
|
|
interface StreamLivenessControllerConfig {
|
|
getTargetID: () => string | null;
|
|
getHASS: () => HomeAssistant | null;
|
|
getCamera: () => Camera | null;
|
|
getStateWatcher: () => StateWatcherSubscriptionInterface | null;
|
|
}
|
|
|
|
/**
|
|
* Coordinates liveness detection for a single live provider and surfaces a
|
|
* `media_unavailable` "issue" when the underlying stream stops delivering media. The
|
|
* issue framework owns the throttled reload that recovers the stream.
|
|
*/
|
|
export class StreamLivenessController implements ReactiveController {
|
|
private _host: ReactiveControllerHost & HTMLElement;
|
|
private _config: StreamLivenessControllerConfig;
|
|
private _detectors: LivenessDetector[];
|
|
|
|
constructor(
|
|
host: ReactiveControllerHost & HTMLElement,
|
|
config: StreamLivenessControllerConfig,
|
|
) {
|
|
this._host = host;
|
|
this._config = config;
|
|
|
|
const onChange = (): void => this._onDetectorChange();
|
|
const getCameraConfig = (): CameraConfig | null =>
|
|
config.getCamera()?.getConfig() ?? null;
|
|
|
|
this._detectors = [
|
|
new ProviderErrorDetector(host, onChange),
|
|
new EntityAvailabilityDetector({
|
|
getHASS: config.getHASS,
|
|
getStateWatcher: config.getStateWatcher,
|
|
getCameraEntity: () => getCameraConfig()?.camera_entity ?? null,
|
|
isAlwaysError: () =>
|
|
getCameraConfig()?.always_error_if_entity_unavailable ?? false,
|
|
onChange,
|
|
}),
|
|
new MediaPlayerLivenessDetector(host, onChange),
|
|
];
|
|
this._host.addController(this);
|
|
}
|
|
|
|
public hostConnected(): void {
|
|
this._detectors.forEach((detector) => detector.subscribe());
|
|
}
|
|
|
|
public hostDisconnected(): void {
|
|
this._detectors.forEach((detector) => detector.unsubscribe());
|
|
}
|
|
|
|
public isLive(): boolean {
|
|
return !this.getFailure();
|
|
}
|
|
|
|
// The stream's confirmed liveness failure, or null when there is none (the
|
|
// stream is live, or still connecting).
|
|
public getFailure(): StreamFailure | null {
|
|
const verdict = this._getVerdict();
|
|
return verdict.state === 'not_live'
|
|
? { reason: verdict.reason, renderPlaceholder: !!verdict.renderPlaceholder }
|
|
: null;
|
|
}
|
|
|
|
// Discard detector state on a stream change (e.g. a stream switch).
|
|
public reset(): void {
|
|
this._detectors.forEach((detector) => detector.reset?.());
|
|
}
|
|
|
|
// Reduce the detectors to a single verdict. Direct evidence from the media
|
|
// itself (e.g. lack of frame stalls) outranks indirect signals (e.g.
|
|
// entity-availability), so a stream that is demonstrably delivering frames is
|
|
// never torn down just because its camera entity blipped unavailable. `hard`
|
|
// failures (a provider error, or the always_error opt-in) outrank everything.
|
|
private _getVerdict(): LivenessVerdict {
|
|
const verdicts = this._detectors.map((detector) => detector.getVerdict());
|
|
const find = (
|
|
state: 'live' | 'not_live',
|
|
authority: LivenessAuthority,
|
|
): LivenessVerdict | null =>
|
|
verdicts.find(
|
|
(v) => 'authority' in v && v.state === state && v.authority === authority,
|
|
) ?? null;
|
|
|
|
// `unknown` verdicts carry no authority, so they match none of these lookups
|
|
// and are skipped; if every detector is silent the reduction is `unknown`.
|
|
return (
|
|
find('not_live', 'hard') ??
|
|
find('not_live', 'direct') ??
|
|
find('live', 'direct') ??
|
|
find('not_live', 'indirect') ?? { state: 'unknown' }
|
|
);
|
|
}
|
|
|
|
private _onDetectorChange(): void {
|
|
const verdict = this._getVerdict();
|
|
if (verdict.state === 'not_live') {
|
|
this._triggerMediaUnavailableIssue(verdict.reason);
|
|
}
|
|
this._host.requestUpdate();
|
|
}
|
|
|
|
// Tell the issue framework this target's media is not loaded, surfacing the
|
|
// media_unavailable issue (status bar + retry) and its throttled reload.
|
|
private _triggerMediaUnavailableIssue(reason: MediaUnavailableIssueReason): void {
|
|
const targetID = this._config.getTargetID();
|
|
if (!targetID) {
|
|
return;
|
|
}
|
|
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
|
|
key: 'media_unavailable',
|
|
targetID,
|
|
reason,
|
|
});
|
|
}
|
|
}
|