Fix view-related querystring action race condition

This commit is contained in:
Dermot Duffy
2023-06-21 08:41:56 -07:00
parent ed1780582f
commit e1320c4d71
4 changed files with 156 additions and 40 deletions
+57 -24
View File
@@ -48,12 +48,12 @@ import {
CameraConfig,
CardWideConfig,
ExtendedHomeAssistant,
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig,
frigateCardConfigSchema,
FrigateCardCustomAction,
FrigateCardError,
FrigateCardView,
FRIGATE_CARD_VIEW_DEFAULT,
MediaLoadedInfo,
MenuButton,
Message,
@@ -65,6 +65,7 @@ import {
frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
isViewAction,
} from './utils/action.js';
import { errorToConsole } from './utils/basic.js';
import { log } from './utils/debug.js';
@@ -440,8 +441,17 @@ class FrigateCard extends LitElement {
return this._overriddenConfig || this._config;
}
protected _changeView(args?: { view?: View; resetMessage?: boolean }): void {
log(this._cardWideConfig, `Frigate Card view change: `, args?.view ?? '[default]');
protected _changeView(args?: {
view?: View;
viewName?: FrigateCardView;
cameraID?: string;
resetMessage?: boolean;
}): void {
log(
this._cardWideConfig,
`Frigate Card view change: `,
args?.view ?? args?.viewName ?? '[default]',
);
const changeView = (view: View): void => {
if (View.isMajorMediaChange(this._view, view)) {
this._mediaLoadedInfoController.clear();
@@ -464,12 +474,13 @@ class FrigateCard extends LitElement {
}
if (!args?.view) {
// Load the default view.
let cameraID: string | null = null;
if (this._cameraManager) {
const cameras = this._cameraManager.getStore().getVisibleCameras();
if (cameras) {
if (this._view?.camera && this._getConfig().view.update_cycle_camera) {
if (args?.cameraID && cameras.has(args.cameraID)) {
cameraID = args.cameraID;
} else if (this._view?.camera && this._getConfig().view.update_cycle_camera) {
const keys = Array.from(cameras.keys());
const currentIndex = keys.indexOf(this._view.camera);
const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1;
@@ -484,7 +495,7 @@ class FrigateCard extends LitElement {
if (cameraID) {
changeView(
new View({
view: this._getConfig().view.default,
view: args?.viewName ?? this._getConfig().view.default,
camera: cameraID,
}),
);
@@ -719,11 +730,26 @@ class FrigateCard extends LitElement {
this._handleThrownError(e);
}
// If there's no view set yet, set one. This will be the case on initial camera load.
// Set a view on initial load. However,if the query string contains an
// action that needs to render content (e.g. a view action or diagnostics),
// we don't set any view here and allow that content to be triggered by the
// firstUpdated() call. 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
if (!this._view) {
// Don't reset the message which may be set to an error above. This sets the
// first view using the newly loaded cameras.
this._changeView({ resetMessage: false });
const querystringActions = getActionsFromQueryString();
if (
!querystringActions.find(
(action) =>
isViewAction(action) || action.frigate_card_action === 'diagnostics',
)
) {
this._changeView({
// Don't reset the message which may be set to an error above. This sets the
// first view using the newly loaded cameras.
resetMessage: false,
});
}
}
}
@@ -1024,7 +1050,10 @@ class FrigateCard extends LitElement {
}
protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void {
if (!this._view || !this._cameraManager) {
// 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).
if (!this._cameraManager) {
return;
}
@@ -1052,10 +1081,8 @@ class FrigateCard extends LitElement {
case 'snapshots':
case 'timeline':
this._changeView({
view: new View({
view: action,
camera: this._view.camera,
}),
viewName: action,
cameraID: this._view?.camera,
});
break;
case 'download':
@@ -1097,21 +1124,27 @@ class FrigateCard extends LitElement {
}
break;
case 'live_substream_select': {
const view = createViewWithSelectedSubstream(
this._view,
frigateCardAction.camera,
);
view && this._changeView({ view: view });
if (this._view) {
const view = createViewWithSelectedSubstream(
this._view,
frigateCardAction.camera,
);
view && this._changeView({ view: view });
}
break;
}
case 'live_substream_off': {
const view = createViewWithoutSubstream(this._view);
view && this._changeView({ view: view });
if (this._view) {
const view = createViewWithoutSubstream(this._view);
view && this._changeView({ view: view });
}
break;
}
case 'live_substream_on': {
const view = createViewWithNextStream(this._cameraManager, this._view);
view && this._changeView({ view: view });
if (this._view) {
const view = createViewWithNextStream(this._cameraManager, this._view);
view && this._changeView({ view: view });
}
break;
}
case 'media_player':
+7 -9
View File
@@ -209,15 +209,11 @@ const frigateCardCustomActionsBaseSchema = customActionSchema.extend({
const FRIGATE_CARD_GENERAL_ACTIONS = [
'camera_ui',
'clip',
'clips',
'default',
'diagnostics',
'expand',
'download',
'fullscreen',
'image',
'live',
'menu_toggle',
'mute',
'live_substream_on',
@@ -226,15 +222,11 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'microphone_unmute',
'play',
'pause',
'recording',
'recordings',
'screenshot',
'snapshot',
'snapshots',
'timeline',
'unmute',
] as const;
const FRIGATE_CARD_ACTIONS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
...FRIGATE_CARD_GENERAL_ACTIONS,
'camera_select',
'live_substream_select',
@@ -242,6 +234,11 @@ const FRIGATE_CARD_ACTIONS = [
] as const;
export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number];
const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({
frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED),
});
export type FrigateCardViewAction = z.infer<typeof frigateCardViewActionSchema>;
const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({
frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS),
});
@@ -261,6 +258,7 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex
});
export const frigateCardCustomActionSchema = z.union([
frigateCardViewActionSchema,
frigateCardGeneralActionSchema,
frigateCardCameraSelectActionSchema,
frigateCardLiveDependencySelectActionSchema,
+19
View File
@@ -10,6 +10,7 @@ import {
FrigateCardAction,
FrigateCardCustomAction,
frigateCardCustomActionSchema,
FrigateCardViewAction,
} from '../types.js';
/**
@@ -173,3 +174,21 @@ 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;
};
+73 -7
View File
@@ -1,14 +1,21 @@
import { handleActionConfig, hasAction } from 'custom-card-helpers';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { actionSchema } from '../../src/types';
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction,
frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
stopEventFromActivatingCardWideActions,
actionSchema,
FrigateCardAction,
FrigateCardCustomAction,
frigateCardCustomActionSchema,
} from '../../src/types';
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction,
frigateCardHandleAction,
frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
isViewAction,
stopEventFromActivatingCardWideActions,
} from '../../src/utils/action';
import { createHASS } from '../test-utils';
@@ -173,6 +180,23 @@ describe('frigateCardHandleActionConfig', () => {
});
});
// @vitest-environment jsdom
describe('frigateCardHandleAction', () => {
const element = document.createElement('div');
const action = actionSchema.parse({
action: 'none',
});
afterEach(() => {
vi.clearAllMocks();
});
it('should call action handler', () => {
frigateCardHandleAction(element, createHASS(), {}, action);
expect(handleActionConfig).toBeCalled();
});
});
describe('frigateCardHasAction', () => {
const action = actionSchema.parse({
action: 'toggle',
@@ -200,3 +224,45 @@ describe('stopEventFromActivatingCardWideActions', () => {
expect(event.stopPropagation).toBeCalled();
});
});
describe('isViewAction', () => {
const createAction = (action: FrigateCardAction): FrigateCardCustomAction => {
return frigateCardCustomActionSchema.parse({
action: 'fire-dom-event' as const,
frigate_card_action: action,
});
};
it('should return true for clip view ', () => {
expect(isViewAction(createAction('clip'))).toBeTruthy();
});
it('should return true for clips view ', () => {
expect(isViewAction(createAction('clips'))).toBeTruthy();
});
it('should return true for image view ', () => {
expect(isViewAction(createAction('image'))).toBeTruthy();
});
it('should return true for live view ', () => {
expect(isViewAction(createAction('live'))).toBeTruthy();
});
it('should return true for recording view ', () => {
expect(isViewAction(createAction('recording'))).toBeTruthy();
});
it('should return true for live view ', () => {
expect(isViewAction(createAction('live'))).toBeTruthy();
});
it('should return true for recordings view ', () => {
expect(isViewAction(createAction('recordings'))).toBeTruthy();
});
it('should return true for snapshot view ', () => {
expect(isViewAction(createAction('snapshot'))).toBeTruthy();
});
it('should return true for snapshots view ', () => {
expect(isViewAction(createAction('snapshots'))).toBeTruthy();
});
it('should return true for timeline view ', () => {
expect(isViewAction(createAction('timeline'))).toBeTruthy();
});
it('should return false for anything else', () => {
expect(isViewAction(createAction('diagnostics'))).toBeFalsy();
});
});