feat: Allow user-agent based conditions (#1821)

* Closes #1819
This commit is contained in:
Dermot Duffy
2025-01-10 07:41:19 -08:00
committed by GitHub
parent 7657bdf22c
commit 2678500670
14 changed files with 232 additions and 11 deletions
+2 -1
View File
@@ -10,6 +10,7 @@
"redhat.vscode-yaml",
"lokalise.i18n-ally",
"vitest.explorer",
"inlang.vs-code-extension"
"inlang.vs-code-extension",
"yoavbls.pretty-ts-errors"
]
}
+56 -3
View File
@@ -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
+2 -2
View File
@@ -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
+16
View File
@@ -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
@@ -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();
+11
View File
@@ -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))
);
}
}
+1
View File
@@ -125,6 +125,7 @@ export interface CardDownloadAPI {
export interface CardElementAPI {
getActionsManager(): ActionsManager;
getCameraManager(): CameraManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getDefaultManager(): DefaultManager;
getExpandManager(): ExpandManager;
+20
View File
@@ -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<typeof frigateCardConditionSchema>;
+3
View File
@@ -0,0 +1,3 @@
export const isCompanionApp = (userAgent: string): boolean => {
return !!userAgent.match(/Home ?Assistant/);
};
+2 -5
View File
@@ -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.
//
@@ -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();
});
});
});
});
+24
View File
@@ -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({
+20
View File
@@ -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);
});
});
+1
View File
@@ -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',