diff --git a/README.md b/README.md index aa267ef8..9ffb6995 100644 --- a/README.md +++ b/README.md @@ -3807,7 +3807,7 @@ The card can respond to actions in the query string (see [below](#query-string-a This example assumes the dashboard URL is `https://ha.mydomain.org/lovelace-test/0`. ``` -https://ha.mydomain.org/lovelace-test/0?frigate-card-action:camera_select=kitchen&frigate-card-action:expand +https://ha.mydomain.org/lovelace-test/0?frigate-card-action.camera_select=kitchen&frigate-card-action.expand ``` @@ -3826,7 +3826,7 @@ cameras: ``` ``` -https://ha.mydomain.org/lovelace-test/0?frigate-card-action:main:clips +https://ha.mydomain.org/lovelace-test/0?frigate-card-action.main.clips ``` @@ -3859,7 +3859,7 @@ elements: left: 30% tap_action: action: navigate - navigation_path: /lovelace-frigate/map?frigate-card-action:camera_select=camera.living_room + navigation_path: /lovelace-frigate/map?frigate-card-action.camera_select=camera.living_room - type: icon icon: mdi:cctv style: @@ -3867,7 +3867,7 @@ elements: left: 42% tap_action: action: navigate - navigation_path: /lovelace-frigate/map?frigate-card-action:camera_select=camera.landing + navigation_path: /lovelace-frigate/map?frigate-card-action.camera_select=camera.landing ``` @@ -3998,13 +3998,13 @@ The Frigate card will execute these actions in the following circumstances: To send an action to *all* Frigate cards: ``` -[PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action:[ACTION]=[VALUE] +[PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action.[ACTION]=[VALUE] ``` To send an action to a named Frigate card: ``` -[PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action:[CARD_ID]:[ACTION]=[VALUE] +[PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action.[CARD_ID].[ACTION]=[VALUE] ``` | Parameter | Description | @@ -4013,6 +4013,8 @@ To send an action to a named Frigate card: | `CARD_ID` | When specified only cards that have a `card_id` parameter will act. | | `VALUE` | An optional value to use with the `camera_select` and `live_substream_select` actions. | +**Note**: Both `.` and `:` may be used as the delimiter. If you use `:` some browsers may require it be escaped to `%3A`. + **Note**: If a dashboard has multiple Frigate cards on it, even if they are on different 'tabs' within that dashboard, they will all respond to the actions unless the action is targeted with a `CARD_ID` as shown above. diff --git a/src/card.ts b/src/card.ts index cfccf191..cecbbbc3 100644 --- a/src/card.ts +++ b/src/card.ts @@ -749,7 +749,7 @@ class FrigateCard extends LitElement { // the default view and the querystring view, see: // https://github.com/dermotduffy/frigate-hass-card/issues/1200 if (!this._view) { - const querystringActions = getActionsFromQueryString(); + const querystringActions = getActionsFromQueryString(window.location.search); if ( !querystringActions.find( (action) => @@ -1594,7 +1594,9 @@ class FrigateCard extends LitElement { protected _locationChangeHandler = (): void => { // Only execute actions when the card has rendered at least once. if (this.hasUpdated) { - getActionsFromQueryString().forEach((action) => this._cardActionHandler(action)); + getActionsFromQueryString(window.location.search).forEach((action) => + this._cardActionHandler(action), + ); } }; diff --git a/src/types.ts b/src/types.ts index 5d9c0963..3b5c572f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -217,7 +217,10 @@ const frigateCardCustomActionsBaseSchema = customActionSchema.extend({ .or(z.literal('fire-dom-event')), // Card this command is intended for. - card_id: z.string().optional(), + card_id: z + .string() + .regex(/^\w+$/, 'card_id parameter can only contain [a-z][A-Z][0-9_]') + .optional(), }); const FRIGATE_CARD_GENERAL_ACTIONS = [ diff --git a/src/utils/querystring.ts b/src/utils/querystring.ts index 27f9201b..85225cd8 100644 --- a/src/utils/querystring.ts +++ b/src/utils/querystring.ts @@ -1,11 +1,14 @@ import { FrigateCardCustomAction } from '../types'; import { createFrigateCardCustomAction } from './action.js'; -export const getActionsFromQueryString = (): FrigateCardCustomAction[] => { - const params = new URLSearchParams(window.location.search); +export const getActionsFromQueryString = ( + queryString: string, +): FrigateCardCustomAction[] => { + const params = new URLSearchParams(queryString); const actions: FrigateCardCustomAction[] = []; - const actionRE = new RegExp(/^frigate-card-action(:(?\w+))?:(?\w+)/); - + const actionRE = new RegExp( + /^frigate-card-action([.:](?\w+))?[.:](?\w+)/, + ); for (const [key, value] of params.entries()) { const match = key.match(actionRE); if (!match || !match.groups) { diff --git a/tests/utils/querystring.test.ts b/tests/utils/querystring.test.ts new file mode 100644 index 00000000..5b04ddad --- /dev/null +++ b/tests/utils/querystring.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getActionsFromQueryString } from '../../src/utils/querystring'; + +// @vitest-environment jsdom +describe('getActionsFromQueryString', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should reject malformed query string', () => { + expect(getActionsFromQueryString(`?BOGUS_KEY=BOGUS_VALUE`)).toEqual([]); + }); + + it('should accept colon as delimiter', () => { + expect(getActionsFromQueryString(`?frigate-card-action:id:clips=`)).toEqual([ + { + action: 'fire-dom-event', + card_id: 'id', + frigate_card_action: 'clips', + }, + ]); + }); + + describe('should get simple action from query string', () => { + it.each([ + ['camera_ui' as const], + ['clip' as const], + ['clips' as const], + ['default' as const], + ['diagnostics' as const], + ['download' as const], + ['expand' as const], + ['image' as const], + ['live' as const], + ['menu_toggle' as const], + ['recording' as const], + ['recordings' as const], + ['snapshot' as const], + ['snapshots' as const], + ['timeline' as const], + ])('%s', (action: string) => { + expect(getActionsFromQueryString(`?frigate-card-action.id.${action}=`)).toEqual([ + { + action: 'fire-dom-event', + card_id: 'id', + frigate_card_action: action, + }, + ]); + }); + }); + + it('should get camera_select action', () => { + expect( + getActionsFromQueryString(`?frigate-card-action.id.camera_select=camera.foo`), + ).toEqual([ + { + action: 'fire-dom-event', + card_id: 'id', + frigate_card_action: 'camera_select', + camera: 'camera.foo', + }, + ]); + }); + + it('should get live_substream_select action', () => { + expect( + getActionsFromQueryString( + `?frigate-card-action.id.live_substream_select=camera.bar`, + ), + ).toEqual([ + { + action: 'fire-dom-event', + card_id: 'id', + frigate_card_action: 'live_substream_select', + camera: 'camera.bar', + }, + ]); + }); + + describe('should reject value-based actions without value', () => { + it.each([['camera_select' as const], ['live_substream_select' as const]])( + '%s', + (action: string) => { + expect(getActionsFromQueryString(`?frigate-card-action.id.${action}=`)).toEqual( + [], + ); + }, + ); + }); + + it('should log unknown but correctly formed action', () => { + const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); + + expect( + getActionsFromQueryString(`?frigate-card-action.id.not_a_real_action}=`), + ).toEqual([]); + + expect(spy).toBeCalledWith( + 'Frigate card received unknown card action in query string: not_a_real_action', + ); + }); +});