Merge remote-tracking branch 'upstream/release-4.0.0' into switch-to-live

This commit is contained in:
Felipe Santos
2022-05-24 13:20:43 -03:00
21 changed files with 802 additions and 216 deletions
+136 -9
View File
@@ -114,6 +114,9 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
| `webrtc_card` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | | `webrtc_card` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. |
| `id` | `camera_entity`, `webrtc_card.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | | `id` | `camera_entity`, `webrtc_card.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). |
| `dependent_cameras` | | :heavy_multiplication_x: | An optional array of other camera identifiers (see [camera IDs](#camera-ids)). If specified the card will fetch events for this camera and *also* recursively events for the named `dependent_cameras`. All `dependent_cameras` must themselves be a configured camera in the card. This can be useful to group events for cameras that are close together, or to show events for the `birdseye` camera that otherwise would not have events itself.| | `dependent_cameras` | | :heavy_multiplication_x: | An optional array of other camera identifiers (see [camera IDs](#camera-ids)). If specified the card will fetch events for this camera and *also* recursively events for the named `dependent_cameras`. All `dependent_cameras` must themselves be a configured camera in the card. This can be useful to group events for cameras that are close together, or to show events for the `birdseye` camera that otherwise would not have events itself.|
| `trigger_by_motion` | `false` | :heavy_multiplication_x: | Whether to not to trigger the camera (used to trigger [scan mode](#scan-mode) or reseting the default view) by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant.|
| `trigger_by_occupancy` | `true` | :heavy_multiplication_x: | Whether to not to trigger the camera (used to trigger [scan mode](#scan-mode) or reseting the default view) by automatically detecting and using the occupancy `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant.|
| `trigger_by_entities` | | :heavy_multiplication_x: | Whether to not to trigger the camera (used to trigger [scan mode](#scan-mode) or reseting the default view) when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras.|
<a name="live-providers"></a> <a name="live-providers"></a>
@@ -171,10 +174,32 @@ See the [fully expanded view configuration example](#config-expanded-view) for h
| `timeout_seconds` | `300` | :white_check_mark: | A numbers of seconds of inactivity after user interaction, after which the card will reset to the default configured view (i.e. 'screensaver' functionality). Inactivity is defined as lack of mouse/touch interaction with the Frigate card. If the default view occurs sooner (e.g. via `update_seconds` or manually) the timer will be stopped. `0` means disable this functionality. | | `timeout_seconds` | `300` | :white_check_mark: | A numbers of seconds of inactivity after user interaction, after which the card will reset to the default configured view (i.e. 'screensaver' functionality). Inactivity is defined as lack of mouse/touch interaction with the Frigate card. If the default view occurs sooner (e.g. via `update_seconds` or manually) the timer will be stopped. `0` means disable this functionality. |
| `update_seconds` | `0` | :white_check_mark: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.| | `update_seconds` | `0` | :white_check_mark: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.|
| `update_force` | `false` | :white_check_mark: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.| | `update_force` | `false` | :white_check_mark: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.|
| `update_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the view to reset to the default. See [card updates](#card-updates) below for behavior and usecases.| | `update_entities` | | :white_check_mark: | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use `trigger_by_entities` for the selected camera instead) See [card updates](#card-updates) below for behavior and usecases.|
| `update_cycle_camera` | `false` | :white_check_mark: | When set to `true` the selected camera is cycled on each default view change. | | `update_cycle_camera` | `false` | :white_check_mark: | When set to `true` the selected camera is cycled on each default view change. |
| `render_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | | `render_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). |
| `scan` | | :white_check_mark: | Configuration for [scan mode](#scan-mode). |
| `actions` | | :white_check_mark: | Actions to use for all views, individual actions may be overriden by view-specific actions. See [actions](#actions) below.| | `actions` | | :white_check_mark: | Actions to use for all views, individual actions may be overriden by view-specific actions. See [actions](#actions) below.|
<a name="scan-mode"></a>
#### View: Scan Mode configuration
All configuration is under:
```yaml
view:
scan:
```
Scan mode allows the card to automatically "follow the action". In this mode the card will automatically select a camera in the `live` view when it is triggered (as defined by your camera configuration, see `trigger_by_motion`, `trigger_by_occupancy` and `trigger_by_entities` parameters). When the camera untriggers, the camera selection will return to the next most recently triggered camera (as long as it is still triggered) -- if there are no triggered cameras remaining, the camera will return to the default. Triggering is only allowed when there is no ongoing human interaction with the card -- interaction will automatically untrigger it and further triggering will not occur until after the card has been unattended for `view.timeout_seconds`.
Scan mode tracks Home Assistant state changes -- when the card is first started, it takes a positive change in state to trigger (i.e. an already occupied room will not trigger it, but a newly occupied room would trigger it).
| Option | Default | Overridable | Description |
| - | - | - | - |
| `enabled` | `false` | :white_check_mark: | Whether to enable scan mode. |
| `show_trigger_status` | `true` | :white_check_mark: | Whether or not the card should show a visual indication that it is triggered (a pulsing border around the card edge). |
### Menu Options ### Menu Options
All configuration is under: All configuration is under:
@@ -896,6 +921,44 @@ This card supports fully configurable submenus.
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/submenu.gif" alt="Configurable submenus" width="400px"> <img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/submenu.gif" alt="Configurable submenus" width="400px">
### Select Entity Submenus
Automatically generate submenus from `select` entities.
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/submenu-select.gif" alt="Select based submenus" width="400px">
### Cast media from the card
Cast media from the card to a local player.
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/cast-your-events.gif" alt="Cast media" width="400px">
### Scan Mode
Automatically choose the camera with the action!
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/scan-mode.gif" alt="Cast media" width="400px">
### Thumbnail Drawers
View thumbnails in side-drawers.
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/thumbnails-in-drawer.gif" alt="Thumbnail drawers" width="400px">
### Event Timeline
View events in the timeline.
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/timeline.gif" alt="Event Timeline" width="400px">
<a name="screenshots-card-casting"></a>
### Card Casting
A dashboard with the card can be cast onto a suitable device (such as the Nest Hub shown below).
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/card-on-nest-hub.jpg" alt="Card on Nest Hub" width="400px">
## Examples ## Examples
### Illustrative Expanded Configuration Reference ### Illustrative Expanded Configuration Reference
@@ -921,6 +984,10 @@ cameras:
# Show events for camera-2 when this camera is viewed. # Show events for camera-2 when this camera is viewed.
dependent_cameras: dependent_cameras:
- camera-2 - camera-2
trigger_by_motion: false
trigger_by_occupancy: true
trigger_by_entities:
- binary_sensor.front_door_sensor
- frigate_url: http://my-other.frigate.local - frigate_url: http://my-other.frigate.local
client_id: frigate-other client_id: frigate-other
camera_name: entrance camera_name: entrance
@@ -935,6 +1002,10 @@ cameras:
webrtc_card: webrtc_card:
entity: camera.entrance_rtsp entity: camera.entrance_rtsp
url: 'rtsp://username:password@camera:554/av_stream/ch0' url: 'rtsp://username:password@camera:554/av_stream/ch0'
trigger_by_motion: false
trigger_by_occupancy: true
trigger_by_entities:
- binary_sensor.entrance_sensor
``` ```
</details> </details>
@@ -958,6 +1029,9 @@ view:
render_entities: render_entities:
- switch.render_card - switch.render_card
dark_mode: 'off' dark_mode: 'off'
scan:
enabled: false
show_trigger_status: true
actions: actions:
entity: light.office_main_lights entity: light.office_main_lights
tap_action: tap_action:
@@ -2234,7 +2308,7 @@ the card to trigger a card update based on that entity -- which causes it to use
the new overriden default immediately. Alternatives to trigger the card to the new overriden default immediately. Alternatives to trigger the card to
change view but without `update_entities` would just be having an change view but without `update_entities` would just be having an
`update_seconds` parameter which reloads the default view that many seconds `update_seconds` parameter which reloads the default view that many seconds
after user interaction stops. after user interaction stops or through the use of the `trigger_by_entities` option for a given camera.
```yaml ```yaml
view: view:
@@ -2365,6 +2439,30 @@ cameras:
``` ```
</details> </details>
### Using Scan Mode
Have your card follow the action with Scan Mode.
<details>
<summary>Expand: Using scan mode</summary>
```yaml
type: custom:frigate-card
cameras:
- camera_entity: camera.back_yard
# This camera will automatically trigger by occupancy.
- camera_entity: camera.front_door
trigger_by_occupancy: false
trigger_by_motion: true
trigger_by_entities:
- binary_sensor.door_opened
view:
scan:
enabled: true
trigger_show_border: true
```
</details>
<a name="card-updates"></a> <a name="card-updates"></a>
## Card Refreshes ## Card Refreshes
@@ -2376,7 +2474,11 @@ The following table describes the behavior these flags have.
### Card Update Truth Table ### Card Update Truth Table
| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | `view . update_entities` | Behavior | Note that no (other) automated updates are permitted when [scan mode](#scan-mode) is being triggered.
In the below "Trigger Entities" refers to the combination of `view.update_entities` and the `trigger_by_entities` for the currently selected camera (which in turn will also include the occupancy and motion sensor entities for Frigate cameras if `trigger_by_occupancy` and `trigger_by_motion` options are enabled).
| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | Trigger Entities | Behavior |
| :-: | :-: | :-: | :-: | - | | :-: | :-: | :-: | :-: | - |
| `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. | | `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. |
| `0` | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera when entity state changes. | | `0` | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera when entity state changes. |
@@ -2400,13 +2502,11 @@ view:
``` ```
* Using `clip` or `snapshot` as the default view (for the most recent clip or * Using `clip` or `snapshot` as the default view (for the most recent clip or
snapshot respectively) and having the card automatically refresh (to fetch a snapshot respectively) and having the card automatically refresh (to fetch a
newer clip/snapshot) when an entity state changes. Use the Frigate newer clip/snapshot) on motion.
binary_sensor for that camera (or any other entity at your discretion) to
trigger the update:
```yaml ```yaml
view: cameras:
update_entities: - entity: camera.office
- binary_sensor.office_person_motion trigger_by_motion: true
``` ```
* Cycle the live view of the camera every 60 seconds * Cycle the live view of the camera every 60 seconds
```yaml ```yaml
@@ -2422,6 +2522,33 @@ view:
timeout_seconds: 30 timeout_seconds: 30
``` ```
### Casting the Card
This card can be (Chrome) casted to a device (such as a [Nest Hub](https://store.google.com/us/product/nest_hub_2nd_gen)) through the use of [Home Assistant Cast](https://cast.home-assistant.io/).
#### Limitations
Casting Home Assistant dashboards comes with a number of caveats:
* Home Assistant Casting does not support the HA `streaming` component ([source](https://cast.home-assistant.io/faq.html)). This means clips playing and the `ha` live provider can not work. Other live providers such as `jsmpeg` and `webrtc-card` function correctly.
* The Javascript fullscreen API does not work (so the fullscreen button does not work, but see below for an equivalent).
#### Recommended configuration for a Nest Hub
Using a `panel` dashboard with the following base configuration will result in the card consuming the entire device screen:
```yaml
type: custom:frigate-card
cameras:
- camera_entity: camera.front_door
live_provider: frigate-jsmpeg
dimensions:
aspect_ratio: 1024:600
aspect_ratio_mode: static
```
See [screenshot above](#screenshots-card-casting).
## Troubleshooting ## Troubleshooting
<a name="jsmpeg-troubleshooting"></a> <a name="jsmpeg-troubleshooting"></a>
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

+293 -120
View File
@@ -48,23 +48,21 @@ import './patches/ha-camera-stream.js';
import './patches/ha-hls-player.js'; import './patches/ha-hls-player.js';
import './patches/ha-web-rtc-player.ts'; import './patches/ha-web-rtc-player.ts';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
import type {
Entity,
ExtendedHomeAssistant,
FrigateCardConfig,
MediaShowInfo,
MenuButton,
Message,
} from './types.js';
import { import {
Actions, Actions,
ActionType, ActionType,
CameraConfig, CameraConfig,
entitySchema, EntityList,
ExtendedEntity,
ExtendedHomeAssistant,
FrigateCardConfig,
frigateCardConfigSchema, frigateCardConfigSchema,
FrigateCardCustomAction, FrigateCardCustomAction,
FrigateCardView, FrigateCardView,
FRIGATE_CARD_VIEWS_USER_SPECIFIED, FRIGATE_CARD_VIEWS_USER_SPECIFIED,
MediaShowInfo,
MenuButton,
Message,
RawFrigateCardConfig, RawFrigateCardConfig,
} from './types.js'; } from './types.js';
import { import {
@@ -79,15 +77,22 @@ import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js';
import { import {
getEntityIcon, getEntityIcon,
getEntityTitle, getEntityTitle,
getHassDifferences,
homeAssistantSignPath, homeAssistantSignPath,
homeAssistantWSRequest, isHassDifferent,
shouldUpdateBasedOnHass, isTriggeredState,
sideLoadHomeAssistantElements, sideLoadHomeAssistantElements,
} from './utils/ha'; } from './utils/ha';
import { getEventID } from './utils/ha/browse-media.js'; import { getEventID } from './utils/ha/browse-media.js';
import {
ExtendedEntityCache,
getAllEntities,
getExtendedEntities,
getExtendedEntity,
} from './utils/ha/entity-registry.js';
import { ResolvedMediaCache } from './utils/ha/resolved-media.js';
import { supportsFeature } from './utils/ha/update.js'; import { supportsFeature } from './utils/ha/update.js';
import { isValidMediaShowInfo } from './utils/media-info.js'; import { isValidMediaShowInfo } from './utils/media-info.js';
import { ResolvedMediaCache } from './utils/resolved-media.js';
import { View } from './view.js'; import { View } from './view.js';
/** A note on media callbacks: /** A note on media callbacks:
@@ -191,6 +196,8 @@ export class FrigateCard extends LitElement {
// Whether the card has been successfully initialized. // Whether the card has been successfully initialized.
protected _initialized = false; protected _initialized = false;
protected _triggers: Map<string, Date> = new Map();
/** /**
* Set the Home Assistant object. * Set the Home Assistant object.
*/ */
@@ -488,7 +495,6 @@ export class FrigateCard extends LitElement {
const isValidMediaPlayer = (entity: string): boolean => { const isValidMediaPlayer = (entity: string): boolean => {
if (entity.startsWith('media_player.')) { if (entity.startsWith('media_player.')) {
const stateObj = this._hass?.states[entity]; const stateObj = this._hass?.states[entity];
if (stateObj && supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA)) { if (stateObj && supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA)) {
return true; return true;
} }
@@ -571,23 +577,136 @@ export class FrigateCard extends LitElement {
} }
} }
/**
* Get the motion sensor entity for a given camera.
* @param cache The ExtendedEntityCache of entity registry information.
* @param cameraConfig The camera config in question.
* @returns The entity id of the motion sensor or null.
*/
protected _getMotionSensor(
cache: ExtendedEntityCache,
cameraConfig: CameraConfig,
): string | null {
if (cameraConfig.camera_name) {
return (
cache.getMatch(
(ent) =>
!!ent.unique_id?.match(
new RegExp(
`:motion_sensor:${cameraConfig.zone || cameraConfig.camera_name}`,
),
),
)?.entity_id ?? null
);
}
return null;
}
/**
* Get the occupancy sensor entity for a given camera.
* @param cache The ExtendedEntityCache of entity registry information.
* @param cameraConfig The camera config in question.
* @returns The entity id of the occupancy sensor or null.
*/
protected _getOccupancySensor(
cache: ExtendedEntityCache,
cameraConfig: CameraConfig,
): string | null {
if (cameraConfig.camera_name) {
return (
cache.getMatch(
(ent) =>
!!ent.unique_id?.match(
new RegExp(
`:occupancy_sensor:${cameraConfig.zone || cameraConfig.camera_name}_${
cameraConfig.label || 'all'
}`,
),
),
)?.entity_id ?? null
);
}
return null;
}
/** /**
* Fully load the configured cameras. * Fully load the configured cameras.
*/ */
protected async _loadCameras(): Promise<void> { protected async _loadCameras(): Promise<void> {
if (!this._hass) {
return;
}
const cache = new ExtendedEntityCache();
let entityList: EntityList | undefined;
try {
entityList = await getAllEntities(this._hass);
} catch (e) {
console.error(e, (e as Error).stack);
}
const cameras: Map<string, CameraConfig> = new Map(); const cameras: Map<string, CameraConfig> = new Map();
let errorFree = true; let errorFree = true;
const addCameraConfig = async (config: CameraConfig) => { const addCameraConfig = async (config: CameraConfig) => {
if (!config.camera_name && config.camera_entity) { if (!this._hass) {
const resolvedName = await this._getFrigateCameraNameFromEntity( return;
config.camera_entity, }
);
let entity: ExtendedEntity | null = null;
if (config.camera_entity) {
try {
entity = await getExtendedEntity(this._hass, config.camera_entity, cache);
} catch (e) {
console.error(e, (e as Error).stack);
}
}
if (!config.camera_name && entity) {
const resolvedName = this._getFrigateCameraNameFromEntity(entity);
if (resolvedName) { if (resolvedName) {
config.camera_name = resolvedName; config.camera_name = resolvedName;
} }
} }
if (entity && entityList) {
// Try to find the correct entities for the motion & occupancy sensors.
// We know they are binary_sensors, and that they'll have the same
// config entry ID as the camera. Searching via unique_id ensures this
// search still works if the user renames the entity_id.
const binarySensorEntities = entityList.filter(
(ent) =>
ent.config_entry_id === entity?.config_entry_id &&
!ent.disabled_by &&
ent.entity_id.startsWith('binary_sensor.'),
);
try {
await getExtendedEntities(
this._hass,
binarySensorEntities.map((ent) => ent.entity_id),
cache,
);
} catch (e) {
console.error(e, (e as Error).stack);
}
if (config.trigger_by_motion) {
const motionEntity = this._getMotionSensor(cache, config);
if (motionEntity) {
config.trigger_by_entities.push(motionEntity);
}
}
if (config.trigger_by_occupancy) {
const occupancyEntity = this._getOccupancySensor(cache, config);
if (occupancyEntity) {
config.trigger_by_entities.push(occupancyEntity);
}
}
}
config.trigger_by_entities = [...new Set(config.trigger_by_entities)];
const id = getCameraID(config); const id = getCameraID(config);
if (!id) { if (!id) {
this._setMessageAndUpdate({ this._setMessageAndUpdate({
@@ -641,37 +760,16 @@ export class FrigateCard extends LitElement {
} }
/** /**
* Get the Frigate camera name from an entity name. * Get the Frigate camera name from an entity.
* @returns The Frigate camera name or null if unavailable. * @returns The Frigate camera name or null if unavailable.
*/ */
protected async _getFrigateCameraNameFromEntity( protected _getFrigateCameraNameFromEntity(entity: ExtendedEntity): string | null {
entity: string, if (entity.unique_id && entity.platform === 'frigate') {
): Promise<string | null> { const match = entity.unique_id.match(/:camera:(?<camera>[^:]+)$/);
if (!this._hass) { if (match && match.groups) {
return null; return match.groups['camera'];
}
// Find entity unique_id in registry.
const request = {
type: 'config/entity_registry/get',
entity_id: entity,
};
try {
const entityResult = await homeAssistantWSRequest<Entity>(
this._hass,
entitySchema,
request,
);
if (entityResult && entityResult.platform == 'frigate') {
const match = entityResult.unique_id.match(/:camera:(?<camera>[^:]+)$/);
if (match && match.groups) {
return match.groups['camera'];
}
} }
} catch (e: unknown) {
// Pass.
} }
return null; return null;
} }
@@ -775,6 +873,7 @@ export class FrigateCard extends LitElement {
this._message = null; this._message = null;
this._generateConditionState(); this._generateConditionState();
this._setLightOrDarkMode(); this._setLightOrDarkMode();
this._untrigger();
} }
/** /**
@@ -850,6 +949,7 @@ export class FrigateCard extends LitElement {
* Called before each update. * Called before each update.
*/ */
protected willUpdate(): void { protected willUpdate(): void {
// Side load the necessary elements if not already initialized.
if (!this._initialized) { if (!this._initialized) {
sideLoadHomeAssistantElements().then((success) => { sideLoadHomeAssistantElements().then((success) => {
if (success) { if (success) {
@@ -859,6 +959,73 @@ export class FrigateCard extends LitElement {
} }
} }
/**
* Get the most recent triggered camera.
*/
protected _getMostRecentTrigger(): string | null {
const sorted = [...this._triggers.entries()].sort(
(a: [string, Date], b: [string, Date]) => b[1].getTime() - a[1].getTime(),
);
return sorted.length ? sorted[0][0] : null;
}
/**
* Determine if a camera has been triggered.
* @param oldHass The old HA object.
* @returns A boolean indicating whether the camera was changed.
*/
protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean {
if (!this._view) {
return false;
}
const now = new Date();
let changedCamera = false;
let triggerChanges = false;
for (const [camera, config] of this._cameras?.entries() ?? []) {
const triggerEntities = config?.trigger_by_entities ?? [];
const diffs = getHassDifferences(this._hass, oldHass, triggerEntities, {
stateOnly: true,
});
const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState));
const shouldUntrigger = triggerEntities.every(
(entity) => !isTriggeredState(this._hass?.states[entity]),
);
if (shouldTrigger) {
this._triggers.set(camera, now);
triggerChanges = true;
} else if (shouldUntrigger && this._triggers.has(camera)) {
this._triggers.delete(camera);
triggerChanges = true;
}
}
if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) {
if (!this._triggers.size) {
this._changeView();
changedCamera = true;
} else {
const targetCamera = this._getMostRecentTrigger();
if (
targetCamera &&
(this._view.camera !== targetCamera || !this._view.is('live'))
) {
this._changeView({ view: new View({ view: 'live', camera: targetCamera }) });
changedCamera = true;
}
}
}
return changedCamera;
}
/**
* Untrigger the card.
*/
protected _untrigger(): void {
this._triggers.clear();
}
/** /**
* Determine whether the element should be updated. * Determine whether the element should be updated.
* @param changedProps The changed properties if any. * @param changedProps The changed properties if any.
@@ -869,19 +1036,21 @@ export class FrigateCard extends LitElement {
let shouldUpdate = !oldHass || changedProps.size != 1; let shouldUpdate = !oldHass || changedProps.size != 1;
if (oldHass) { if (oldHass) {
// Home Assistant pumps a lot of updates through. Re-rendering the card is const selectedCamera = this._getSelectedCameraConfig();
// necessary at times (e.g. to update the 'clip' view as new clips if (this._getConfig().view.scan.enabled && this._updateTriggeredCameras(oldHass)) {
// arrive), but also is a jarring experience for the user (e.g. if they shouldUpdate ||= true;
// are browsing the mini-gallery). Do not allow re-rendering from a Home } else if (
// Assistant update if there's been recent interaction (e.g. clicks on the // Home Assistant pumps a lot of updates through. Re-rendering the card is
// card) or if there is media active playing. // necessary at times (e.g. to update the 'clip' view as new clips
if ( // 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() && this._isAutomatedViewUpdateAllowed() &&
shouldUpdateBasedOnHass( isHassDifferent(this._hass, oldHass, [
this._hass, ...(this._getConfig().view.update_entities || []),
oldHass, ...(selectedCamera?.trigger_by_entities || []),
this._getConfig().view.update_entities || [], ])
)
) { ) {
// If entities being monitored have changed then reset the view to the // If entities being monitored have changed then reset the view to the
// default. Note that as per the Lit lifecycle, the setting of the view // default. Note that as per the Lit lifecycle, the setting of the view
@@ -889,7 +1058,7 @@ export class FrigateCard extends LitElement {
this._changeView(); this._changeView();
shouldUpdate ||= true; shouldUpdate ||= true;
} else { } else {
shouldUpdate ||= shouldUpdateBasedOnHass( shouldUpdate ||= isHassDifferent(
this._hass, this._hass,
oldHass, oldHass,
this._getConfig().view.render_entities || [], this._getConfig().view.render_entities || [],
@@ -1177,11 +1346,17 @@ export class FrigateCard extends LitElement {
*/ */
protected _startInteractionTimer(): void { protected _startInteractionTimer(): void {
this._clearInteractionTimer(); this._clearInteractionTimer();
// Interactions reset the trigger state.
this._untrigger();
if (this._getConfig().view.timeout_seconds) { if (this._getConfig().view.timeout_seconds) {
this._interactionTimerID = window.setTimeout(() => { this._interactionTimerID = window.setTimeout(() => {
this._changeView();
this._clearInteractionTimer(); this._clearInteractionTimer();
this._setLightOrDarkMode(); if (this._isAutomatedViewUpdateAllowed()) {
this._changeView();
this._setLightOrDarkMode();
}
}, this._getConfig().view.timeout_seconds * 1000); }, this._getConfig().view.timeout_seconds * 1000);
} }
this._setLightOrDarkMode(); this._setLightOrDarkMode();
@@ -1213,8 +1388,11 @@ export class FrigateCard extends LitElement {
* Determine if an automated view update is allowed. * Determine if an automated view update is allowed.
* @returns `true` if it's allowed, `false` otherwise. * @returns `true` if it's allowed, `false` otherwise.
*/ */
protected _isAutomatedViewUpdateAllowed(): boolean { protected _isAutomatedViewUpdateAllowed(ignoreTriggers?: boolean): boolean {
return this._getConfig().view.update_force || !this._interactionTimerID; return (
(ignoreTriggers || !this._triggers.size) &&
(this._getConfig().view.update_force || !this._interactionTimerID)
);
} }
/** /**
@@ -1342,21 +1520,21 @@ export class FrigateCard extends LitElement {
* required). * required).
* @returns A padding percentage. * @returns A padding percentage.
*/ */
protected _getAspectRatioPadding(): number | null { protected _getAspectRatioStyle(): string {
if (!this._isAspectRatioEnforced()) { if (!this._isAspectRatioEnforced()) {
return null; return 'auto';
} }
const aspectRatioMode = this._getConfig().dimensions.aspect_ratio_mode; const aspectRatioMode = this._getConfig().dimensions.aspect_ratio_mode;
if (aspectRatioMode == 'dynamic' && this._mediaShowInfo) { if (aspectRatioMode == 'dynamic' && this._mediaShowInfo) {
return (this._mediaShowInfo.height / this._mediaShowInfo.width) * 100; return `${this._mediaShowInfo.width} / ${this._mediaShowInfo.height}`;
} }
const defaultAspectRatio = this._getConfig().dimensions.aspect_ratio; const defaultAspectRatio = this._getConfig().dimensions.aspect_ratio;
if (defaultAspectRatio) { if (defaultAspectRatio) {
return (defaultAspectRatio[1] / defaultAspectRatio[0]) * 100; return `${defaultAspectRatio[0]} / ${defaultAspectRatio[1]}`;
} else { } else {
return (9 / 16) * 100; return '16 / 9';
} }
} }
@@ -1389,17 +1567,13 @@ export class FrigateCard extends LitElement {
return; return;
} }
const padding = this._getAspectRatioPadding(); const cardStyle = {
const outerStyle = {}; 'aspect-ratio': this._getAspectRatioStyle(),
};
// Padding to force a particular aspect ratio. const mainClasses = {
if (padding != null) { main: true,
outerStyle['padding-top'] = `${padding}%`; triggered:
} !!this._triggers.size && this._getConfig().view.scan.show_trigger_status,
const contentClasses = {
'frigate-card-contents': true,
absolute: padding != null,
}; };
const actions = this._getMergedActions(); const actions = this._getMergedActions();
@@ -1412,6 +1586,7 @@ export class FrigateCard extends LitElement {
hasHold: frigateCardHasAction(actions.hold_action), hasHold: frigateCardHasAction(actions.hold_action),
hasDoubleClick: frigateCardHasAction(actions.double_tap_action), hasDoubleClick: frigateCardHasAction(actions.double_tap_action),
})} })}
style="${styleMap(cardStyle)}"
@action=${(ev: CustomEvent) => this._actionHandler(ev, actions)} @action=${(ev: CustomEvent) => this._actionHandler(ev, actions)}
@ll-custom=${this._cardActionHandler.bind(this)} @ll-custom=${this._cardActionHandler.bind(this)}
@frigate-card:message=${this._messageHandler} @frigate-card:message=${this._messageHandler}
@@ -1420,49 +1595,47 @@ export class FrigateCard extends LitElement {
@frigate-card:render=${() => this.requestUpdate()} @frigate-card:render=${() => this.requestUpdate()}
> >
${renderMenuAbove ? this._renderMenu() : ''} ${renderMenuAbove ? this._renderMenu() : ''}
<div class="container outer" style="${styleMap(outerStyle)}"> <div class="${classMap(mainClasses)}">
<div class="${classMap(contentClasses)}"> ${this._cameras === undefined
${this._cameras === undefined ? until(
? until( (async () => {
(async () => { await this._loadCameras();
await this._loadCameras(); // Don't reset messages as errors may have been generated
// Don't reset messages as errors may have been generated // during the camera load.
// during the camera load. this._changeView({ resetMessage: false });
this._changeView({ resetMessage: false }); return this._render();
return this._render(); })(),
})(), renderProgressIndicator(),
renderProgressIndicator(), )
) : // Always want to call render even if there's a message, to
: // Always want to call render even if there's a message, to // ensure live preload is always present (even if not displayed).
// ensure live preload is always present (even if not displayed). this._render()}
this._render()} ${this._getConfig().elements
${this._getConfig().elements ? // Always show elements to allow for custom menu items (etc.) to
? // Always show elements to allow for custom menu items (etc.) to // be present even if a particular view has an error. Elements
// be present even if a particular view has an error. Elements // need to render after the main views so it can render 'on top'.
// need to render after the main views so it can render 'on top'. html` <frigate-card-elements
html` <frigate-card-elements ${ref(this._refElements)}
${ref(this._refElements)} .hass=${this._hass}
.hass=${this._hass} .elements=${this._getConfig().elements}
.elements=${this._getConfig().elements} .conditionState=${this._conditionState}
.conditionState=${this._conditionState} @frigate-card:menu-add=${(e) => {
@frigate-card:menu-add=${(e) => { this._addDynamicMenuButton(e.detail);
this._addDynamicMenuButton(e.detail); }}
}} @frigate-card:menu-remove=${(e) => {
@frigate-card:menu-remove=${(e) => { this._removeDynamicMenuButton(e.detail);
this._removeDynamicMenuButton(e.detail); }}
}} @frigate-card:condition-state-request=${(ev) => {
@frigate-card:condition-state-request=${(ev) => { conditionStateRequestHandler(ev, this._conditionState);
conditionStateRequestHandler(ev, this._conditionState); }}
}} >
> </frigate-card-elements>`
</frigate-card-elements>` : ``}
: ``} ${
${ // Keep message rendering to last to show messages that may have
// Keep message rendering to last to show messages that may have // been generated during the render.
// been generated during the render. this._message ? renderMessage(this._message) : ''
this._message ? renderMessage(this._message) : '' }
}
</div>
</div> </div>
${!renderMenuAbove ? this._renderMenu() : ''} ${!renderMenuAbove ? this._renderMenu() : ''}
</ha-card>`; </ha-card>`;
+8 -8
View File
@@ -1,11 +1,11 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { import {
CSSResultGroup, CSSResultGroup,
html, html,
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS unsafeCSS
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
@@ -14,7 +14,7 @@ import defaultImage from '../images/frigate-bird-in-sky.jpg';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss'; import imageStyle from '../scss/image.scss';
import { CameraConfig, ImageViewConfig } from '../types.js'; import { CameraConfig, ImageViewConfig } from '../types.js';
import { shouldUpdateBasedOnHass } from '../utils/ha'; import { isHassDifferent } from '../utils/ha';
import { dispatchMediaShowEvent } from '../utils/media-info.js'; import { dispatchMediaShowEvent } from '../utils/media-info.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { dispatchErrorMessageEvent } from './message.js'; import { dispatchErrorMessageEvent } from './message.js';
@@ -96,7 +96,7 @@ export class FrigateCardImage extends LitElement {
this._imageConfig?.mode === 'camera' && this._imageConfig?.mode === 'camera' &&
cameraEntity cameraEntity
) { ) {
if (shouldUpdateBasedOnHass(this.hass, changedProps.get('hass'), [cameraEntity])) { if (isHassDifferent(this.hass, changedProps.get('hass'), [cameraEntity])) {
// If the state of the camera entity has changed, remove the cached // If the state of the camera entity has changed, remove the cached
// value (will be re-calculated in willUpdate). This is important to // value (will be re-calculated in willUpdate). This is important to
// ensure a changed access token is immediately used. // ensure a changed access token is immediately used.
+2 -1
View File
@@ -1090,7 +1090,8 @@ export class FrigateCardLiveJSMPEG extends LitElement {
if (!this.cameraConfig?.camera_name) { if (!this.cameraConfig?.camera_name) {
return dispatchErrorMessageEvent( return dispatchErrorMessageEvent(
this, this,
localize('error.no_camera_name') + `: ${JSON.stringify(this.cameraConfig)}`, localize('error.no_camera_name'),
this.cameraConfig,
); );
} }
+10 -10
View File
@@ -1,12 +1,12 @@
import type { Corner } from '@material/mwc-menu'; import type { Corner } from '@material/mwc-menu';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { import {
CSSResultGroup, CSSResultGroup,
html, html,
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS unsafeCSS
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js'; import { ifDefined } from 'lit/directives/if-defined.js';
@@ -15,10 +15,10 @@ import { actionHandler } from '../action-handler-directive.js';
import submenuStyle from '../scss/submenu.scss'; import submenuStyle from '../scss/submenu.scss';
import { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../types.js'; import { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../types.js';
import { import {
frigateCardHasAction, frigateCardHasAction,
stopEventFromActivatingCardWideActions stopEventFromActivatingCardWideActions
} from '../utils/action.js'; } from '../utils/action.js';
import { refreshDynamicStateParameters, shouldUpdateBasedOnHass } from '../utils/ha'; import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha';
import { domainIcon } from '../utils/icons/domain-icon.js'; import { domainIcon } from '../utils/icons/domain-icon.js';
@customElement('frigate-card-submenu') @customElement('frigate-card-submenu')
@@ -139,7 +139,7 @@ export class FrigateCardSubmenuSelect extends LitElement {
changedProps.size != 1 || changedProps.size != 1 ||
!this.submenuSelect || !this.submenuSelect ||
(!!oldHass && (!!oldHass &&
shouldUpdateBasedOnHass(this.hass, oldHass, [this.submenuSelect.entity])) isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity]))
); );
} }
+1 -1
View File
@@ -37,7 +37,7 @@ import {
overrideMultiBrowseMediaQueryParameters overrideMultiBrowseMediaQueryParameters
} from '../utils/ha/browse-media.js'; } from '../utils/ha/browse-media.js';
import { createMediaShowInfo } from '../utils/media-info.js'; import { createMediaShowInfo } from '../utils/media-info.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/resolved-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
+11 -1
View File
@@ -23,6 +23,12 @@ export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER =
`${CONF_CAMERAS}.#.live_provider` as const; `${CONF_CAMERAS}.#.live_provider` as const;
export const CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS = export const CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS =
`${CONF_CAMERAS}.#.dependent_cameras` as const; `${CONF_CAMERAS}.#.dependent_cameras` as const;
export const CONF_CAMERAS_ARRAY_TRIGGER_BY_MOTION =
`${CONF_CAMERAS}.#.trigger_by_motion` as const;
export const CONF_CAMERAS_ARRAY_TRIGGER_BY_OCCUPANCY =
`${CONF_CAMERAS}.#.trigger_by_occupancy` as const;
export const CONF_CAMERAS_ARRAY_TRIGGER_BY_ENTITIES =
`${CONF_CAMERAS}.#.trigger_by_entities` as const;
export const CONF_VIEW = 'view' as const; export const CONF_VIEW = 'view' as const;
export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const;
@@ -33,6 +39,10 @@ export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera`
export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const;
export const CONF_VIEW_UPDATE_ENTITIES = `${CONF_VIEW}.update_entities` as const; export const CONF_VIEW_UPDATE_ENTITIES = `${CONF_VIEW}.update_entities` as const;
export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const;
export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const;
export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const;
export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS =
`${CONF_VIEW_SCAN}.show_trigger_status` as const;
export const CONF_EVENT_GALLERY = 'event_gallery' as const; export const CONF_EVENT_GALLERY = 'event_gallery' as const;
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS =
@@ -142,4 +152,4 @@ export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
export const CONF_OVERRIDES = 'overrides' as const; export const CONF_OVERRIDES = 'overrides' as const;
// Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93 // Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93
export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
+60 -18
View File
@@ -21,6 +21,9 @@ import {
CONF_CAMERAS_ARRAY_LABEL, CONF_CAMERAS_ARRAY_LABEL,
CONF_CAMERAS_ARRAY_LIVE_PROVIDER, CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
CONF_CAMERAS_ARRAY_TITLE, CONF_CAMERAS_ARRAY_TITLE,
CONF_CAMERAS_ARRAY_TRIGGER_BY_ENTITIES,
CONF_CAMERAS_ARRAY_TRIGGER_BY_MOTION,
CONF_CAMERAS_ARRAY_TRIGGER_BY_OCCUPANCY,
CONF_CAMERAS_ARRAY_URL, CONF_CAMERAS_ARRAY_URL,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
@@ -81,6 +84,9 @@ import {
CONF_VIEW_CAMERA_SELECT, CONF_VIEW_CAMERA_SELECT,
CONF_VIEW_DARK_MODE, CONF_VIEW_DARK_MODE,
CONF_VIEW_DEFAULT, CONF_VIEW_DEFAULT,
CONF_VIEW_SCAN,
CONF_VIEW_SCAN_ENABLED,
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_TIMEOUT_SECONDS,
CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_CYCLE_CAMERA,
CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_FORCE,
@@ -99,11 +105,12 @@ import {
} from './types.js'; } from './types.js';
import { arrayMove } from './utils/basic.js'; import { arrayMove } from './utils/basic.js';
import { getCameraID, getCameraTitle } from './utils/camera.js'; import { getCameraID, getCameraTitle } from './utils/camera.js';
import { sideLoadHomeAssistantElements } from './utils/ha'; import { getEntitiesFromHASS, sideLoadHomeAssistantElements } from './utils/ha';
const MENU_BUTTONS = 'buttons'; const MENU_BUTTONS = 'buttons';
const MENU_CAMERAS = 'cameras'; const MENU_CAMERAS = 'cameras';
const MENU_OPTIONS = 'options'; const MENU_OPTIONS = 'options';
const MENU_VIEW_SCAN = 'scan';
interface EditorOptionsSet { interface EditorOptionsSet {
icon: string; icon: string;
@@ -398,20 +405,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
} }
} }
protected _getEntities(domain: string): string[] {
if (!this.hass) {
return [];
}
const entities = Object.keys(this.hass.states).filter(
(eid) => eid.substr(0, eid.indexOf('.')) === domain,
);
entities.sort();
// Add a blank entry to unset a selection.
entities.unshift('');
return entities;
}
/** /**
* Render an option set header * Render an option set header
* @param optionSetName The name of the EditorOptionsSet. * @param optionSetName The name of the EditorOptionsSet.
@@ -601,6 +594,38 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
); );
} }
protected _renderViewScanMenu(): TemplateResult {
return html`
<div
class="submenu-header"
@click=${this._toggleMenu}
.domain=${MENU_VIEW_SCAN}
.key=${true}
>
<ha-icon .icon=${'mdi:target-account'}></ha-icon>
<span>${localize(`config.${CONF_VIEW_SCAN}.scan_mode`)}</span>
</div>
${this._expandedMenus[MENU_VIEW_SCAN]
? html` <div class="values">
${this._renderSwitch(
CONF_VIEW_SCAN_ENABLED,
frigateCardConfigDefaults.view.scan.enabled ?? true,
{
label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`),
},
)}
${this._renderSwitch(
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
frigateCardConfigDefaults.view.scan.show_trigger_status ?? true,
{
label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`),
},
)}
</div>`
: ''}
`;
}
/** /**
* Render an editor menu for the card menu buttons. * Render an editor menu for the card menu buttons.
* @param button The name of the button. * @param button The name of the button.
@@ -700,6 +725,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
protected _renderCamera( protected _renderCamera(
cameras: RawFrigateCardConfigArray, cameras: RawFrigateCardConfigArray,
cameraIndex: number, cameraIndex: number,
entities: string[],
addNewCamera?: boolean, addNewCamera?: boolean,
): TemplateResult | void { ): TemplateResult | void {
const liveProviders: EditorSelectOption[] = [ const liveProviders: EditorSelectOption[] = [
@@ -848,6 +874,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
multiple: true, multiple: true,
}, },
)} )}
${this._renderSwitch(
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGER_BY_OCCUPANCY, cameraIndex),
frigateCardConfigDefaults.cameras.trigger_by_occupancy,
)}
${this._renderSwitch(
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGER_BY_MOTION, cameraIndex),
frigateCardConfigDefaults.cameras.trigger_by_motion,
)}
${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGER_BY_ENTITIES, cameraIndex),
entities,
{
multiple: true,
},
)}
</div>` </div>`
: ``} : ``}
`; `;
@@ -935,7 +976,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
} }
const defaults = frigateCardConfigDefaults; const defaults = frigateCardConfigDefaults;
const entities = getEntitiesFromHASS(this.hass);
const cameras = (getConfigValue(this._config, CONF_CAMERAS) || const cameras = (getConfigValue(this._config, CONF_CAMERAS) ||
[]) as RawFrigateCardConfigArray; []) as RawFrigateCardConfigArray;
@@ -964,8 +1005,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderOptionSetHeader('cameras')} ${this._renderOptionSetHeader('cameras')}
${this._expandedMenus[MENU_OPTIONS] === 'cameras' ${this._expandedMenus[MENU_OPTIONS] === 'cameras'
? html` <div class="submenu"> ? html` <div class="submenu">
${cameras.map((_, index) => this._renderCamera(cameras, index))} ${cameras.map((_, index) => this._renderCamera(cameras, index, entities))}
${this._renderCamera(cameras, cameras.length, true)} ${this._renderCamera(cameras, cameras.length, entities, true)}
</div>` </div>`
: ''} : ''}
${this._renderOptionSetHeader('view')} ${this._renderOptionSetHeader('view')}
@@ -985,6 +1026,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_CYCLE_CAMERA,
defaults.view.update_cycle_camera, defaults.view.update_cycle_camera,
)} )}
${this._renderViewScanMenu()}
</div> </div>
` `
: ''} : ''}
+9 -1
View File
@@ -27,6 +27,9 @@
"frigate-jsmpeg": "Frigate JSMpeg", "frigate-jsmpeg": "Frigate JSMpeg",
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
}, },
"trigger_by_entities": "Trigger from other entities",
"trigger_by_motion": "Trigger by auto-detecting the motion sensor",
"trigger_by_occupancy": "Trigger by auto-detecting the occupancy sensor",
"webrtc_card": { "webrtc_card": {
"entity": "WebRTC Card Camera Entity (Not a Frigate camera)", "entity": "WebRTC Card Camera Entity (Not a Frigate camera)",
"url": "WebRTC Card Camera URL" "url": "WebRTC Card Camera URL"
@@ -65,7 +68,12 @@
"timeout_seconds": "Reset to default view X seconds after user action (0=never)", "timeout_seconds": "Reset to default view X seconds after user action (0=never)",
"update_cycle_camera": "Cycle through cameras when default view updates", "update_cycle_camera": "Cycle through cameras when default view updates",
"update_force": "Force card updates (ignore user interaction)", "update_force": "Force card updates (ignore user interaction)",
"update_seconds": "Refresh default view every X seconds (0=never)" "update_seconds": "Refresh default view every X seconds (0=never)",
"scan": {
"scan_mode": "Scan mode",
"enabled": "Scan mode enabled",
"show_trigger_status": "Show pulsing border when triggered"
}
}, },
"event_gallery": { "event_gallery": {
"controls": { "controls": {
+21 -21
View File
@@ -9,48 +9,48 @@
filter: brightness(50%); filter: brightness(50%);
} }
.container { div.main {
position: relative; position: relative;
overflow: auto; overflow: auto;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: auto; margin: auto;
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
// Need to apply the border radius on the container level, as the ha-card has // Need to apply the border radius on the container level, as the ha-card has
// overflow visible in order to allow a submenu to extend beyond the card // overflow visible in order to allow a submenu to extend beyond the card
// boundary. // boundary.
border-radius: var(--ha-card-border-radius, 4px); border-radius: var(--ha-card-border-radius, 4px);
}
.frigate-card-contents { // Include the borders in the sizing (to keep the triggered warning pulse
width: inherit; // visible in fullscreen).
height: inherit; box-sizing: border-box;
margin: auto;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
// Hide scrollbar: Firefox // Hide scrollbar: Firefox
scrollbar-width: none; scrollbar-width: none;
// Hide scrollbar: IE and Edge // Hide scrollbar: IE and Edge
-ms-overflow-style: none; -ms-overflow-style: none;
} }
div.main.triggered {
/* Hide scrollbar for Chrome, Safari and Opera */ @keyframes warning-pulse {
.frigate-card-contents::-webkit-scrollbar { 0% {
display: none; border: solid 1px rgba(0,0,0,0);
}
50% {
border: solid 1px var(--warning-color);
}
100% {
border: solid 1px rgba(0,0,0,0);
}
}
animation: warning-pulse 5s infinite;
} }
/* When forcing aspect ratio absolute positioning is required */ /* Hide scrollbar for Chrome, Safari and Opera */
.frigate-card-contents.absolute { div.main::-webkit-scrollbar {
position: absolute; display: none;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
} }
/* The 'hover' menu mode is styling applied outside of the menu itself */ /* The 'hover' menu mode is styling applied outside of the menu itself */
+1 -1
View File
@@ -42,7 +42,7 @@ div.upgrade span {
.submenu-header { .submenu-header {
display: flex; display: flex;
margin-top: 4px; margin-top: 10px;
cursor: pointer; cursor: pointer;
} }
+27 -2
View File
@@ -364,6 +364,9 @@ const customSchema = z
export const cameraConfigDefault = { export const cameraConfigDefault = {
client_id: 'frigate' as const, client_id: 'frigate' as const,
live_provider: 'auto' as const, live_provider: 'auto' as const,
trigger_by_motion: false,
trigger_by_occupancy: true,
trigger_by_entities: [],
}; };
const webrtcCardCameraConfigSchema = z.object({ const webrtcCardCameraConfigSchema = z.object({
entity: z.string().optional(), entity: z.string().optional(),
@@ -394,6 +397,10 @@ const cameraConfigSchema = z
// Set of cameras IDs upon which this camera depends. // Set of cameras IDs upon which this camera depends.
dependent_cameras: z.string().array().optional(), dependent_cameras: z.string().array().optional(),
trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion),
trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy),
trigger_by_entities: z.string().array().default(cameraConfigDefault.trigger_by_entities),
}) })
.default(cameraConfigDefault); .default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>; export type CameraConfig = z.infer<typeof cameraConfigSchema>;
@@ -498,6 +505,10 @@ const viewConfigDefault = {
update_force: false, update_force: false,
update_cycle_camera: false, update_cycle_camera: false,
dark_mode: 'off' as const, dark_mode: 'off' as const,
scan: {
enabled: false,
show_trigger_status: true,
}
}; };
const viewConfigSchema = z const viewConfigSchema = z
.object({ .object({
@@ -514,6 +525,10 @@ const viewConfigSchema = z
update_entities: z.string().array().optional(), update_entities: z.string().array().optional(),
render_entities: z.string().array().optional(), render_entities: z.string().array().optional(),
dark_mode: z.enum(['on', 'off', 'auto']).optional(), dark_mode: z.enum(['on', 'off', 'auto']).optional(),
scan: z.object({
enabled: z.boolean().default(viewConfigDefault.scan.enabled),
show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status),
}).default(viewConfigDefault.scan)
}) })
.merge(actionsSchema) .merge(actionsSchema)
.default(viewConfigDefault); .default(viewConfigDefault);
@@ -1221,7 +1236,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.
event: z.object({ event: z.object({
camera: z.string(), camera: z.string(),
end_time: z.number().nullable(), end_time: z.number().nullable(),
false_positive: z.boolean(), false_positive: z.boolean().nullable(),
has_clip: z.boolean(), has_clip: z.boolean(),
has_snapshot: z.boolean(), has_snapshot: z.boolean(),
id: z.string(), id: z.string(),
@@ -1249,8 +1264,18 @@ export const signedPathSchema = z.object({
export type SignedPath = z.infer<typeof signedPathSchema>; export type SignedPath = z.infer<typeof signedPathSchema>;
export const entitySchema = z.object({ export const entitySchema = z.object({
config_entry_id: z.string().nullable(),
disabled_by: z.string().nullable(),
entity_id: z.string(), entity_id: z.string(),
unique_id: z.string(),
platform: z.string(), platform: z.string(),
}); });
export type Entity = z.infer<typeof entitySchema>; export type Entity = z.infer<typeof entitySchema>;
export const extendedEntitySchema = entitySchema.extend({
// Extended entity results.
unique_id: z.string().optional(),
})
export type ExtendedEntity = z.infer<typeof extendedEntitySchema>;
export const entityListSchema = entitySchema.array();
export type EntityList = z.infer<typeof entityListSchema>;
+109
View File
@@ -0,0 +1,109 @@
import { HomeAssistant } from 'custom-card-helpers';
import { homeAssistantWSRequest } from '.';
import {
Entity,
EntityList,
entityListSchema,
ExtendedEntity,
extendedEntitySchema
} from '../../types.js';
export class ExtendedEntityCache {
protected _cache: Map<string, ExtendedEntity> = new Map();
/**
* Determine if the cache has a given entity_id.
* @param id
* @returns `true` if the id is in the cache, `false` otherwise.
*/
public has(id: string): boolean {
return this._cache.has(id);
}
/**
* Get the first value that returns true for the given predicate.
* @param func A callback function that returns a boolean.
* @returns The first matching value.
*/
public getMatch(func: (arg: ExtendedEntity) => boolean): ExtendedEntity | null {
return [...this._cache.values()].find(func) ?? null;
}
/**
* Get entity information given an id.
* @param id The entity id.
* @returns The `ExtendedEntity` for this id.
*/
public get(id: string): ExtendedEntity | undefined {
return this._cache.get(id);
}
/**
* Add a given ExtendedEntity to the cache.
* @param extendedEntity
*/
public set(extendedEntity: ExtendedEntity): void {
this._cache.set(extendedEntity.entity_id, extendedEntity);
}
}
/**
* Get the extended entity information for an entity. May throw.
* @param hass The Home Assistant object.
* @param entity The entity id.
* @param cache An optional ExtendedEntityCache.
* @returns The ExtendedEntity information.
*/
export const getExtendedEntity = async (
hass: HomeAssistant,
entity: string,
cache?: ExtendedEntityCache,
): Promise<ExtendedEntity> => {
const cachedValue = cache ? cache.get(entity) : undefined;
if (cachedValue) {
return cachedValue;
}
const result = await homeAssistantWSRequest<ExtendedEntity>(
hass,
extendedEntitySchema,
{
type: 'config/entity_registry/get',
entity_id: entity,
},
);
if (cache) {
cache.set(result);
}
return result;
};
/**
* Get the extended entity information for an array of entities.
* @param hass The Home Assistant object.
* @param entities An array of entity ids.
* @param cache An optional ExtendedEntityCache.
* @returns A map of entity id to ExtendedEntity objects.
*/
export const getExtendedEntities = async (
hass: HomeAssistant,
entities: string[],
cache?: ExtendedEntityCache,
): Promise<Map<string, Entity>> => {
const output: Map<string, Entity> = new Map();
const _storeExtendedEntity = async (entity: string): Promise<void> => {
output.set(entity, await getExtendedEntity(hass, entity, cache));
};
await Promise.all(entities.map(_storeExtendedEntity));
return output;
};
/**
* Get a list of all entities from the entity registry.
* @param hass The Home Assistant object.
* @returns An entity list object.
*/
export const getAllEntities = async (hass: HomeAssistant): Promise<EntityList> => {
return await homeAssistantWSRequest<EntityList>(hass, entityListSchema, {
type: 'config/entity_registry/list',
});
};
+86 -18
View File
@@ -4,11 +4,10 @@ import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema } from 'zod'; import { ZodSchema } from 'zod';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import { import {
CardHelpers, CardHelpers, ExtendedHomeAssistant,
ExtendedHomeAssistant, SignedPath,
SignedPath, signedPathSchema,
signedPathSchema, StateParameters
StateParameters
} from '../../types.js'; } from '../../types.js';
import { stateIcon } from '../icons/state-icon.js'; import { stateIcon } from '../icons/state-icon.js';
import { getParseErrorKeys } from '../zod.js'; import { getParseErrorKeys } from '../zod.js';
@@ -75,32 +74,75 @@ export async function homeAssistantSignPath(
return hass.hassUrl(response.path); return hass.hassUrl(response.path);
} }
interface HassStateDifference {
entity: string;
oldState?: HassEntity;
newState: HassEntity;
}
/** /**
* Determine whether the card should be updated based on Home Assistant changes. * Get the difference between two hass objects.
* @param newHass The new HA object. * @param newHass The new HA object.
* @param oldHass The old HA object. * @param oldHass The old HA object.
* @param entities The entities to examine for changes. * @param entities The entities to examine for changes.
* @returns A boolean indicating whether or not to allow an update. * @param options An options object. stateOnly: whether or not to compare state
* strings only, firstOnly: whether or not to get the first difference only.
* @returns An array of HassStateDifference objects.
*/ */
export function shouldUpdateBasedOnHass( export function getHassDifferences(
newHass: HomeAssistant | undefined | null, newHass: HomeAssistant | undefined | null,
oldHass: HomeAssistant | undefined | null, oldHass: HomeAssistant | undefined | null,
entities: string[] | null, entities: string[] | null,
): boolean { options?: {
firstOnly?: boolean;
stateOnly?: boolean;
},
): HassStateDifference[] {
if (!newHass || !entities || !entities.length) { if (!newHass || !entities || !entities.length) {
return false; return [];
}
if (!oldHass) {
return true;
} }
for (let i = 0; i < entities.length; i++) { const differences: HassStateDifference[] = [];
const entity = entities[i]; for (const entity of entities) {
if (entity && oldHass.states[entity] !== newHass.states[entity]) { const oldState: HassEntity | undefined = oldHass?.states[entity];
return true; const newState: HassEntity | undefined = newHass.states[entity];
if (
(options?.stateOnly && oldState?.state !== newState?.state) ||
(!options?.stateOnly && oldState !== newState)
) {
differences.push({
entity: entity,
oldState: oldState,
newState: newState,
});
if (options?.firstOnly) {
break;
}
} }
} }
return false; return differences;
}
/**
* Determine if two hass objects are different for a list of entities.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @param entities The entities to examine for changes.
* @param options An options object. stateOnly: whether or not to compare state strings only.
* @returns An array of HassStateDifference objects.
*/
export function isHassDifferent(
newHass: HomeAssistant | undefined | null,
oldHass: HomeAssistant | undefined | null,
entities: string[] | null,
options?: {
stateOnly?: boolean;
},
): boolean {
return !!getHassDifferences(newHass, oldHass, entities, {
...options,
firstOnly: true,
}).length;
} }
/** /**
@@ -254,3 +296,29 @@ export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
} }
return false; return false;
}; };
/**
* Determine if a given state qualifies as 'triggered'.
* @param state The HASSEntity.
* @returns `true` if triggered, `false` otherwise.
*/
export const isTriggeredState = (state?: HassEntity): boolean => {
return !!state && ['on', 'open'].includes(state.state);
};
/**
* Get entities from the HASS object.
* @param hass
* @param domain
* @returns
*/
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
if (!hass) {
return [];
}
const entities = Object.keys(hass.states).filter(
(eid) => !domain || eid.substr(0, eid.indexOf('.')) === domain,
);
entities.sort();
return entities;
}
@@ -1,11 +1,11 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import QuickLRU from 'quick-lru'; import QuickLRU from 'quick-lru';
import { homeAssistantWSRequest } from '.';
import { import {
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
ResolvedMedia, ResolvedMedia,
resolvedMediaSchema resolvedMediaSchema
} from '../types.js'; } from '../../types.js';
import { homeAssistantWSRequest } from './ha';
// It's important the cache size be at least as large as the largest likely // It's important the cache size be at least as large as the largest likely
// media query or media items will from a given query will be evicted for other // media query or media items will from a given query will be evicted for other
@@ -21,19 +21,42 @@ export class ResolvedMediaCache {
this._cache = new QuickLRU({ maxSize: RESOLVED_MEDIA_CACHE_SIZE }); this._cache = new QuickLRU({ maxSize: RESOLVED_MEDIA_CACHE_SIZE });
} }
/**
* Determine if the cache has a given id.
* @param id
* @returns `true` if the id is in the cache, `false` otherwise.
*/
public has(id: string): boolean { public has(id: string): boolean {
return this._cache.has(id); return this._cache.has(id);
} }
/**
* Get resolved media information given an id.
* @param id The id.
* @returns The `ResolvedMedia` for this id.
*/
public get(id: string): ResolvedMedia | undefined { public get(id: string): ResolvedMedia | undefined {
return this._cache.get(id); return this._cache.get(id);
} }
/**
* Add a given ResolvedMedia to the cache.
* @param id The id for the object.
* @param resolvedMedia The `ResolvedMedia` object.
*/
public set(id: string, resolvedMedia: ResolvedMedia): void { public set(id: string, resolvedMedia: ResolvedMedia): void {
this._cache.set(id, resolvedMedia); this._cache.set(id, resolvedMedia);
} }
} }
/**
* Resolve a given media source item.
* @param hass The Home Assistant object.
* @param mediaSource The media source object.
* @param cache An optional ResolvedMediaCache object.
* @returns
*/
export const resolveMedia = async ( export const resolveMedia = async (
hass: HomeAssistant, hass: HomeAssistant,
mediaSource?: FrigateBrowseMediaSource, mediaSource?: FrigateBrowseMediaSource,