diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 7834487c..d2f09548 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -10,6 +10,7 @@ "redhat.vscode-yaml", "lokalise.i18n-ally", "vitest.explorer", - "inlang.vs-code-extension" + "inlang.vs-code-extension", + "yoavbls.pretty-ts-errors" ] } diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index 855695c4..5b36f48a 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -16,6 +16,8 @@ certain configurations (in `overrides`) or to display "picture elements" (in ## `camera` +Matches based on the selected camera. + ```yaml conditions: - condition: camera @@ -29,6 +31,8 @@ conditions: ## `expand` +Matches based on whether the card is in "expanded" mode. + ```yaml conditions: - condition: expand @@ -42,6 +46,8 @@ conditions: ## `fullscreen` +Matches based on whether the card is in fullscreen. + ```yaml conditions: - condition: fullscreen @@ -55,6 +61,8 @@ conditions: ## `interaction` +Matches based on whether the card has been interacted with. + ```yaml conditions: - condition: interaction @@ -68,6 +76,8 @@ conditions: ## `key` +Matches based on key state. + ```yaml conditions: - condition: key @@ -86,6 +96,8 @@ conditions: ## `media_loaded` +Matches based on whether the selected live or media stream has loaded. + ```yaml conditions: - condition: media_loaded @@ -99,6 +111,8 @@ conditions: ## `microphone` +Matches based on microphone state. + ```yaml conditions: - condition: microphone @@ -111,18 +125,25 @@ conditions: | `connected` | Optional: If `true` or `false` the condition is satisfied if the microphone is connected or disconnected respectively. | | `muted` | Optional: If `true` or `false` the condition is satisfied if the microphone is muted or unmuted respectively. | +When multiple parameters are specified they must all match for the condition to +match. + ## `numeric_state` +Matches based on numeric Home Assistant state. + ```yaml conditions: - condition: numeric_state # [...] ``` -This stock Home Assistant condition works out of the box. See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#numeric-state). +See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#numeric-state). ## `screen` +Matches based on [media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries). + ```yaml conditions: - condition: screen @@ -138,16 +159,20 @@ See the [screen conditions examples](../examples.md?id=screen-conditions). ## `state` +Matches based on Home Assistant state. + ```yaml conditions: - condition: state # [...] ``` -This stock Home Assistant condition works out of the box. See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#state). +See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#state). ## `triggered` +Matches based on whether the selected camera has been triggered. + ```yaml conditions: - condition: triggered @@ -161,16 +186,40 @@ conditions: ## `user` +Matches based on the Home Assistant user that is logged in. See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#user). + ```yaml conditions: - condition: user # [...] ``` -This stock Home Assistant condition works out of the box. See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#user). +## `user_agent` + +Matches based on the [User-Agent](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent). + +```yaml +conditions: + - condition: user_agent + # [...] +``` + +| Parameter | Description | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `condition` | Must be `user_agent`. | +| `user_agent` | Exactly matches a user-agent, e.g. `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36` | +| `user_agent_re` | Matches a user-agent based on a regular expression, e.g. `Chrome/`. | +| `companion` | If `true` matches if the user-agent is the Home Assistant companion app, if `false` matches if the user-agent is _NOT_ the Home Assistant companion app. | + +When multiple parameters are specified they must all match for the condition to +match. + +See the [user-agent overrides example](../examples.md?id=disable-ptz-controls-in-the-home-assistant-companion-app). ## `view` +Matches based on the selected view. + ```yaml conditions: - condition: view @@ -230,6 +279,10 @@ conditions: - condition: user users: - 581fca7fdc014b8b894519cc531f9a04 + - condition: user_agent + user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + user_agent_re: "Chrome/" + companion: true - condition: view views: - live diff --git a/docs/configuration/profiles.md b/docs/configuration/profiles.md index 900d652d..4baa2cd7 100644 --- a/docs/configuration/profiles.md +++ b/docs/configuration/profiles.md @@ -39,13 +39,13 @@ Principles used in the selection of options set by `low-performance` profile mod - Get 'out of the box' performance similar to the basic "Home Assistant Picture Glance" card. - Do not break the visual aesthetic of the card. -See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/dev/src/config/profiles/low-performance.ts) for an exhaustive list of defaults set by this profile. +See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/main/src/config/profiles/low-performance.ts) for an exhaustive list of defaults set by this profile. ## `scrubbing` Configures the `live` view and media viewer to allow media "scrubbing" as the timeline is dragged back and forth. -See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/dev/src/config/profiles/scrubbing.ts) for an exhaustive list of options set by this profile. +See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/main/src/config/profiles/scrubbing.ts) for an exhaustive list of options set by this profile. ## Fully expanded reference diff --git a/docs/examples.md b/docs/examples.md index c1d7f86b..12f4c748 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -624,6 +624,22 @@ overrides: - 'cameras[1]' ``` +### Disable PTZ controls in the Home Assistant Companion App + +This example disables the PTZ controls when the card is viewed on the Companion app: + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.office +overrides: + - conditions: + - condition: user_agent + companion: true + set: + live.controls.ptz.mode: 'off' +``` + ## PTZ control The card supports using PTZ controls to conveniently control pan, tilt and zoom diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 1b6f32dd..499a3d09 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -127,6 +127,10 @@ export class CardElementManager { this._api.getQueryStringManager().requestExecution, ); + this._api.getConditionsManager()?.setState({ + user_agent: navigator.userAgent, + }); + // Manually request query string execute as the card will be // disconnected/reconnected when dashboard 'tab' changes happen within HA. this._api.getQueryStringManager().requestExecution(); diff --git a/src/card-controller/conditions-manager.ts b/src/card-controller/conditions-manager.ts index dcb37349..1950b47b 100644 --- a/src/card-controller/conditions-manager.ts +++ b/src/card-controller/conditions-manager.ts @@ -19,6 +19,7 @@ import { import { localize } from '../localize/localize'; import { FrigateCardError } from '../types'; import { desparsifyArrays } from '../utils/basic'; +import { isCompanionApp } from '../utils/companion'; import { CardConditionAPI, KeysState } from './types'; interface MicrophoneConditionState { @@ -39,6 +40,7 @@ interface ConditionState { microphone?: MicrophoneConditionState; user?: CurrentUser; keys?: KeysState; + user_agent?: string; } class OverrideConfigurationError extends FrigateCardError {} @@ -353,6 +355,15 @@ export class ConditionsManager { (conditionObj.shift === undefined || conditionObj.shift === !!state.keys[conditionObj.key].shift) ); + case 'user_agent': + return ( + !!state.user_agent && + (!conditionObj.user_agent || conditionObj.user_agent === state.user_agent) && + (conditionObj.companion === undefined || + conditionObj.companion === isCompanionApp(state.user_agent)) && + (conditionObj.user_agent_re === undefined || + new RegExp(conditionObj.user_agent_re).test(state.user_agent)) + ); } } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 96c128e4..55ac324f 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -125,6 +125,7 @@ export interface CardDownloadAPI { export interface CardElementAPI { getActionsManager(): ActionsManager; getCameraManager(): CameraManager; + getConditionsManager(): ConditionsManager; getConfigManager(): ConfigManager; getDefaultManager(): DefaultManager; getExpandManager(): ExpandManager; diff --git a/src/config/types.ts b/src/config/types.ts index 488d9fb6..6e3efbed 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -726,6 +726,25 @@ const keyConditionSchema = z.object({ alt: z.boolean().optional(), meta: z.boolean().optional(), }); +const userAgentConditionSchema = z.object({ + condition: z.literal('user_agent'), + user_agent: z.string().optional(), + user_agent_re: z + .string() + .refine( + (val) => { + try { + new RegExp(val); + } catch { + return false; + } + return true; + }, + { message: 'Invalid regular expression' }, + ) + .optional(), + companion: z.boolean().optional(), +}); export const frigateCardConditionSchema = z.discriminatedUnion('condition', [ // Stock conditions: @@ -745,6 +764,7 @@ export const frigateCardConditionSchema = z.discriminatedUnion('condition', [ interactionConditionSchema, microphoneConditionSchema, keyConditionSchema, + userAgentConditionSchema, ]); export type FrigateCardCondition = z.infer; diff --git a/src/utils/companion.ts b/src/utils/companion.ts new file mode 100644 index 00000000..a770a64b --- /dev/null +++ b/src/utils/companion.ts @@ -0,0 +1,3 @@ +export const isCompanionApp = (userAgent: string): boolean => { + return !!userAgent.match(/Home ?Assistant/); +}; diff --git a/src/utils/download.ts b/src/utils/download.ts index 1200f0c0..cdde9bfd 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -3,6 +3,7 @@ import { localize } from '../localize/localize'; import { ExtendedHomeAssistant, FrigateCardError } from '../types'; import { ViewMedia } from '../view/media'; import { errorToConsole } from './basic'; +import { isCompanionApp } from './companion'; import { homeAssistantSignPath } from './ha'; export const downloadURL = (url: string, filename = 'download'): void => { @@ -11,11 +12,7 @@ export const downloadURL = (url: string, filename = 'download'): void => { const isSameOrigin = new URL(url).origin === window.location.origin; const dataURL = url.startsWith('data:'); - if ( - navigator.userAgent.startsWith('Home Assistant/') || - navigator.userAgent.startsWith('HomeAssistant/') || - (!isSameOrigin && !dataURL) - ) { + if (isCompanionApp(navigator.userAgent) || (!isSameOrigin && !dataURL)) { // Home Assistant companion apps cannot download files without opening a // new browser window. // diff --git a/tests/card-controller/conditions-manager.test.ts b/tests/card-controller/conditions-manager.test.ts index 6c7fc921..afbb7cac 100644 --- a/tests/card-controller/conditions-manager.test.ts +++ b/tests/card-controller/conditions-manager.test.ts @@ -1023,5 +1023,75 @@ describe('ConditionsManager', () => { expect(manager.evaluateConditions(conditions)).toBeTruthy(); }); }); + + describe('with user agent condition', () => { + const userAgent = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + + it('should match exact user agent', () => { + const manager = new ConditionsManager(createCardAPI()); + const conditions = [{ condition: 'user_agent' as const, user_agent: userAgent }]; + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + manager.setState({ + user_agent: userAgent, + }); + expect(manager.evaluateConditions(conditions)).toBeTruthy(); + manager.setState({ + user_agent: 'Something else', + }); + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + }); + + it('should match user agent regex', () => { + const manager = new ConditionsManager(createCardAPI()); + const conditions = [ + { condition: 'user_agent' as const, user_agent_re: 'Chrome/' }, + ]; + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + manager.setState({ + user_agent: userAgent, + }); + expect(manager.evaluateConditions(conditions)).toBeTruthy(); + manager.setState({ + user_agent: 'Something else', + }); + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + }); + + it('should match companion app', () => { + const manager = new ConditionsManager(createCardAPI()); + const conditions = [{ condition: 'user_agent' as const, companion: true }]; + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + manager.setState({ + user_agent: 'Home Assistant/', + }); + expect(manager.evaluateConditions(conditions)).toBeTruthy(); + manager.setState({ + user_agent: userAgent, + }); + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + }); + + it('should match multiple parameters', () => { + const manager = new ConditionsManager(createCardAPI()); + const conditions = [ + { + condition: 'user_agent' as const, + companion: true, + user_agent: 'Home Assistant/', + user_agent_re: 'Home.Assistant', + }, + ]; + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + manager.setState({ + user_agent: 'Home Assistant/', + }); + expect(manager.evaluateConditions(conditions)).toBeTruthy(); + manager.setState({ + user_agent: 'Something else', + }); + expect(manager.evaluateConditions(conditions)).toBeFalsy(); + }); + }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 3beb634c..8c553a2a 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -4,6 +4,7 @@ import { conditionalSchema, customSchema, dimensionsConfigSchema, + frigateCardConditionSchema, frigateCardCustomActionsBaseSchema, frigateCardCustomActionSchema, } from '../../src/config/types'; @@ -401,6 +402,29 @@ it('should transform dimensions.aspect_ratio', () => { ).toEqual(expect.objectContaining({ aspect_ratio: [16, 9] })); }); +describe('should refine user_agent_re conditions', () => { + it('should successfully parse valid user_agent_re condition', () => { + expect( + frigateCardConditionSchema.parse({ + condition: 'user_agent', + user_agent_re: 'Chrome/', + }), + ).toEqual({ + condition: 'user_agent', + user_agent_re: 'Chrome/', + }); + }); + + it('should reject invalid user_agent_re conditions', () => { + expect(() => + frigateCardConditionSchema.parse({ + condition: 'user_agent', + user_agent_re: '[', + }), + ).toThrowError(/Invalid regular expression/); + }); +}); + it('should transform action', () => { expect( frigateCardCustomActionsBaseSchema.parse({ diff --git a/tests/utils/companion.test.ts b/tests/utils/companion.test.ts new file mode 100644 index 00000000..ea5a11f2 --- /dev/null +++ b/tests/utils/companion.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { isCompanionApp } from '../../src/utils/companion'; + +describe('isCompanionApp', () => { + it('should return true for userAgent starting with "Home Assistant/"', () => { + expect(isCompanionApp('Home Assistant/1.0')).toBe(true); + }); + + it('should return true for userAgent starting with "HomeAssistant/"', () => { + expect(isCompanionApp('HomeAssistant/1.0')).toBe(true); + }); + + it('should return false for userAgent not starting with "Home Assistant/" or "HomeAssistant/"', () => { + expect(isCompanionApp('Mozilla/5.0')).toBe(false); + }); + + it('should return false for an empty userAgent', () => { + expect(isCompanionApp('')).toBe(false); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index f804fd04..632f34f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,6 +21,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'utils/basic.ts', 'utils/camera.ts', 'utils/casting.ts', + 'utils/companion.ts', 'utils/custom-icons.ts', 'utils/debug.ts', 'utils/diagnostics.ts',