Refactor card.ts substantially and test.

This commit is contained in:
Dermot Duffy
2023-10-02 16:30:05 -07:00
parent 4cf13b1645
commit bfdbe70d6c
92 changed files with 8404 additions and 2996 deletions
-19
View File
@@ -10,7 +10,6 @@ import {
FrigateCardAction,
FrigateCardCustomAction,
frigateCardCustomActionSchema,
FrigateCardViewAction,
ViewDisplayMode,
} from '../types.js';
@@ -187,21 +186,3 @@ export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolea
export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
ev.stopPropagation();
};
export const isViewAction = (
action: FrigateCardCustomAction,
): action is FrigateCardViewAction => {
switch (action.frigate_card_action) {
case 'clip':
case 'clips':
case 'image':
case 'live':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
return true;
}
return false;
};
@@ -0,0 +1,221 @@
import {
Actions,
ActionsConfig, FrigateCardCustomAction, FRIGATE_CARD_VIEW_DEFAULT
} from '../../types.js';
import {
convertActionToFrigateCardCustomAction,
frigateCardHandleActionConfig,
getActionConfigGivenAction
} from '../action.js';
import { getStreamCameraID } from '../substream.js';
import { CardActionsManagerAPI } from './types.js';
export class ActionsManager {
protected _api: CardActionsManagerAPI;
constructor(api: CardActionsManagerAPI) {
this._api = api;
}
/**
* Merge card-wide and view-specific actions.
* @returns A combined set of action.
*/
public getMergedActions(): ActionsConfig {
const view = this._api.getViewManager().getView();
if (this._api.getMessageManager().hasMessage()) {
return {};
}
const config = this._api.getConfigManager().getConfig();
let specificActions: Actions | undefined = undefined;
if (view?.is('live')) {
specificActions = config?.live.actions;
} else if (view?.isGalleryView()) {
specificActions = config?.media_gallery?.actions;
} else if (view?.isViewerView()) {
specificActions = config?.media_viewer.actions;
} else if (view?.is('image')) {
specificActions = config?.image?.actions;
} else {
return {};
}
return { ...config?.view.actions, ...specificActions };
}
/**
* Handle an human interaction called on an element (e.g. 'tap').
*/
public handleInteraction(interaction: string): void {
const hass = this._api.getHASSManager().getHASS();
const config = this.getMergedActions();
const actionConfig = getActionConfigGivenAction(interaction, config);
if (
hass &&
config &&
interaction &&
// Don't call frigateCardHandleActionConfig() unless there is explicitly an
// action defined (as it uses a default that is unhelpful for views that
// have default tap/click actions).
actionConfig
) {
frigateCardHandleActionConfig(
this._api.getCardElementManager().getElement(),
hass,
config,
interaction,
actionConfig,
);
}
}
public handleActionEvent = (ev: Event): void => {
if (!('detail' in ev)) {
// The event may not actually be a CustomEvent object, but may still have a
// detail field. See:
// https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70
return;
}
const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
if (frigateCardAction) {
this.executeAction(frigateCardAction);
}
};
/**
* Execute a card action.
* @param frigateCardAction
* @returns `true` if an action is executed.
*/
public async executeAction(frigateCardAction: FrigateCardCustomAction): Promise<void> {
const config = this._api.getConfigManager().getConfig();
const mediaLoadedInfoManager = this._api.getMediaLoadedInfoManager();
if (
// Command not intended for this card (e.g. query string command).
frigateCardAction.card_id &&
config?.card_id !== frigateCardAction.card_id
) {
return;
}
// Note: This function needs to process (view-related) commands even when
// _view has not yet been initialized (since it may be used to set a view
// via the querystring).
const view = this._api.getViewManager().getView();
const action = frigateCardAction.frigate_card_action;
switch (action) {
case 'default':
this._api.getViewManager().setViewDefault();
break;
case 'clip':
case 'clips':
case 'image':
case 'live':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
this._api.getViewManager().setViewByParameters({
viewName: action,
cameraID: view?.camera,
});
break;
case 'download':
await this._api.getDownloadManager().downloadViewerMedia();
break;
case 'camera_ui':
this._api.getCameraURLManager().openURL();
break;
case 'expand':
this._api.getExpandManager().toggleExpanded();
break;
case 'fullscreen':
this._api.getFullscreenManager().toggleFullscreen();
break;
case 'menu_toggle':
// This is a rare code path: this would only be used if someone has a
// menu toggle action configured outside of the menu itself (e.g.
// picture elements).
this._api.getCardElementManager().toggleMenu();
break;
case 'camera_select':
const selectCameraID = frigateCardAction.camera;
if (view) {
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
const targetViewName =
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
const verifiedViewName = this._api
.getViewManager()
.isViewSupportedByCamera(selectCameraID, targetViewName)
? targetViewName
: FRIGATE_CARD_VIEW_DEFAULT;
this._api.getViewManager().setViewByParameters({
viewName: verifiedViewName,
cameraID: selectCameraID,
});
}
break;
case 'live_substream_select': {
this._api.getViewManager().setViewWithSubstream(frigateCardAction.camera);
break;
}
case 'live_substream_off': {
this._api.getViewManager().setViewWithoutSubstream();
break;
}
case 'live_substream_on': {
this._api.getViewManager().setViewWithSubstream();
break;
}
case 'media_player':
const mediaPlayer = frigateCardAction.media_player;
const mediaPlayerController = this._api.getMediaPlayerManager();
const media = view?.queryResults?.getSelectedResult() ?? null;
if (frigateCardAction.media_player_action === 'stop') {
await mediaPlayerController.stop(mediaPlayer);
} else if (view?.is('live')) {
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
} else if (view?.isViewerView() && media) {
await mediaPlayerController.playMedia(mediaPlayer, media);
}
break;
case 'diagnostics':
this._api.getViewManager().setViewByParameters({ viewName: 'diagnostics' });
break;
case 'microphone_mute':
this._api.getMicrophoneManager().mute();
break;
case 'microphone_unmute':
await this._api.getMicrophoneManager().unmute();
break;
case 'mute':
await mediaLoadedInfoManager.get()?.player?.mute();
break;
case 'unmute':
await mediaLoadedInfoManager.get()?.player?.unmute();
break;
case 'play':
await mediaLoadedInfoManager.get()?.player?.play();
break;
case 'pause':
await mediaLoadedInfoManager.get()?.player?.pause();
break;
case 'screenshot':
await this._api.getDownloadManager().downloadScreenshot();
break;
case 'display_mode_select':
this._api
.getViewManager()
.setViewWithNewDisplayMode(frigateCardAction.display_mode);
break;
default:
console.warn(`Frigate card received unknown card action: ${action}`);
}
}
}
@@ -0,0 +1,43 @@
import { Timer } from '../timer';
import { CardAutoRefreshAPI } from './types';
export class AutoUpdateManager {
protected _timer = new Timer();
protected _api: CardAutoRefreshAPI;
constructor(api: CardAutoRefreshAPI) {
this._api = api;
}
/**
* Set the update timer to trigger an update refresh every
* `view.update_seconds`.
*/
public startDefaultViewTimer(): void {
this._timer.stop();
const updateSeconds = this._api.getConfigManager().getConfig()
?.view.update_seconds;
if (updateSeconds) {
this._timer.start(updateSeconds, () => {
if (this._isAutomatedUpdateAllowed()) {
this._api.getViewManager().setViewDefault();
} else {
// Not allowed to update this time around, but try again at the next
// interval.
this.startDefaultViewTimer();
}
});
}
}
protected _isAutomatedUpdateAllowed(): boolean {
const triggers = this._api.getTriggersManager();
const config = this._api.getConfigManager().getConfig();
const interactionManager = this._api.getInteractionManager();
return (
!triggers.isTriggered() &&
(config?.view.update_force || !interactionManager.hasInteraction())
);
}
}
@@ -0,0 +1,73 @@
import { localize } from '../../localize/localize.js';
import {
Automation,
AutomationActions,
Automations,
} from '../../types.js';
import { frigateCardHandleAction } from '../action.js';
import { CardAutomationsAPI } from './types.js';
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
export class AutomationsManager {
protected _api: CardAutomationsAPI;
protected _automations: Automations;
protected _priorEvaluations: Map<Automation, boolean> = new Map();
// A counter to avoid infinite loops, increases every time actions are run,
// decreases every time actions are complete.
protected _nestedAutomationExecutions = 0;
constructor(api: CardAutomationsAPI) {
this._api = api;
}
public setAutomationsFromConfig() {
this._automations = this._api
.getConfigManager()
.getNonOverriddenConfig()?.automations;
}
public execute(): void {
const hass = this._api.getHASSManager().getHASS();
// Never execute automations if there's an error (as our automation loop
// avoidance -- which shows as an error -- would not work!).
if (!hass || this._api.getMessageManager().hasErrorMessage()) {
return;
}
const actionsToRun: AutomationActions[] = [];
for (const automation of this._automations ?? []) {
const shouldExecute = this._api
.getConditionsManager()
.evaluateCondition(automation.conditions);
const actions = shouldExecute ? automation.actions : automation.actions_not;
const priorEvaluation = this._priorEvaluations.get(automation);
this._priorEvaluations.set(automation, shouldExecute);
if (shouldExecute !== priorEvaluation && actions) {
actionsToRun.push(actions);
}
}
++this._nestedAutomationExecutions;
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.too_many_automations'),
});
return;
}
actionsToRun.forEach((actions) => {
frigateCardHandleAction(
this._api.getCardElementManager().getElement(),
hass,
{},
actions,
);
});
--this._nestedAutomationExecutions;
}
}
@@ -0,0 +1,32 @@
import { CardCameraURLAPI } from './types';
export class CameraURLManager {
protected _api: CardCameraURLAPI;
constructor(api: CardCameraURLAPI) {
this._api = api;
}
public openURL(): void {
const url = this.getCameraURL();
if (url) {
window.open(url);
}
}
public hasCameraURL(): boolean {
return !!this.getCameraURL();
}
public getCameraURL(): string | null {
const view = this._api.getViewManager().getView();
const media = view?.queryResults?.getSelectedResult() ?? null;
const endpoints = view?.camera
? this._api.getCameraManager().getCameraEndpoints(view.camera, {
view: view.view,
...(media && { media: media }),
}) ?? null
: null;
return endpoints?.ui?.endpoint ?? null;
}
}
@@ -0,0 +1,117 @@
import { LitElement, ReactiveControllerHost } from 'lit';
import { ActionEventTarget } from '../../action-handler-directive';
import { setOrRemoveAttribute } from '../basic';
import { isCardInPanel } from '../ha';
import { CardElementAPI } from './types';
export type ScrollCallback = () => void;
export type MenuToggleCallback = () => void;
export type CardHTMLElement = LitElement & ReactiveControllerHost & ActionEventTarget;
export class CardElementManager {
protected _api: CardElementAPI;
protected _element: CardHTMLElement;
protected _scrollCallback: ScrollCallback;
protected _menuToggleCallback: MenuToggleCallback;
constructor(
api: CardElementAPI,
element: CardHTMLElement,
scrollCallback: ScrollCallback,
menuToggleCallback: MenuToggleCallback,
) {
this._api = api;
this._element = element;
this._scrollCallback = scrollCallback;
this._menuToggleCallback = menuToggleCallback;
}
public getElement(): HTMLElement {
return this._element;
}
public scrollReset(): void {
this._scrollCallback();
}
public toggleMenu(): void {
this._menuToggleCallback();
}
public update(): void {
this._element.requestUpdate();
}
public hasUpdated(): boolean {
return this._element.hasUpdated;
}
public getCardHeight(): number {
return this._element.getBoundingClientRect().height;
}
public elementConnected(): void {
// Whether or not the card is in panel mode on the dashboard.
setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel');
this._api.getFullscreenManager().connect();
this._element.addEventListener(
'mousemove',
this._api.getInteractionManager().reportInteraction,
);
this._element.addEventListener(
'll-custom',
this._api.getActionsManager().handleActionEvent,
);
this._element.addEventListener(
'@action',
this._api.getInteractionManager().reportInteraction,
);
// Listen for HA `navigate` actions.
// See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43
window.addEventListener(
'location-changed',
this._api.getQueryStringManager().executeAll,
);
// Listen for history state changes (i.e. user using the browser
// back/forward controls).
window.addEventListener('popstate', this._api.getQueryStringManager().executeAll);
// Manually call the location change handler as the card will be
// disconnected/reconnected when dashboard 'tab' changes happen within HA.
this._api.getQueryStringManager().executeAll();
}
public elementDisconnected(): void {
setOrRemoveAttribute(this._element, false, 'panel');
// When the dashboard 'tab' is changed, the media is effectively unloaded.
this._api.getMediaLoadedInfoManager().clear();
this._api.getFullscreenManager().disconnect();
this._element.removeEventListener(
'mousemove',
this._api.getInteractionManager().reportInteraction,
);
this._element.removeEventListener(
'll-custom',
this._api.getActionsManager().handleActionEvent,
);
this._element.removeEventListener(
'@action',
this._api.getInteractionManager().reportInteraction,
);
window.removeEventListener(
'location-changed',
this._api.getQueryStringManager().executeAll,
);
window.removeEventListener('popstate', this._api.getQueryStringManager().executeAll);
}
}
@@ -0,0 +1,257 @@
import { HassEntities } from 'home-assistant-js-websocket';
import merge from 'lodash-es/merge';
import { copyConfig } from '../../config-mgmt';
import {
FrigateCardCondition,
frigateConditionalSchema,
OverrideConfigurationKey,
RawFrigateCardConfig,
ViewDisplayMode,
} from '../../types';
import { CardConditionAPI } from './types';
interface ConditionState {
view?: string;
fullscreen?: boolean;
expand?: boolean;
camera?: string;
state?: HassEntities;
media_loaded?: boolean;
displayMode?: ViewDisplayMode;
}
export class ConditionEvaluateRequestEvent extends Event {
public condition: FrigateCardCondition;
public evaluation?: boolean;
constructor(condition: FrigateCardCondition, eventInitDict?: EventInit) {
super('frigate-card:condition:evaluate', eventInitDict);
this.condition = condition;
}
}
/**
* Evaluate whether a frigateCardCondition is met using an event to evaluate.
* @returns A boolean indicating whether the condition is met.
*/
export function evaluateConditionViaEvent(
element: HTMLElement,
condition?: FrigateCardCondition,
): boolean {
if (!condition) {
return true;
}
const evaluateEvent = new ConditionEvaluateRequestEvent(condition, {
bubbles: true,
composed: true,
});
/* Special note on what's going on here:
*
* Some parts of the card (e.g. <frigate-card-elements>) may have arbitrary
* complexity and layers (that this card doesn't control) between that master
* element and the element that needs to evaluate the condition. In these
* cases there's no clean way to pass state from the rest of card down through
* these layers. Instead, an event is dispatched as a "request for evaluation"
* (ConditionEvaluateRequestEvent) upwards which is caught by the outer card
* and the evaluation result is added to the event object. Because event
* propagation is handled synchronously, the result will be added to the event
* before the flow proceeds.
*/
element.dispatchEvent(evaluateEvent);
return evaluateEvent.evaluation ?? false;
}
type RawOverrides = {
conditions: FrigateCardCondition;
overrides: RawFrigateCardConfig;
}[];
export function getOverriddenConfig(
manager: Readonly<ConditionsManager>,
config: Readonly<RawFrigateCardConfig>,
configOverrides?: Readonly<RawOverrides>,
stateOverrides?: Partial<ConditionState>,
): RawFrigateCardConfig {
const output = copyConfig(config);
let overridden = false;
if (configOverrides) {
for (const override of configOverrides) {
if (manager.evaluateCondition(override.conditions, stateOverrides)) {
merge(output, override.overrides);
overridden = true;
}
}
}
// Attempt to return the same configuration object if it has not been
// overridden (to reduce re-renders for a configuration that has not changed).
return overridden ? output : config;
}
export function getOverridesByKey(
key: OverrideConfigurationKey,
overrides?: Readonly<RawOverrides>,
): RawOverrides {
return (
overrides
?.filter((o) => key in o.overrides)
.map((o) => ({
conditions: o.conditions,
overrides: o.overrides[key] as RawFrigateCardConfig,
})) ?? []
);
}
// A tiny wrapper interface to allow the same manager to be passed around
// immutably within objects that will not be equal (===). Every state change
// generates a new epoch. This is used for Lit rendering to ensure changes to
// condition state are recognized as changes even though the manager is the
// same.
export interface ConditionsManagerEpoch {
manager: Readonly<ConditionsManager>;
}
export type ConditionsManagerListener = () => void;
export class ConditionsManager {
protected _api: CardConditionAPI;
protected _state: ConditionState = {};
protected _epoch: ConditionsManagerEpoch = this._createEpoch();
protected _listeners: ConditionsManagerListener[];
// Whether or not to include HA state in ConditionState. Doing so increases
// CPU usage as HA state is pumped out very fast, so this is only enabled if
// the configuration needs to consume it.
protected _hasHAStateConditions = false;
protected _mediaQueries: MediaQueryList[] = [];
protected _mediaQueryTrigger = () => this._triggerChange();
constructor(api: CardConditionAPI, listener?: ConditionsManagerListener) {
this._api = api;
this._listeners = [
() => this._api.getConfigManager().computeOverrideConfig(),
() => this._api.getAutomationsManager().execute(),
...(listener ? [listener] : [])
];
}
public removeConditions(): void {
this._mediaQueries.forEach((mql) =>
mql.removeEventListener('change', this._mediaQueryTrigger),
);
this._mediaQueries = [];
}
public setConditionsFromConfig(): void {
this.removeConditions();
const getAllConditions = (): FrigateCardCondition[] => {
const config = this._api.getConfigManager().getConfig();
const conditions: FrigateCardCondition[] = [];
config?.overrides?.forEach((override) => conditions.push(override.conditions));
// Element conditions can be arbitrarily nested underneath conditionals and
// custom elements that this card may not known. Here we recursively parse
// down the elements tree, parsing as we go to find valid conditions.
const getElementsConditions = (data: unknown): void => {
const parseResult = frigateConditionalSchema.safeParse(data);
if (parseResult.success) {
conditions.push(parseResult.data.conditions);
parseResult.data.elements?.forEach(getElementsConditions);
} else if (data && typeof data === 'object') {
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
}
};
config?.elements?.forEach(getElementsConditions);
return conditions;
};
const conditions = getAllConditions();
this._hasHAStateConditions = conditions.some(
(condition) => !!condition.state?.length,
);
conditions.forEach((condition) => {
if (condition.media_query) {
const mql = window.matchMedia(condition.media_query);
mql.addEventListener('change', this._mediaQueryTrigger);
this._mediaQueries.push(mql);
}
});
}
public setState(state: Partial<ConditionState>): void {
this._state = {
...this._state,
...state,
};
this._triggerChange();
}
public hasHAStateConditions(): boolean {
return this._hasHAStateConditions;
}
public getEpoch(): ConditionsManagerEpoch {
return this._epoch;
}
public evaluateCondition(
condition: Readonly<FrigateCardCondition>,
stateOverrides?: Partial<ConditionState>,
): boolean {
const state = {
...this._state,
...stateOverrides,
};
let result = true;
if (condition.view?.length) {
result &&= !!state?.view && condition.view.includes(state.view);
}
if (condition.fullscreen !== undefined) {
result &&=
state.fullscreen !== undefined && condition.fullscreen === state.fullscreen;
}
if (condition.expand !== undefined) {
result &&= state.expand !== undefined && condition.expand === state.expand;
}
if (condition.camera?.length) {
result &&= !!state.camera && condition.camera.includes(state.camera);
}
if (condition.state?.length) {
for (const stateTest of condition.state) {
result &&=
!!state.state &&
((!stateTest.state && !stateTest.state_not) ||
(stateTest.entity in state.state &&
(!stateTest.state ||
state.state[stateTest.entity].state === stateTest.state) &&
(!stateTest.state_not ||
state.state[stateTest.entity].state !== stateTest.state_not)));
}
}
if (condition.media_loaded !== undefined) {
result &&=
state.media_loaded !== undefined &&
condition.media_loaded === state.media_loaded;
}
if (condition.media_query) {
result &&= window.matchMedia(condition.media_query).matches;
}
if (condition.display_mode) {
result &&= !!state.displayMode && condition.display_mode === state.displayMode;
}
return result;
}
protected _createEpoch(): ConditionsManagerEpoch {
return { manager: this };
}
protected _triggerChange(): void {
this._epoch = this._createEpoch();
this._listeners.forEach((listener) => listener());
}
}
+147
View File
@@ -0,0 +1,147 @@
import isEqual from 'lodash-es/isEqual';
import { isConfigUpgradeable } from '../../config-mgmt';
import { localize } from '../../localize/localize';
import { setLowPerformanceProfile } from '../../performance.js';
import {
CardWideConfig,
FrigateCardConfig,
frigateCardConfigSchema,
RawFrigateCardConfig,
} from '../../types';
import { getParseErrorPaths } from '../zod.js';
import { getOverriddenConfig } from './conditions-manager';
import { InitializationAspect } from './initialization-manager';
import { CardConfigAPI } from './types';
export class ConfigManager {
protected _api: CardConfigAPI;
// The main base configuration object. For most usecases use getConfig() to
// get the correct configuration (which will return overrides as appropriate).
// This variable must be called `_config` or `config` to be compatible with
// card-mod.
protected _config: FrigateCardConfig | null = null;
protected _overriddenConfig: FrigateCardConfig | null = null;
protected _rawConfig: RawFrigateCardConfig | null = null;
protected _cardWideConfig: CardWideConfig | null = null;
constructor(api) {
this._api = api;
}
public hasConfig(): boolean {
return !!this.getConfig();
}
public getConfig(): FrigateCardConfig | null {
return this._overriddenConfig ?? this._config;
}
public getCardWideConfig(): CardWideConfig | null {
return this._cardWideConfig;
}
public getNonOverriddenConfig(): FrigateCardConfig | null {
return this._config;
}
public getRawConfig(): RawFrigateCardConfig | null {
return this._rawConfig;
}
public setConfig(inputConfig?: RawFrigateCardConfig): void {
if (!inputConfig) {
throw new Error(localize('error.invalid_configuration'));
}
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
if (!parseResult.success) {
const configUpgradeable = isConfigUpgradeable(inputConfig);
const hint = getParseErrorPaths(parseResult.error);
let upgradeMessage = '';
if (configUpgradeable) {
upgradeMessage = `${localize('error.upgrade_available')}. `;
}
throw new Error(
upgradeMessage +
`${localize('error.invalid_configuration')}: ` +
(hint && hint.size
? JSON.stringify([...hint], null, ' ')
: localize('error.invalid_configuration_no_hint')),
);
}
const config =
parseResult.data.performance.profile !== 'low'
? parseResult.data
: setLowPerformanceProfile(inputConfig, parseResult.data);
this._rawConfig = inputConfig;
if (isEqual(this._config, config)) {
return;
}
this._config = config;
this._cardWideConfig = {
performance: config.performance,
debug: config.debug,
};
this._api.getConditionsManager().setConditionsFromConfig();
this._api.getConditionsManager().setState({
view: undefined,
displayMode: undefined,
camera: undefined,
});
this._api.getMediaLoadedInfoManager().clear();
this._api.getViewManager().reset();
this._api.getMessageManager().reset();
this._api.getAutomationsManager().setAutomationsFromConfig();
this._api.getStyleManager().setPerformance();
this._api.getCardElementManager().update();
this.computeOverrideConfig();
}
public computeOverrideConfig(): void {
const conditionsManager = this._api.getConditionsManager();
if (!this._config) {
return;
}
const overriddenConfig = getOverriddenConfig(
conditionsManager,
this._config,
this._config.overrides,
) as FrigateCardConfig;
// Save on Lit re-rendering costs by only updating the configuration if it
// actually changes.
if (isEqual(overriddenConfig, this._overriddenConfig)) {
return;
}
const previousConfig = this._overriddenConfig;
this._overriddenConfig = overriddenConfig;
this._api.getStyleManager().setMinMaxHeight();
if (
previousConfig &&
(!isEqual(previousConfig?.cameras, this._overriddenConfig?.cameras) ||
!isEqual(previousConfig?.cameras_global, this._overriddenConfig?.cameras_global))
) {
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
}
if (
previousConfig &&
previousConfig?.live.microphone.always_connected !==
this._overriddenConfig?.live.microphone.always_connected
) {
this._api
.getInitializationManager()
.uninitialize(InitializationAspect.MICROPHONE_CONNECT);
}
this._api.getCardElementManager().update();
}
}
+264
View File
@@ -0,0 +1,264 @@
// TODO: test errors during rendering to make sure resetMessage is no longer necessary.
// TODO: Test HA state connection/disconnect logic in real life.
// TODO: Should not need to import screenfull anywhere except the fullscreen manager.
// TODO: executeMediaQueryForView should not need a HTMLElement host parameter see the view-manager.ts call in particular.
import { LovelaceCardEditor } from 'custom-card-helpers';
import { ReactiveController } from 'lit';
import { CameraManager } from '../../camera-manager/manager';
import { FrigateCardConfig } from '../../types';
import { EntityRegistryManager } from '../ha/entity-registry';
import { EntityCache } from '../ha/entity-registry/cache';
import { ResolvedMediaCache } from '../ha/resolved-media';
import { ActionsManager } from './actions-manager';
import { AutoUpdateManager } from './auto-update-manager';
import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager';
import {
CardElementManager,
CardHTMLElement,
MenuToggleCallback,
ScrollCallback,
} from './card-element-manager';
import { ConditionsManager, ConditionsManagerListener } from './conditions-manager';
import { ConfigManager } from './config-manager';
import { DownloadManager } from './download-manager';
import { ExpandManager } from './expand-manager';
import { FullscreenManager } from './fullscreen-manager';
import { HASSManager } from './hass-manager';
import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
import { QueryStringManager } from './query-string-manager';
import { StyleManager } from './style-manager';
import { TriggersManager } from './triggers-manager';
import {
CardActionsManagerAPI,
CardAutomationsAPI,
CardAutoRefreshAPI,
CardCameraAPI,
CardCameraURLAPI,
CardConditionAPI,
CardConfigAPI,
CardDownloadAPI,
CardElementAPI,
CardExpandAPI,
CardFullscreenAPI,
CardHASSAPI,
CardInitializerAPI,
CardInteractionAPI,
CardMediaLoadedAPI,
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
CardViewAPI,
} from './types';
import { ViewManager } from './view-manager';
export class CardController
implements
CardActionsManagerAPI,
CardAutomationsAPI,
CardAutoRefreshAPI,
CardCameraAPI,
CardCameraURLAPI,
CardConditionAPI,
CardConfigAPI,
CardDownloadAPI,
CardElementAPI,
CardExpandAPI,
CardFullscreenAPI,
CardHASSAPI,
CardInitializerAPI,
CardInteractionAPI,
CardMediaLoadedAPI,
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
CardViewAPI,
ReactiveController
{
// These properties may be used in the construction of 'managers' (and should
// be created first).
protected _entityRegistryManager = new EntityRegistryManager(new EntityCache());
protected _resolvedMediaCache = new ResolvedMediaCache();
protected _actionsManager = new ActionsManager(this);
protected _automationsManager = new AutomationsManager(this);
protected _autoUpdateManager = new AutoUpdateManager(this);
protected _cameraManager: CameraManager = new CameraManager(this);
protected _cameraURLManager = new CameraURLManager(this);
protected _cardElementManager: CardElementManager;
protected _conditionsManager: ConditionsManager;
protected _configManager = new ConfigManager(this);
protected _downloadManager: DownloadManager = new DownloadManager(this);
protected _expandManager = new ExpandManager(this);
protected _fullscreenManager = new FullscreenManager(this);
protected _hassManager = new HASSManager(this);
protected _initializationManager = new InitializationManager(this);
protected _interactionManager = new InteractionManager(this);
protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
protected _mediaPlayerManager = new MediaPlayerManager(this);
protected _messageManager = new MessageManager(this);
protected _microphoneManager = new MicrophoneManager(this);
protected _queryStringManager = new QueryStringManager(this);
protected _styleManager = new StyleManager(this);
protected _triggersManager: TriggersManager = new TriggersManager(this);
protected _viewManager = new ViewManager(this);
constructor(
host: CardHTMLElement,
scrollCallback: ScrollCallback,
menuToggleCallback: MenuToggleCallback,
conditionListener: ConditionsManagerListener,
) {
host.addController(this);
this._conditionsManager = new ConditionsManager(this, conditionListener);
this._cardElementManager = new CardElementManager(
this,
host,
scrollCallback,
menuToggleCallback,
);
}
// *************************************************************************
// Accessors
// *************************************************************************
public getActionsManager(): ActionsManager {
return this._actionsManager;
}
public getAutomationsManager(): AutomationsManager {
return this._automationsManager;
}
public getAutoUpdateManager(): AutoUpdateManager {
return this._autoUpdateManager;
}
public getCameraManager(): CameraManager {
return this._cameraManager;
}
public getCameraURLManager(): CameraURLManager {
return this._cameraURLManager;
}
public getCardElementManager(): CardElementManager {
return this._cardElementManager;
}
public getConditionsManager(): ConditionsManager {
return this._conditionsManager;
}
public static async getConfigElement(): Promise<LovelaceCardEditor> {
await import('../../editor.js');
return document.createElement('frigate-card-editor');
}
public getConfigManager(): ConfigManager {
return this._configManager;
}
public getDownloadManager(): DownloadManager {
return this._downloadManager;
}
public getEntityRegistryManager(): EntityRegistryManager {
return this._entityRegistryManager;
}
public getExpandManager(): ExpandManager {
return this._expandManager;
}
public getFullscreenManager(): FullscreenManager {
return this._fullscreenManager;
}
public getHASSManager(): HASSManager {
return this._hassManager;
}
public getInitializationManager(): InitializationManager {
return this._initializationManager;
}
public getInteractionManager(): InteractionManager {
return this._interactionManager;
}
public getMediaLoadedInfoManager(): MediaLoadedInfoManager {
return this._mediaLoadedInfoManager;
}
public getMediaPlayerManager(): MediaPlayerManager {
return this._mediaPlayerManager;
}
public getMessageManager(): MessageManager {
return this._messageManager;
}
public getMicrophoneManager(): MicrophoneManager {
return this._microphoneManager;
}
public getQueryStringManager(): QueryStringManager {
return this._queryStringManager;
}
public getResolvedMediaCache(): ResolvedMediaCache {
return this._resolvedMediaCache;
}
public static getStubConfig(entities: string[]): FrigateCardConfig {
const cameraEntity = entities.find((element) => element.startsWith('camera.'));
return {
cameras: [
{
camera_entity: cameraEntity ?? 'camera.demo'
},
],
// Need to use 'as unknown' to convince Typescript that this really isn't a
// mistake, despite the miniscule size of the configuration vs the full type
// description.
} as unknown as FrigateCardConfig;
}
public getStyleManager(): StyleManager {
return this._styleManager;
}
public getTriggersManager(): TriggersManager {
return this._triggersManager;
}
public getViewManager(): ViewManager {
return this._viewManager;
}
// *************************************************************************
// Handlers
// *************************************************************************
public hostConnected(): void {
this.getCardElementManager().elementConnected();
}
public hostDisconnected(): void {
this.getCardElementManager().elementDisconnected();
}
}
@@ -0,0 +1,41 @@
import { downloadMedia, downloadURL } from '../download';
import { generateScreenshotTitle } from '../screenshot';
import { CardDownloadAPI } from './types';
export class DownloadManager {
protected _api: CardDownloadAPI;
constructor(api: CardDownloadAPI) {
this._api = api;
}
public async downloadViewerMedia(): Promise<boolean> {
const media = this._api
.getViewManager()
.getView()
?.queryResults?.getSelectedResult();
const hass = this._api.getHASSManager().getHASS();
if (!media || !hass) {
return false;
}
try {
await downloadMedia(hass, this._api.getCameraManager(), media);
} catch (error: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
return false;
}
return true;
}
public async downloadScreenshot(): Promise<void> {
const url = await this._api
.getMediaLoadedInfoManager()
.get()
?.player?.getScreenshotURL();
if (url) {
downloadURL(url, generateScreenshotTitle(this._api.getViewManager().getView()));
}
}
}
@@ -0,0 +1,31 @@
import { CardExpandAPI } from './types';
export class ExpandManager {
protected _expanded = false;
protected _api: CardExpandAPI;
constructor(api: CardExpandAPI) {
this._api = api;
}
public isExpanded(): boolean {
return this._expanded;
}
public toggleExpanded(): void {
this.setExpanded(!this._expanded);
}
public setExpanded(expanded: boolean): void {
if (expanded && this._api.getFullscreenManager().isInFullscreen()) {
// Fullscreen and expanded mode are mutually exclusive.
this._api.getFullscreenManager().stopFullscreen();
}
this._expanded = expanded;
this._api.getConditionsManager()?.setState({
expand: expanded,
});
this._api.getCardElementManager().update();
}
}
@@ -0,0 +1,47 @@
import screenfull from 'screenfull';
import { CardFullscreenAPI } from './types';
export class FullscreenManager {
protected _api: CardFullscreenAPI;
constructor(api: CardFullscreenAPI) {
this._api = api;
}
public connect(): void {
if (screenfull.isEnabled) {
screenfull.on('change', this._fullscreenHandler);
}
}
public disconnect(): void {
if (screenfull.isEnabled) {
screenfull.off('change', this._fullscreenHandler);
}
}
public isInFullscreen(): boolean {
return screenfull.isEnabled && screenfull.isFullscreen;
}
public toggleFullscreen(): void {
screenfull.toggle(this._api.getCardElementManager().getElement());
}
public stopFullscreen(): void {
screenfull.exit();
}
protected _fullscreenHandler = (): void => {
this._api.getExpandManager().setExpanded(false);
this._api.getConditionsManager()?.setState({
fullscreen: this.isInFullscreen(),
});
// Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked
// modes).
this._api.getCardElementManager().update();
};
}
+88
View File
@@ -0,0 +1,88 @@
import { localize } from '../../localize/localize';
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
import { hasHAConnectionStateChanged, isHassDifferent } from '../ha';
import { CardHASSAPI } from './types';
export class HASSManager {
protected _hass: ExtendedHomeAssistant | null = null;
protected _api: CardHASSAPI;
constructor(api: CardHASSAPI) {
this._api = api;
}
public getHASS(): ExtendedHomeAssistant | null {
return this._hass;
}
public setHASS(hass: ExtendedHomeAssistant): void {
const getSelectedCameraConfig = (): CameraConfig | null => {
const view = this._api.getViewManager().getView();
const cameraManager = this._api.getCameraManager();
return view && cameraManager
? cameraManager?.getStore().getCameraConfig(view.camera) ?? null
: null;
};
const oldHass = this._hass;
this._hass = hass;
const selectedCamera = getSelectedCameraConfig();
if (hasHAConnectionStateChanged(oldHass, hass)) {
if (!this._hass?.connected) {
this._api.getMessageManager().setMessageIfHigherPriority({
message: localize('error.reconnecting'),
icon: 'mdi:lan-disconnect',
type: 'connection',
dotdotdot: true,
});
} else {
this._api.getViewManager().setViewDefault();
}
} else if (
// Home Assistant pumps a lot of updates through. Re-rendering the card is
// necessary at times (e.g. to update the 'clip' view as new clips
// arrive), but also is a jarring experience for the user (e.g. if they
// are browsing the mini-gallery). Do not allow re-rendering from a Home
// Assistant update if there's been recent interaction (e.g. clicks on the
// card) or if there is media active playing.
this._isAutomatedViewUpdateAllowed() &&
isHassDifferent(this._hass, oldHass, [
...(this._api.getConfigManager().getConfig()?.view.update_entities ?? []),
...(selectedCamera?.triggers.entities ?? []),
])
) {
// If entities being monitored have changed then reset the view to the
// default.
this._api.getViewManager().setViewDefault();
} else if (
isHassDifferent(this._hass, oldHass, [
...(this._api.getConfigManager().getConfig()?.view.render_entities ?? []),
// Refresh the card if media player state changes:
// https://github.com/dermotduffy/frigate-hass-card/issues/881
...this._api.getMediaPlayerManager().getMediaPlayers(),
])
) {
this._api.getCardElementManager().update();
}
this._api.getTriggersManager().updateTriggeredCameras(oldHass);
if (this._api.getConditionsManager().hasHAStateConditions()) {
this._api.getConditionsManager().setState({ state: this._hass.states });
}
// Dark mode may depend on HASS.
this._api.getStyleManager().setLightOrDarkMode();
}
protected _isAutomatedViewUpdateAllowed(): boolean {
return (
this._api.getConfigManager().getConfig()?.view.update_force ||
!this._api.getInteractionManager().hasInteraction()
);
}
}
@@ -0,0 +1,134 @@
import { loadLanguages } from '../../localize/localize';
import { sideLoadHomeAssistantElements } from '../ha';
import { Initializer } from '../initializer/initializer';
import { CardInitializerAPI } from './types';
export enum InitializationAspect {
LANGUAGES = 'languages',
SIDE_LOAD_ELEMENTS = 'side-load-elements',
MEDIA_PLAYERS = 'media-players',
CAMERAS = 'cameras',
MICROPHONE_CONNECT = 'microphone-connect',
}
export class InitializationManager {
protected _api: CardInitializerAPI;
protected _initializer;
constructor(api: CardInitializerAPI, initializer?: Initializer) {
this._api = api;
this._initializer = initializer ?? new Initializer();
}
public isInitializedMandatory(): boolean {
return this._initializer.isInitializedMultiple([
InitializationAspect.LANGUAGES,
InitializationAspect.SIDE_LOAD_ELEMENTS,
InitializationAspect.CAMERAS,
]);
}
/**
* Initialize the hard requirements for rendering anything.
* @returns `true` if card rendering can continue.
*/
public async initializeMandatory(): Promise<boolean> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return false;
}
if (
!(await this._initializer.initializeMultipleIfNecessary({
// Caution: Ensure nothing in this set of initializers requires
// config or languages since they will not yet have been initialized.
[InitializationAspect.LANGUAGES]: async () => await loadLanguages(hass),
[InitializationAspect.SIDE_LOAD_ELEMENTS]: async () =>
await sideLoadHomeAssistantElements(),
}))
) {
return false;
}
if (!this._api.getConfigManager().hasConfig()) {
return false;
}
if (
!(await this._initializer.initializeIfNecessary(
InitializationAspect.CAMERAS,
async () => await this._api.getCameraManager().initializeCamerasFromConfig(),
))
) {
return false;
}
if (!this._api.getMessageManager().hasMessage()) {
// Set a view on initial load. However, if the query string contains a
// view related action, we don't set any view here and allow that content
// to be triggered by the firstUpdated() call that runs query string
// actions. To do otherwise may cause a race condition between the default
// view and the querystring view, see:
// https://github.com/dermotduffy/frigate-hass-card/issues/1200
const hasViewRelatedActions = this._api
.getQueryStringManager()
.hasViewRelatedActions();
if (hasViewRelatedActions) {
this._api.getQueryStringManager().executeViewRelated();
} else {
this._api.getViewManager().setViewDefault();
}
}
this._api.getCardElementManager().update();
return true;
}
/**
* Initialize aspects of the card that can load in the 'background'.
* @returns `true` if card rendering can continue.
*/
public async initializeBackgroundIfNecessary(): Promise<boolean> {
const hass = this._api.getHASSManager().getHASS();
const config = this._api.getConfigManager().getConfig();
if (!hass || !config) {
return false;
}
if (
this._initializer.isInitializedMultiple([
...(config.menu.buttons.media_player.enabled
? [InitializationAspect.MEDIA_PLAYERS]
: []),
...(config.live.microphone.always_connected
? [InitializationAspect.MICROPHONE_CONNECT]
: []),
])
) {
return true;
}
if (
!(await this._initializer.initializeMultipleIfNecessary({
...(config.menu.buttons.media_player.enabled && {
[InitializationAspect.MEDIA_PLAYERS]: async () =>
await this._api.getMediaPlayerManager().initialize(),
}),
...(config.live.microphone.always_connected && {
[InitializationAspect.MICROPHONE_CONNECT]: async () =>
await this._api.getMicrophoneManager().connect(),
}),
}))
) {
return false;
}
this._api.getCardElementManager().update();
return true;
}
public uninitialize(aspect: InitializationAspect) {
return this._initializer.uninitialize(aspect);
}
}
@@ -0,0 +1,50 @@
import throttle from 'lodash-es/throttle';
import { Timer } from '../timer';
import { CardInteractionAPI } from './types';
export class InteractionManager {
protected _timer = new Timer();
protected _api: CardInteractionAPI;
constructor(api: CardInteractionAPI) {
this._api = api;
}
// The mouse handler may be called continually, throttle it to at most once
// per second for performance reasons.
public reportInteraction = throttle(() => {
this._reportInteraction();
}, 1 * 1000);
public hasInteraction(): boolean {
return this._timer.isRunning();
}
/**
* Start the user interaction ('screensaver') timer to reset the view to
* default `view.timeout_seconds` after user interaction.
*/
protected _reportInteraction(): void {
this._timer.stop();
// Interactions reset the trigger state.
this._api.getTriggersManager().untrigger();
const timeoutSeconds = this._api.getConfigManager().getConfig()
?.view.timeout_seconds;
if (timeoutSeconds) {
this._timer.start(timeoutSeconds, () => {
if (this._isAutomatedUpdateAllowed()) {
this._api.getViewManager().setViewDefault();
this._api.getStyleManager().setLightOrDarkMode();
}
});
}
this._api.getStyleManager().setLightOrDarkMode();
}
protected _isAutomatedUpdateAllowed(): boolean {
return !this._api.getTriggersManager().isTriggered();
}
}
@@ -0,0 +1,52 @@
import { MediaLoadedInfo } from '../../types';
import { log } from '../debug';
import { isValidMediaLoadedInfo } from '../media-info';
import { CardMediaLoadedAPI } from './types';
export class MediaLoadedInfoManager {
protected _api: CardMediaLoadedAPI;
protected _current: MediaLoadedInfo | null = null;
protected _lastKnown: MediaLoadedInfo | null = null;
constructor(api: CardMediaLoadedAPI) {
this._api = api;
}
public set(mediaInfo: MediaLoadedInfo): void {
if (!isValidMediaLoadedInfo(mediaInfo)) {
return;
}
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card media load: `,
mediaInfo,
);
this._current = mediaInfo;
this._lastKnown = mediaInfo;
this._api.getConditionsManager().setState({ media_loaded: true });
// Fresh media information may change how the card is rendered.
this._api.getStyleManager().setExpandedMode();
this._api.getCardElementManager().update();
}
public get(): MediaLoadedInfo | null {
return this._current;
}
public getLastKnown(): MediaLoadedInfo | null {
return this._lastKnown;
}
public clear(): void {
this._current = null;
this._api.getConditionsManager().setState({ media_loaded: false });
}
public has(): boolean {
return !!this._current;
}
}
@@ -0,0 +1,125 @@
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../../const';
import { ViewMedia } from '../../view/media';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { errorToConsole } from '../basic';
import { Entity } from '../ha/entity-registry/types';
import { supportsFeature } from '../ha/update';
import { CardMediaPlayerAPI } from './types';
export class MediaPlayerManager {
protected _mediaPlayers: string[] = [];
protected _api: CardMediaPlayerAPI;
constructor(api: CardMediaPlayerAPI) {
this._api = api;
}
public getMediaPlayers(): string[] {
return this._mediaPlayers;
}
public hasMediaPlayers(): boolean {
return this._mediaPlayers.length > 0;
}
public async initialize(): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
}
const isValidMediaPlayer = (entityID: string): boolean => {
if (entityID.startsWith('media_player.')) {
const stateObj = hass.states[entityID];
if (
stateObj &&
stateObj.state !== 'unavailable' &&
supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA)
) {
return true;
}
}
return false;
};
const mediaPlayers = Object.keys(hass.states).filter(isValidMediaPlayer);
let mediaPlayerEntities: Map<string, Entity> | null = null;
try {
mediaPlayerEntities = await this._api
.getEntityRegistryManager()
.getEntities(hass, mediaPlayers);
} catch (e) {
// Failing to fetch media player information is not considered
// sufficiently serious to block card startup -- it is just logged and we
// move on.
errorToConsole(e as Error);
}
// Filter out entities that are marked as hidden (this information is not
// available in the HA state, only in the registry).
this._mediaPlayers = mediaPlayers.filter((entityID) => {
// Specifically allow for media players that are not found in the entity registry:
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1016
const entity = mediaPlayerEntities?.get(entityID);
return !entity || !entity.hidden_by;
});
}
public async stop(mediaPlayer: string): Promise<void> {
await this._api
.getHASSManager()
.getHASS()
?.callService('media_player', 'media_stop', {
entity_id: mediaPlayer,
});
}
public async playLive(mediaPlayer: string, cameraID: string): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
const cameraConfig = this._api
.getCameraManager()
.getStore()
.getCameraConfig(cameraID);
const cameraEntity = cameraConfig?.camera_entity ?? null;
if (!hass || !cameraEntity) {
return;
}
const title =
this._api.getCameraManager().getCameraMetadata(cameraID)?.title ?? null;
const thumbnail = hass.states[cameraEntity]?.attributes?.entity_picture ?? null;
await hass.callService('media_player', 'play_media', {
entity_id: mediaPlayer,
media_content_id: `media-source://camera/${cameraEntity}`,
media_content_type: 'application/vnd.apple.mpegurl',
extra: {
...(title && { title: title }),
...(thumbnail && { thumb: thumbnail }),
},
});
}
public async playMedia(mediaPlayer: string, media?: ViewMedia | null): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass || !media) {
return;
}
const title = media.getTitle();
const thumbnail = media.getThumbnail();
await hass.callService('media_player', 'play_media', {
entity_id: mediaPlayer,
media_content_id: media.getContentID(),
media_content_type: ViewMediaClassifier.isVideo(media) ? 'video' : 'image',
extra: {
...(title && { title: title }),
...(thumbnail && { thumb: thumbnail }),
},
});
}
}
@@ -0,0 +1,67 @@
import { FrigateCardError, MESSAGE_TYPE_PRIORITIES, Message } from '../../types';
import { errorToConsole } from '../basic';
import { CardMessageAPI } from './types';
export class MessageManager {
protected _message: Message | null = null;
protected _api: CardMessageAPI;
constructor(api: CardMessageAPI) {
this._api = api;
}
public getMessage(): Message | null {
return this._message;
}
public hasMessage(): boolean {
return !!this._message;
}
public hasErrorMessage(): boolean {
return this._message?.type === 'error';
}
public reset(): void {
const hadMessage = this.hasMessage();
this._message = null;
if (hadMessage) {
this._api.getCardElementManager().update();
}
}
public setErrorIfHigherPriority(error: unknown): void {
// This object should accept unknown objects to be able to seamlessly
// process arguments to catch() which can only be unknown/any.
if (!(error instanceof Error)) {
return;
}
errorToConsole(error);
this.setMessageIfHigherPriority({
message: error.message,
type: 'error',
...(error instanceof FrigateCardError && { context: error.context }),
});
}
public setMessageIfHigherPriority(message: Message): boolean {
const currentPriority = this._message
? MESSAGE_TYPE_PRIORITIES[this._message.type]
: 0;
const newPriority = MESSAGE_TYPE_PRIORITIES[message.type];
if (this._message && newPriority < currentPriority) {
return false;
}
this._message = message;
// When a message is displayed it effectively unloads the media.
this._api.getMediaLoadedInfoManager().clear();
this._api.getCardElementManager().scrollReset();
this._api.getCardElementManager().update();
return true;
}
}
@@ -1,7 +1,9 @@
import { errorToConsole } from './basic';
import { Timer } from './timer';
import { errorToConsole } from '../basic';
import { Timer } from '../timer';
import { CardMicrophoneAPI } from './types';
export class MicrophoneController {
export class MicrophoneManager {
protected _api: CardMicrophoneAPI;
protected _stream?: MediaStream | null;
protected _timer = new Timer();
@@ -10,10 +12,8 @@ export class MicrophoneController {
// have the right mute status.
protected _mute = true;
protected _disconnectSeconds: number;
constructor(disconnectSeconds?: number) {
this._disconnectSeconds = disconnectSeconds ?? 0;
constructor(api: CardMicrophoneAPI) {
this._api = api;
}
public async connect(): Promise<void> {
@@ -32,6 +32,8 @@ export class MicrophoneController {
public async disconnect(): Promise<void> {
this._stream?.getTracks().forEach((track) => track.stop());
this._stream = undefined;
this._api.getCardElementManager().update();
}
public getStream(): MediaStream | undefined {
@@ -43,6 +45,8 @@ export class MicrophoneController {
track.enabled = !this._mute;
});
this._startTimer();
this._api.getCardElementManager().update();
}
public mute(): void {
@@ -50,9 +54,24 @@ export class MicrophoneController {
this._setMute();
}
public unmute(): void {
this._mute = false;
this._setMute();
public async unmute(): Promise<void> {
const unmute = (): void => {
this._mute = false;
this._setMute();
};
if (!this.isConnected() && !this.isForbidden()) {
// The connect() call is async and make take an arbitrary amount of
// time for the user to grant access to their microphone. With a
// momentary microphone button the mute call (on mouse release) may
// arrive before the connection is even granted, so we unmute first
// before the connection is made, so the mute call on release will not
// be 'overwritten' incorrectly.
unmute();
await this.connect();
} else if (this.isConnected()) {
unmute();
}
}
public isConnected(): boolean {
@@ -70,8 +89,17 @@ export class MicrophoneController {
}
protected _startTimer(): void {
if (this._disconnectSeconds) {
this._timer.start(this._disconnectSeconds, () => {
const microphoneConfig = this._api.getConfigManager().getConfig()
?.live.microphone;
if (microphoneConfig?.always_connected) {
return;
}
const disconnectSeconds = microphoneConfig?.disconnect_seconds ?? 0;
if (disconnectSeconds) {
this._timer.start(disconnectSeconds, () => {
this.disconnect();
});
}
@@ -0,0 +1,160 @@
import { FrigateCardCustomAction, FrigateCardViewAction } from '../../types';
import { createFrigateCardCustomAction } from '../action.js';
import { CardQueryStringAPI } from './types';
import { ViewManagerSetViewParameters } from './view-manager';
interface QueryStringViewIntent {
view?: ViewManagerSetViewParameters & {
default?: boolean;
};
other?: FrigateCardCustomAction[];
}
export class QueryStringManager {
protected _api: CardQueryStringAPI;
constructor(api: CardQueryStringAPI) {
this._api = api;
}
public hasViewRelatedActions(): boolean {
return !!this._calculateIntent().view;
}
public executeNonViewRelated(): void {
this._executeNonViewRelated(this._calculateIntent());
}
public executeViewRelated(): void {
this._executeViewRelated(this._calculateIntent());
}
public executeAll(): void {
const intent = this._calculateIntent();
this._executeViewRelated(intent);
this._executeNonViewRelated(intent);
}
protected _executeViewRelated(intent: QueryStringViewIntent): void {
if (intent.view) {
if (intent.view.default) {
this._api.getViewManager().setViewDefault({
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
});
} else {
this._api.getViewManager().setViewByParameters({
...(intent.view.viewName && { viewName: intent.view.viewName }),
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
});
}
}
}
protected _executeNonViewRelated(intent: QueryStringViewIntent): void {
// Only execute non-view actions when the card has rendered at least once.
if (!this._api.getCardElementManager().hasUpdated()) {
return;
}
intent.other?.forEach((action) =>
this._api.getActionsManager().executeAction(action),
);
}
protected _calculateIntent(): QueryStringViewIntent {
const result: QueryStringViewIntent = {};
for (const action of this._getActions()) {
if (this._isViewAction(action)) {
(result.view ??= {}).viewName = action.frigate_card_action;
(result.view ??= {}).default = undefined;
} else if (action.frigate_card_action === 'default') {
(result.view ??= {}).default = true;
(result.view ??= {}).viewName = undefined;
} else if (action.frigate_card_action === 'camera_select') {
(result.view ??= {}).cameraID = action.camera;
} else if (action.frigate_card_action === 'live_substream_select') {
(result.view ??= {}).substream = action.camera;
} else {
(result.other ??= []).push(action);
}
}
return result;
}
protected _getActions(): FrigateCardCustomAction[] {
const params = new URLSearchParams(window.location.search);
const actions: FrigateCardCustomAction[] = [];
const actionRE = new RegExp(
/^frigate-card-action([.:](?<cardID>\w+))?[.:](?<action>\w+)/,
);
for (const [key, value] of params.entries()) {
const match = key.match(actionRE);
if (!match || !match.groups) {
continue;
}
const cardID: string | undefined = match.groups['cardID'];
const action = match.groups['action'];
let customAction: FrigateCardCustomAction | null = null;
switch (action) {
case 'camera_select':
case 'live_substream_select':
if (value) {
customAction = createFrigateCardCustomAction(action, {
camera: value,
cardID: cardID,
});
}
break;
case 'camera_ui':
case 'clip':
case 'clips':
case 'default':
case 'diagnostics':
case 'download':
case 'expand':
case 'image':
case 'live':
case 'menu_toggle':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
customAction = createFrigateCardCustomAction(action, {
cardID: cardID,
});
break;
default:
console.warn(
`Frigate card received unknown card action in query string: ${action}`,
);
}
if (customAction) {
actions.push(customAction);
}
}
return actions;
}
protected _isViewAction = (
action: FrigateCardCustomAction,
): action is FrigateCardViewAction => {
switch (action.frigate_card_action) {
case 'clip':
case 'clips':
case 'diagnostics':
case 'image':
case 'live':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
return true;
}
return false;
};
}
+121
View File
@@ -0,0 +1,121 @@
import { setPerformanceCSSStyles } from '../../performance';
import { FrigateCardConfig } from '../../types';
import { View } from '../../view/view';
import { setOrRemoveAttribute } from '../basic';
import { CardStyleAPI } from './types';
export class StyleManager {
protected _api: CardStyleAPI;
constructor(api: CardStyleAPI) {
this._api = api;
}
public setLightOrDarkMode(): void {
const config = this._api.getConfigManager().getConfig();
const isDarkMode =
config?.view.dark_mode === 'on' ||
(config?.view.dark_mode === 'auto' &&
(!this._api.getInteractionManager().hasInteraction() ||
!!this._api.getHASSManager().getHASS()?.themes.darkMode));
setOrRemoveAttribute(
this._api.getCardElementManager().getElement(),
isDarkMode,
'dark',
);
}
public setExpandedMode(): void {
const card = this._api.getCardElementManager().getElement();
const view = this._api.getViewManager().getView();
// When a new media loads, set the aspect ratio for when the card is
// expanded/popped-up. This is based exclusively on last media content,
// as dimension configuration does not apply in fullscreen or expanded mode.
const lastKnown = this._api.getMediaLoadedInfoManager().getLastKnown();
card.style.setProperty(
'--frigate-card-expand-aspect-ratio',
view?.isAnyMediaView() && lastKnown
? `${lastKnown.width} / ${lastKnown.height}`
: 'unset',
);
// Non-media may have no intrinsic dimensions (or multiple media items in a
// grid) and so we need to explicit request the dialog to use all available
// space.
const isGrid = view?.isGrid();
card.style.setProperty(
'--frigate-card-expand-width',
!isGrid && view?.isAnyMediaView()
? 'none'
: 'var(--frigate-card-expand-max-width)',
);
card.style.setProperty(
'--frigate-card-expand-height',
!isGrid && view?.isAnyMediaView()
? 'none'
: 'var(--frigate-card-expand-max-height)',
);
}
public setMinMaxHeight(): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
const card = this._api.getCardElementManager().getElement();
card.style.setProperty('--frigate-card-min-height', config.dimensions.min_height);
card.style.setProperty('--frigate-card-max-height', config.dimensions.max_height);
}
}
public setPerformance(): void {
setPerformanceCSSStyles(
this._api.getCardElementManager().getElement(),
this._api.getConfigManager().getCardWideConfig()?.performance,
);
}
protected _isAspectRatioEnforced(config: FrigateCardConfig, view: View): boolean {
const aspectRatioMode = config.dimensions.aspect_ratio_mode;
// Do not artifically constrain aspect ratio if:
// - It's fullscreen.
// - It's in expanded mode.
// - Aspect ratio enforcement is disabled.
// - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the
// gallery) or diagnostics / timeline.
return !(
this._api.getFullscreenManager().isInFullscreen() ||
this._api.getExpandManager().isExpanded() ||
aspectRatioMode === 'unconstrained' ||
(aspectRatioMode === 'dynamic' &&
(view.isAnyMediaView() || view.is('timeline'))) ||
view.is('diagnostics')
);
}
/**
* Get the aspect ratio padding required to enforce the aspect ratio (if it is
* required).
* @returns A padding percentage.
*/
public getAspectRatioStyle(): string {
const config = this._api.getConfigManager().getConfig();
const view = this._api.getViewManager().getView();
if (config && view) {
if (!this._isAspectRatioEnforced(config, view)) {
return 'auto';
}
const aspectRatioMode = config.dimensions.aspect_ratio_mode;
const lastKnown = this._api.getMediaLoadedInfoManager().getLastKnown();
if (lastKnown && aspectRatioMode === 'dynamic') {
return `${lastKnown.width} / ${lastKnown.height}`;
}
return `${config.dimensions.aspect_ratio[0]} / ${config.dimensions.aspect_ratio[1]}`;
}
return '16 / 9';
}
}
@@ -0,0 +1,126 @@
import { HomeAssistant } from 'custom-card-helpers';
import orderBy from 'lodash-es/orderBy';
import { getHassDifferences, isTriggeredState } from '../ha';
import { Timer } from '../timer';
import { CardTriggersAPI } from './types';
export class TriggersManager {
protected _api: CardTriggersAPI;
protected _triggers: Map<string, Date> = new Map();
protected _untriggerTimer = new Timer();
constructor(api: CardTriggersAPI) {
this._api = api;
}
public isTriggered(): boolean {
return !!this._triggers.size || this._untriggerTimer.isRunning();
}
public updateTriggeredCameras(oldHass?: HomeAssistant | null): boolean {
if (!this._shouldTrackTriggers()) {
return false;
}
const hass = this._api.getHASSManager().getHASS();
const now = new Date();
let triggerChanges = false;
const cameras = this._api.getCameraManager().getStore().getVisibleCameras();
for (const [cameraID, config] of cameras?.entries()) {
const triggerEntities = config.triggers.entities;
const diffs = getHassDifferences(hass, oldHass, triggerEntities, {
stateOnly: true,
});
const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState));
const shouldUntrigger = triggerEntities.every(
(entity) => !isTriggeredState(hass?.states[entity]),
);
if (shouldTrigger) {
this._triggers.set(cameraID, now);
triggerChanges = true;
} else if (shouldUntrigger && this._triggers.has(cameraID)) {
this._triggers.delete(cameraID);
triggerChanges = true;
}
}
if (triggerChanges) {
const targetCameraID = this._getMostRecentTrigger();
if (targetCameraID) {
this._triggerAction(targetCameraID);
return true;
} else {
this._startUntriggerTimer();
}
}
return false;
}
public untrigger(): void {
const wasTriggered = this.isTriggered();
this._triggers.clear();
this._untriggerTimer.stop();
if (wasTriggered) {
this._untriggerAction();
}
}
protected _triggerAction(cameraID: string): void {
const view = this._api.getViewManager().getView();
if (
this._isAutomatedViewUpdateAllowed() &&
(view?.camera !== cameraID || !view?.is('live'))
) {
this._api.getViewManager().setViewByParameters({
viewName: 'live',
cameraID: cameraID,
});
}
}
protected _untriggerAction(): void {
if (
!this.isTriggered() &&
this._isAutomatedViewUpdateAllowed() &&
this._api.getConfigManager().getConfig()?.view.scan.untrigger_reset
) {
this._api.getViewManager().setViewDefault();
}
}
protected _isAutomatedViewUpdateAllowed(): boolean {
return (
this._api.getConfigManager().getConfig()?.view.update_force ||
!this._api.getInteractionManager().hasInteraction()
);
}
protected _shouldTrackTriggers(): boolean {
return !!this._api.getConfigManager().getConfig()?.view.scan.enabled;
}
protected _startUntriggerTimer(): void {
this._untriggerTimer.start(
/* istanbul ignore next: the case of config being null here cannot be
reached, as there's no way to have the untrigger call happen without
a config. -- @preserve */
this._api.getConfigManager().getConfig()?.view.scan.untrigger_seconds ?? 0,
() => {
this._untriggerAction();
},
);
}
protected _getMostRecentTrigger(): string | null {
const sorted = orderBy(
[...this._triggers.entries()],
(entry) => entry[1].getTime(),
'desc',
);
return sorted.length ? sorted[0][0] : null;
}
}
+214
View File
@@ -0,0 +1,214 @@
import { CameraManager } from '../../camera-manager/manager';
import { ConditionsManager } from './conditions-manager';
import { EntityRegistryManager } from '../ha/entity-registry';
import { ResolvedMediaCache } from '../ha/resolved-media';
import { ActionsManager } from './actions-manager';
import { AutoUpdateManager } from './auto-update-manager';
import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager';
import { CardElementManager } from './card-element-manager';
import { ConfigManager } from './config-manager';
import { DownloadManager } from './download-manager';
import { ExpandManager } from './expand-manager';
import { FullscreenManager } from './fullscreen-manager';
import { HASSManager } from './hass-manager';
import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
import { StyleManager } from './style-manager';
import { TriggersManager } from './triggers-manager';
import { ViewManager } from './view-manager';
import { QueryStringManager } from './query-string-manager';
/**
* This defines a series of limited APIs that various manager helpers use to
* control the card. Explicitly specifying them helps make coupling intentional
* and avoids cyclic importing.
*/
export interface CardActionsManagerAPI {
getCameraManager(): CameraManager;
getCameraURLManager(): CameraURLManager;
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
getDownloadManager(): DownloadManager;
getExpandManager(): ExpandManager;
getFullscreenManager(): FullscreenManager;
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getViewManager(): ViewManager;
getMicrophoneManager(): MicrophoneManager;
}
export interface CardAutomationsAPI {
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getMessageManager(): MessageManager;
}
export interface CardAutoRefreshAPI {
getConfigManager(): ConfigManager;
getViewManager(): ViewManager;
getTriggersManager(): TriggersManager;
getInteractionManager(): InteractionManager;
}
export interface CardCameraAPI {
getConfigManager(): ConfigManager;
getEntityRegistryManager(): EntityRegistryManager;
getResolvedMediaCache(): ResolvedMediaCache;
getHASSManager(): HASSManager;
getMessageManager(): MessageManager;
}
export interface CardCameraURLAPI {
getCameraManager(): CameraManager;
getViewManager(): ViewManager;
}
export interface CardConditionAPI {
getAutomationsManager(): AutomationsManager;
getConfigManager(): ConfigManager;
}
export interface CardConfigAPI {
getAutomationsManager(): AutomationsManager;
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getStyleManager(): StyleManager;
getViewManager(): ViewManager;
}
export interface CardDownloadAPI {
getCameraManager(): CameraManager;
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getViewManager(): ViewManager;
}
export interface CardElementAPI {
getActionsManager(): ActionsManager;
getFullscreenManager(): FullscreenManager;
getInteractionManager(): InteractionManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getQueryStringManager(): QueryStringManager;
}
export interface CardExpandAPI {
getFullscreenManager(): FullscreenManager;
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager;
}
export interface CardFullscreenAPI {
getCardElementManager(): CardElementManager;
getExpandManager(): ExpandManager;
getConditionsManager(): ConditionsManager;
getMediaPlayerManager(): MediaPlayerManager;
}
export interface CardHASSAPI {
getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getInteractionManager(): InteractionManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
}
export interface CardInitializerAPI {
getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getMicrophoneManager(): MicrophoneManager;
getQueryStringManager(): QueryStringManager;
getResolvedMediaCache(): ResolvedMediaCache;
getViewManager(): ViewManager;
}
export interface CardInteractionAPI {
getConfigManager(): ConfigManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
}
export interface CardMediaLoadedAPI {
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getCardElementManager(): CardElementManager;
getStyleManager(): StyleManager;
}
export interface CardMediaPlayerAPI {
getHASSManager(): HASSManager;
getCameraManager(): CameraManager;
getEntityRegistryManager(): EntityRegistryManager;
}
export interface CardMessageAPI {
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
}
export interface CardMicrophoneAPI {
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
}
export interface CardQueryStringAPI {
getCardElementManager(): CardElementManager;
getViewManager(): ViewManager;
getActionsManager(): ActionsManager;
}
export interface CardStyleAPI {
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
getExpandManager(): ExpandManager;
getFullscreenManager(): FullscreenManager;
getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getViewManager(): ViewManager;
}
export interface CardTriggersAPI {
getCameraManager(): CameraManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager;
getViewManager(): ViewManager;
}
export interface CardViewAPI {
getAutoUpdateManager(): AutoUpdateManager;
getCameraManager(): CameraManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getStyleManager(): StyleManager;
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager;
}
+283
View File
@@ -0,0 +1,283 @@
import { ViewContext } from 'view';
import { FrigateCardConfig, FrigateCardView, ViewDisplayMode } from '../../types';
import { View } from '../../view/view';
import { getAllDependentCameras } from '../camera';
import { log } from '../debug';
import { executeMediaQueryForView } from '../media-to-view';
import { CardViewAPI } from './types';
interface ViewManagerSetViewDefaultParameters {
cameraID?: string;
substream?: string;
}
export interface ViewManagerSetViewParameters
extends ViewManagerSetViewDefaultParameters {
viewName?: FrigateCardView;
}
export class ViewManager {
protected _view: View | null = null;
protected _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public getView(): View | null {
return this._view;
}
public setView(view: View): void {
this._setView(view);
}
public setViewDefault(params?: ViewManagerSetViewDefaultParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let forceCameraID: string | null = params?.cameraID ?? null;
if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) {
const cameraIDs = [
...this._api.getCameraManager().getStore().getVisibleCameraIDs(),
];
const currentIndex = cameraIDs.indexOf(this._view.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
forceCameraID = cameraIDs[targetIndex];
}
this.setViewByParameters({
...params,
viewName: config.view.default,
...(forceCameraID && { cameraID: forceCameraID }),
});
// Restart the refresh timer, so the default view is refreshed at a fixed
// interval from now (if so configured).
this._api.getAutoUpdateManager().startDefaultViewTimer();
}
}
public setViewByParameters(params: ViewManagerSetViewParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let cameraID: string | null = null;
const cameras = this._api.getCameraManager().getStore().getVisibleCameraIDs();
if (cameras.size) {
if (params?.cameraID && cameras.has(params.cameraID)) {
cameraID = params.cameraID;
} else {
// Reset to the default camera.
cameraID = cameras.keys().next().value;
}
}
const viewName = params?.viewName ?? this._view?.view ?? config.view.default;
if (cameraID && viewName && this.isViewSupportedByCamera(cameraID, viewName)) {
const displayMode =
this._view?.displayMode ??
this._getDefaultDisplayModeForView(viewName, config);
let view: View = new View({
view: viewName,
camera: cameraID,
displayMode: displayMode,
});
if (params.substream) {
view = this._createViewWithSelectedSubstream(view, params.substream);
}
this._setView(view);
}
}
}
public setViewWithNewContext(context: ViewContext): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
}
public reset(): void {
this._view = null;
}
public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise<void> {
const host = this._api.getCardElementManager().getElement();
const hass = this._api.getHASSManager().getHASS();
if (this._view && hass) {
const view = this._view.evolve({
displayMode: displayMode,
});
const cameraCount = this._api
.getCameraManager()
.getStore()
.getVisibleCameraCount();
const queryCameraCount = view.query?.getQueryCameraIDs()?.size ?? 0;
const generateNewQuery =
view?.query &&
queryCameraCount &&
((view.isGrid() && queryCameraCount < cameraCount) ||
(!view.isGrid() && queryCameraCount > 1));
if (generateNewQuery && view && view.query) {
// If the user requests a grid but the current query does not have a
// query for more than one camera, reset the query results, change the
// existing query to refer to all cameras and execute it to fetch new
// results.
const viewWithNewQuery = await executeMediaQueryForView(
host,
this._api.getCameraManager(),
view,
view.query
.clone()
.setQueryCameraIDs(
view.isGrid()
? this._api.getCameraManager().getStore().getVisibleCameraIDs()
: view.camera,
),
);
if (viewWithNewQuery) {
return this._setView(viewWithNewQuery);
}
} else {
return this._setView(view);
}
}
}
public setViewWithSubstream(substream?: string): void {
if (!this._view) {
return;
}
this._setView(
substream
? this._createViewWithSelectedSubstream(this._view, substream)
: this._createViewWithNextStream(this._view),
);
}
public setViewWithoutSubstream(): void {
const view = this._createViewWithoutSubstream();
if (view) {
return this._setView(view);
}
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
const capabilities = this._api.getCameraManager().getCameraCapabilities(cameraID);
switch (view) {
case 'live':
case 'image':
case 'diagnostics':
return true;
case 'clip':
case 'clips':
return !!capabilities?.supportsClips;
case 'snapshot':
case 'snapshots':
return !!capabilities?.supportsSnapshots;
case 'recording':
case 'recordings':
return !!capabilities?.supportsRecordings;
case 'timeline':
return !!capabilities?.supportsTimeline;
case 'media':
return (
!!capabilities?.supportsClips ||
!!capabilities?.supportsSnapshots ||
!!capabilities?.supportsRecordings
);
}
}
protected _getDefaultDisplayModeForView(
viewName: FrigateCardView,
config?: FrigateCardConfig,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
}
protected _setView(view: View): void {
const oldView = this._view;
View.adoptFromViewIfAppropriate(view, oldView);
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card view change: `,
view.view,
);
this._view = view;
if (View.isMajorMediaChange(oldView, view)) {
this._api.getMediaLoadedInfoManager().clear();
}
if (oldView?.view !== view.view) {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionsManager()?.setState({
view: view.view,
camera: view.camera,
displayMode: view.displayMode ?? undefined,
});
this._api.getCardElementManager().update();
}
protected _createViewWithSelectedSubstream(baseView: View, substreamID: string): View {
const overrides: Map<string, string> =
baseView?.context?.live?.overrides ?? new Map();
overrides.set(baseView.camera, substreamID);
return baseView.clone().mergeInContext({
live: { overrides: overrides },
});
}
protected _createViewWithNextStream(baseView: View): View {
const dependencies = [
...getAllDependentCameras(this._api.getCameraManager(), baseView.camera),
];
if (dependencies.length <= 1) {
return baseView.clone();
}
const view = baseView.clone();
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
const currentOverride = overrides.get(view.camera) ?? view.camera;
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
overrides.set(view.camera, dependencies[newIndex]);
view.mergeInContext({ live: { overrides: overrides } });
return view;
}
protected _createViewWithoutSubstream(): View | null {
if (!this._view) {
return null;
}
const view = this._view.clone();
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(view.camera);
}
return view;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { CardWideConfig } from '../types';
export const log = (cardWideConfig?: CardWideConfig, ...args: unknown[]) => {
export const log = (cardWideConfig?: CardWideConfig | null, ...args: unknown[]) => {
if (cardWideConfig?.debug?.logging) {
console.debug(...args);
}
+70
View File
@@ -0,0 +1,70 @@
import { HomeAssistant } from 'custom-card-helpers';
import pkg from '../../package.json';
import { getLanguage } from '../localize/localize';
import { RawFrigateCardConfig } from '../types';
import { DeviceList, getAllDevices } from './ha/device-registry';
type FrigateVersions = Record<string, string>;
interface GitDiagnostics {
build_version?: string;
build_date?: string;
commit_date?: string;
}
export interface Diagnostics {
card_version: string;
browser: string;
date: Date;
lang: string;
timezone: string;
git: GitDiagnostics;
frigate_versions?: FrigateVersions;
ha_version?: string;
config?: RawFrigateCardConfig;
}
export const getDiagnostics = async (
hass?: HomeAssistant,
rawConfig?: RawFrigateCardConfig,
): Promise<Diagnostics> => {
let devices: DeviceList | undefined = [];
if (hass) {
try {
devices = await getAllDevices(hass);
} catch (e) {
// Pass. This is optional.
}
}
// Get the Frigate devices in order to extract the Frigate integration and
// server version numbers.
const frigateDevices = devices?.filter((device) => device.manufacturer === 'Frigate');
const frigateVersionMap: Map<string, string> = new Map();
frigateDevices?.forEach((device) => {
device.config_entries.forEach((configEntry) => {
if (device.model) {
frigateVersionMap.set(configEntry, device.model);
}
});
});
return {
card_version: pkg.version,
browser: navigator.userAgent,
date: new Date(),
...(frigateVersionMap.size && {
frigate_versions: Object.fromEntries(frigateVersionMap),
}),
lang: getLanguage(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
git: {
...(pkg['gitVersion'] && { build_version: pkg['gitVersion'] }),
...(pkg['buildDate'] && { build_date: pkg['buildDate'] }),
...(pkg['gitDate'] && { commit_date: pkg['gitDate'] }),
},
...(hass && { ha_version: hass.config.version }),
...(rawConfig && { config: rawConfig }),
};
};
+1 -1
View File
@@ -39,7 +39,7 @@ export const downloadMedia = async (
cameraManager: CameraManager,
media: ViewMedia,
): Promise<void> => {
const download = await cameraManager.getMediaDownloadPath(hass, media);
const download = await cameraManager.getMediaDownloadPath(media);
if (!download) {
throw new FrigateCardError(localize('error.download_no_media'));
}
+16
View File
@@ -364,3 +364,19 @@ export function canonicalizeHAURL(
}
return url ?? null;
}
/**
* Determine if HA connection state has changed.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @returns `true` if the connection state has changed.
*/
export const hasHAConnectionStateChanged = (
oldHass: HomeAssistant | undefined | null,
newHass: HomeAssistant | undefined | null,
): boolean => {
return (
(!oldHass && !newHass?.connected) ||
(!!oldHass && oldHass.connected !== !!newHass?.connected)
);
};
@@ -1,16 +1,16 @@
import { allPromises } from './basic';
import { allPromises } from '../basic';
enum InitializationState {
INITIALIZING = 'initializing',
INITIALIZED = 'initialized',
}
type Initializer = () => Promise<unknown>;
type InitializationCallback = () => Promise<unknown>;
/**
* Manages initialization state & calling initializers.
*/
export class FrigateCardInitializer {
export class Initializer {
protected _state: Map<string, InitializationState>;
constructor() {
@@ -18,7 +18,7 @@ export class FrigateCardInitializer {
}
public async initializeMultipleIfNecessary(
aspects: Record<string, Initializer>,
aspects: Record<string, InitializationCallback>,
): Promise<boolean> {
const results = await allPromises(
Object.entries(aspects),
@@ -36,7 +36,7 @@ export class FrigateCardInitializer {
*/
public async initializeIfNecessary(
aspect: string,
initializer?: Initializer,
initializer?: InitializationCallback,
): Promise<boolean> {
const state = this._state.get(aspect);
if (state !== InitializationState.INITIALIZED) {
-27
View File
@@ -1,27 +0,0 @@
import { MediaLoadedInfo } from '../types';
export class MediaLoadedInfoController {
protected _current: MediaLoadedInfo | null = null;
protected _lastKnown: MediaLoadedInfo | null = null;
public set(current: MediaLoadedInfo): void {
this._current = current;
this._lastKnown = current;
}
public get(): MediaLoadedInfo | null {
return this._current;
}
public getLastKnown(): MediaLoadedInfo | null {
return this._lastKnown;
}
public clear(): void {
this._current = null;
}
public has(): boolean {
return !!this._current;
}
}
+3 -7
View File
@@ -1,4 +1,3 @@
import { HomeAssistant } from 'custom-card-helpers';
import { ViewContext } from 'view';
import { CameraManager } from '../camera-manager/manager';
import { MediaQuery } from '../camera-manager/types';
@@ -20,7 +19,6 @@ type ResultSelectType = 'latest' | 'time' | 'none';
export const changeViewToRecentEventsForCameraAndDependents = async (
element: HTMLElement,
hass: HomeAssistant,
cameraManager: CameraManager,
cardWideConfig: CardWideConfig,
view: View,
@@ -46,7 +44,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
}
(
await executeMediaQueryForView(element, hass, cameraManager, view, queries, {
await executeMediaQueryForView(element, cameraManager, view, queries, {
targetView: options?.targetView,
select: options?.select,
})
@@ -82,7 +80,6 @@ const createQueriesForEventsView = (
*/
export const changeViewToRecentRecordingForCameraAndDependents = async (
element: HTMLElement,
hass: HomeAssistant,
cameraManager: CameraManager,
cardWideConfig: CardWideConfig,
view: View,
@@ -109,7 +106,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
}
(
await executeMediaQueryForView(element, hass, cameraManager, view, queries, {
await executeMediaQueryForView(element, cameraManager, view, queries, {
targetView: options?.targetView,
select: options?.select,
})
@@ -131,7 +128,6 @@ const createQueriesForRecordingsView = (
export const executeMediaQueryForView = async (
element: HTMLElement,
hass: HomeAssistant,
cameraManager: CameraManager,
view: View,
query: MediaQueries,
@@ -150,7 +146,7 @@ export const executeMediaQueryForView = async (
}
try {
mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(hass, queries);
mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(queries);
} catch (e) {
errorToConsole(e as Error);
dispatchFrigateCardErrorEvent(element, e as Error);
+39 -36
View File
@@ -15,7 +15,8 @@ import { View } from '../view/view';
import { createFrigateCardCustomAction } from './action';
import { getAllDependentCameras } from './camera';
import { getEntityIcon, getEntityTitle } from './ha';
import { MicrophoneController } from './microphone';
import { MediaPlayerManager } from './card-controller/media-player-manager';
import { MicrophoneManager } from './card-controller/microphone-manager';
import { hasSubstream } from './substream';
export class MenuButtonController {
@@ -46,9 +47,9 @@ export class MenuButtonController {
expanded: boolean,
options?: {
currentMediaLoadedInfo?: MediaLoadedInfo | null;
mediaPlayers?: string[];
cameraURL?: string | null;
microphoneController?: MicrophoneController;
showCameraUIButton?: boolean,
microphoneManager?: MicrophoneManager | null;
mediaPlayerController?: MediaPlayerManager | null;
},
): MenuButton[] {
const visibleCameras = cameraManager.getStore().getVisibleCameras();
@@ -87,7 +88,7 @@ export class MenuButtonController {
const action = createFrigateCardCustomAction('camera_select', {
camera: cameraID,
});
const metadata = cameraManager.getCameraMetadata(hass, cameraID) ?? undefined;
const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
return {
enabled: true,
@@ -132,7 +133,7 @@ export class MenuButtonController {
const action = createFrigateCardCustomAction('live_substream_select', {
camera: cameraID,
});
const metadata = cameraManager.getCameraMetadata(hass, cameraID) ?? undefined;
const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID);
return {
enabled: true,
@@ -244,7 +245,7 @@ export class MenuButtonController {
});
}
if (options?.cameraURL) {
if (options?.showCameraUIButton) {
buttons.push({
icon: 'mdi:web',
...config.menu.buttons.camera_ui,
@@ -257,11 +258,11 @@ export class MenuButtonController {
}
if (
options?.microphoneController &&
options?.microphoneManager &&
options?.currentMediaLoadedInfo?.capabilities?.supports2WayAudio
) {
const forbidden = options.microphoneController.isForbidden();
const muted = options.microphoneController.isMuted();
const forbidden = options.microphoneManager.isForbidden();
const muted = options.microphoneManager.isMuted();
const buttonType = config.menu.buttons.microphone.type;
buttons.push({
icon: forbidden
@@ -285,7 +286,7 @@ export class MenuButtonController {
...(!forbidden &&
buttonType === 'toggle' && {
tap_action: createFrigateCardCustomAction(
options.microphoneController.isMuted()
options.microphoneManager.isMuted()
? 'microphone_unmute'
: 'microphone_mute',
) as FrigateCardCustomAction,
@@ -316,34 +317,36 @@ export class MenuButtonController {
});
if (
options?.mediaPlayers?.length &&
options?.mediaPlayerController?.hasMediaPlayers() &&
(view?.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity))
) {
const mediaPlayerItems = options.mediaPlayers.map((playerEntityID) => {
const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
const state = hass.states[playerEntityID];
const playAction = createFrigateCardCustomAction('media_player', {
media_player: playerEntityID,
media_player_action: 'play',
});
const stopAction = createFrigateCardCustomAction('media_player', {
media_player: playerEntityID,
media_player_action: 'stop',
});
const disabled = !state || state.state === 'unavailable';
const mediaPlayerItems = options.mediaPlayerController
.getMediaPlayers()
.map((playerEntityID) => {
const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
const state = hass.states[playerEntityID];
const playAction = createFrigateCardCustomAction('media_player', {
media_player: playerEntityID,
media_player_action: 'play',
});
const stopAction = createFrigateCardCustomAction('media_player', {
media_player: playerEntityID,
media_player_action: 'stop',
});
const disabled = !state || state.state === 'unavailable';
return {
enabled: true,
selected: false,
icon: getEntityIcon(hass, playerEntityID),
entity: playerEntityID,
state_color: false,
title: title,
disabled: disabled,
...(!disabled && playAction && { tap_action: playAction }),
...(!disabled && stopAction && { hold_action: stopAction }),
};
});
return {
enabled: true,
selected: false,
icon: getEntityIcon(hass, playerEntityID),
entity: playerEntityID,
state_color: false,
title: title,
disabled: disabled,
...(!disabled && playAction && { tap_action: playAction }),
...(!disabled && stopAction && { hold_action: stopAction }),
};
});
buttons.push({
icon: 'mdi:cast',
-60
View File
@@ -1,60 +0,0 @@
import { FrigateCardCustomAction } from '../types';
import { createFrigateCardCustomAction } from './action.js';
export const getActionsFromQueryString = (
queryString: string,
): FrigateCardCustomAction[] => {
const params = new URLSearchParams(queryString);
const actions: FrigateCardCustomAction[] = [];
const actionRE = new RegExp(
/^frigate-card-action([.:](?<cardID>\w+))?[.:](?<action>\w+)/,
);
for (const [key, value] of params.entries()) {
const match = key.match(actionRE);
if (!match || !match.groups) {
continue;
}
const cardID: string | undefined = match.groups['cardID'];
const action = match.groups['action'];
let customAction: FrigateCardCustomAction | null = null;
switch (action) {
case 'camera_select':
case 'live_substream_select':
if (value) {
customAction = createFrigateCardCustomAction(action, {
camera: value,
cardID: cardID,
});
}
break;
case 'camera_ui':
case 'clip':
case 'clips':
case 'default':
case 'diagnostics':
case 'download':
case 'expand':
case 'image':
case 'live':
case 'menu_toggle':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
customAction = createFrigateCardCustomAction(action, {
cardID: cardID,
});
break;
default:
console.warn(
`Frigate card received unknown card action in query string: ${action}`,
);
}
if (customAction) {
actions.push(customAction);
}
}
return actions;
};
+1 -1
View File
@@ -14,7 +14,7 @@ export const screenshotMedia = (video: HTMLVideoElement): string | null => {
return canvas.toDataURL('image/jpeg');
};
export const generateScreenshotTitle = (view?: View): string => {
export const generateScreenshotTitle = (view?: View | null): string => {
if (view?.is('live') || view?.is('image')) {
return `${view.view}-${view.camera}-${format(
new Date(),
+3 -42
View File
@@ -1,48 +1,9 @@
import { CameraManager } from '../camera-manager/manager';
import { View } from '../view/view';
import { getAllDependentCameras } from './camera';
export const createViewWithSelectedSubstream = (
view: View,
substreamID: string,
): View | null => {
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
overrides.set(view.camera, substreamID);
return view.clone().mergeInContext({
live: { overrides: overrides },
});
};
export const createViewWithoutSubstream = (view: View): View => {
const newView = view.clone();
const overrides: Map<string, string> | undefined = newView.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
newView.context?.live?.overrides?.delete(view.camera);
}
return newView;
export const getStreamCameraID = (view: View): string => {
return view?.context?.live?.overrides?.get(view.camera) ?? view.camera;
};
export const hasSubstream = (view: View): boolean => {
const override = view?.context?.live?.overrides?.get(view.camera);
return !!override && override !== view.camera;
};
export const createViewWithNextStream = (
cameraManager: CameraManager,
view: View,
): View => {
const dependencies = [...getAllDependentCameras(cameraManager, view.camera)];
if (dependencies.length <= 1) {
return view.clone();
}
const newView = view.clone();
const overrides: Map<string, string> = newView.context?.live?.overrides ?? new Map();
const currentOverride = overrides.get(newView.camera) ?? newView.camera;
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
overrides.set(view.camera, dependencies[newIndex]);
newView.mergeInContext({ live: { overrides: overrides } });
return newView;
return getStreamCameraID(view) !== view.camera;
};
+13 -23
View File
@@ -1,19 +1,18 @@
import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add';
import sub from 'date-fns/sub';
import { DataSet } from 'vis-data';
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
import { ClipsOrSnapshotsOrAll } from '../types';
import { CameraManager } from '../camera-manager/manager';
import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { ViewMedia } from '../view/media';
import {
compressRanges,
ExpiringMemoryRangeSet,
MemoryRangeSet,
compressRanges,
} from '../camera-manager/range';
import { errorToConsole, ModifyInterface } from './basic.js';
import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { ClipsOrSnapshotsOrAll } from '../types';
import { ViewMedia } from '../view/media';
import { ModifyInterface, errorToConsole } from './basic.js';
// Allow timeline freshness to be at least this number of seconds out of date
// (caching times in the data-engine may increase the effective delay).
@@ -82,11 +81,11 @@ export class TimelineDataSource {
}
}
public async refresh(hass: HomeAssistant, window: TimelineWindow): Promise<void> {
public async refresh(window: TimelineWindow): Promise<void> {
try {
await Promise.all([
this._refreshEvents(hass, window),
...(this._showRecordings ? [this._refreshRecordings(hass, window)] : []),
this._refreshEvents(window),
...(this._showRecordings ? [this._refreshRecordings(window)] : []),
]);
} catch (e) {
errorToConsole(e as Error);
@@ -114,10 +113,7 @@ export class TimelineDataSource {
});
}
protected async _refreshEvents(
hass: HomeAssistant,
window: TimelineWindow,
): Promise<void> {
protected async _refreshEvents(window: TimelineWindow): Promise<void> {
if (
this._eventRanges.hasCoverage({
start: window.start,
@@ -134,7 +130,7 @@ export class TimelineDataSource {
return;
}
const mediaArray = await this._cameraManager.executeMediaQueries(hass, eventQueries);
const mediaArray = await this._cameraManager.executeMediaQueries(eventQueries);
const data: FrigateCardTimelineItem[] = [];
for (const media of mediaArray ?? []) {
const startTime = media.getStartTime();
@@ -159,10 +155,7 @@ export class TimelineDataSource {
});
}
protected async _refreshRecordings(
hass: HomeAssistant,
window: TimelineWindow,
): Promise<void> {
protected async _refreshRecordings(window: TimelineWindow): Promise<void> {
type FrigateCardTimelineItemWithEnd = ModifyInterface<
FrigateCardTimelineItem,
{ end: number }
@@ -228,10 +221,7 @@ export class TimelineDataSource {
if (!recordingQueries) {
return;
}
const results = await this._cameraManager.getRecordingSegments(
hass,
recordingQueries,
);
const results = await this._cameraManager.getRecordingSegments(recordingQueries);
const newSegments: Map<string, RecordingSegment[]> = new Map();
for (const [query, result] of results) {
+8 -1
View File
@@ -1,9 +1,14 @@
export class Timer {
protected _timer: number | null = null;
protected _repeated = false;
public stop(): void {
if (this._timer) {
window.clearTimeout(this._timer);
if (this._repeated) {
window.clearInterval(this._timer);
} else {
window.clearTimeout(this._timer);
}
this._timer = null;
}
}
@@ -18,6 +23,7 @@ export class Timer {
this._timer = null;
func();
}, seconds * 1000);
this._repeated = false;
}
public startRepeated(seconds: number, func: () => void): void {
@@ -25,5 +31,6 @@ export class Timer {
this._timer = window.setInterval(() => {
func();
}, seconds * 1000);
this._repeated = true;
}
}
+2 -10
View File
@@ -62,7 +62,7 @@ export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
* @param error The ZodError object from parsing.
* @returns An array of string error paths.
*/
export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> | null => {
export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> => {
/* Zod errors involving unions are complex, as Zod may not be able to tell
* where the 'real' error is vs simply a union option not matching. This
* function finds all ZodError "issues" that don't have an error with 'type'
@@ -79,16 +79,8 @@ export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> | null
if (issue.code === 'invalid_union') {
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
for (const unionError of unionErrors) {
const nestedErrors = getParseErrorPaths(unionError);
if (nestedErrors && nestedErrors.size) {
nestedErrors.forEach(contenders.add, contenders);
}
getParseErrorPaths(unionError).forEach(contenders.add, contenders);
}
} else if (issue.code === 'invalid_type') {
if (issue.path[issue.path.length - 1] === 'type') {
return null;
}
contenders.add(getParseErrorPathString(issue.path));
} else {
contenders.add(getParseErrorPathString(issue.path));
}