Merge remote-tracking branch 'upstream/release-4.0.0' into switch-to-live
This commit is contained in:
@@ -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. |
|
||||
| `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.|
|
||||
| `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>
|
||||
|
||||
@@ -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. |
|
||||
| `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_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. |
|
||||
| `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.|
|
||||
|
||||
<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
|
||||
|
||||
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">
|
||||
|
||||
### 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
|
||||
|
||||
### Illustrative Expanded Configuration Reference
|
||||
@@ -921,6 +984,10 @@ cameras:
|
||||
# Show events for camera-2 when this camera is viewed.
|
||||
dependent_cameras:
|
||||
- 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
|
||||
client_id: frigate-other
|
||||
camera_name: entrance
|
||||
@@ -935,6 +1002,10 @@ cameras:
|
||||
webrtc_card:
|
||||
entity: camera.entrance_rtsp
|
||||
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>
|
||||
|
||||
@@ -958,6 +1029,9 @@ view:
|
||||
render_entities:
|
||||
- switch.render_card
|
||||
dark_mode: 'off'
|
||||
scan:
|
||||
enabled: false
|
||||
show_trigger_status: true
|
||||
actions:
|
||||
entity: light.office_main_lights
|
||||
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
|
||||
change view but without `update_entities` would just be having an
|
||||
`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
|
||||
view:
|
||||
@@ -2365,6 +2439,30 @@ cameras:
|
||||
```
|
||||
</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>
|
||||
|
||||
## Card Refreshes
|
||||
@@ -2376,7 +2474,11 @@ The following table describes the behavior these flags have.
|
||||
|
||||
### 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)* | *(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
|
||||
snapshot respectively) and having the card automatically refresh (to fetch a
|
||||
newer clip/snapshot) when an entity state changes. Use the Frigate
|
||||
binary_sensor for that camera (or any other entity at your discretion) to
|
||||
trigger the update:
|
||||
newer clip/snapshot) on motion.
|
||||
```yaml
|
||||
view:
|
||||
update_entities:
|
||||
- binary_sensor.office_person_motion
|
||||
cameras:
|
||||
- entity: camera.office
|
||||
trigger_by_motion: true
|
||||
```
|
||||
* Cycle the live view of the camera every 60 seconds
|
||||
```yaml
|
||||
@@ -2422,6 +2522,33 @@ view:
|
||||
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
|
||||
|
||||
<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
@@ -48,23 +48,21 @@ import './patches/ha-camera-stream.js';
|
||||
import './patches/ha-hls-player.js';
|
||||
import './patches/ha-web-rtc-player.ts';
|
||||
import cardStyle from './scss/card.scss';
|
||||
import type {
|
||||
Entity,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardConfig,
|
||||
MediaShowInfo,
|
||||
MenuButton,
|
||||
Message,
|
||||
} from './types.js';
|
||||
import {
|
||||
Actions,
|
||||
ActionType,
|
||||
CameraConfig,
|
||||
entitySchema,
|
||||
EntityList,
|
||||
ExtendedEntity,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardConfig,
|
||||
frigateCardConfigSchema,
|
||||
FrigateCardCustomAction,
|
||||
FrigateCardView,
|
||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
MediaShowInfo,
|
||||
MenuButton,
|
||||
Message,
|
||||
RawFrigateCardConfig,
|
||||
} from './types.js';
|
||||
import {
|
||||
@@ -79,15 +77,22 @@ import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js';
|
||||
import {
|
||||
getEntityIcon,
|
||||
getEntityTitle,
|
||||
getHassDifferences,
|
||||
homeAssistantSignPath,
|
||||
homeAssistantWSRequest,
|
||||
shouldUpdateBasedOnHass,
|
||||
isHassDifferent,
|
||||
isTriggeredState,
|
||||
sideLoadHomeAssistantElements,
|
||||
} from './utils/ha';
|
||||
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 { isValidMediaShowInfo } from './utils/media-info.js';
|
||||
import { ResolvedMediaCache } from './utils/resolved-media.js';
|
||||
import { View } from './view.js';
|
||||
|
||||
/** A note on media callbacks:
|
||||
@@ -191,6 +196,8 @@ export class FrigateCard extends LitElement {
|
||||
// Whether the card has been successfully initialized.
|
||||
protected _initialized = false;
|
||||
|
||||
protected _triggers: Map<string, Date> = new Map();
|
||||
|
||||
/**
|
||||
* Set the Home Assistant object.
|
||||
*/
|
||||
@@ -488,7 +495,6 @@ export class FrigateCard extends LitElement {
|
||||
const isValidMediaPlayer = (entity: string): boolean => {
|
||||
if (entity.startsWith('media_player.')) {
|
||||
const stateObj = this._hass?.states[entity];
|
||||
|
||||
if (stateObj && supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA)) {
|
||||
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.
|
||||
*/
|
||||
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();
|
||||
let errorFree = true;
|
||||
|
||||
const addCameraConfig = async (config: CameraConfig) => {
|
||||
if (!config.camera_name && config.camera_entity) {
|
||||
const resolvedName = await this._getFrigateCameraNameFromEntity(
|
||||
config.camera_entity,
|
||||
);
|
||||
if (!this._hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
if (!id) {
|
||||
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.
|
||||
*/
|
||||
protected async _getFrigateCameraNameFromEntity(
|
||||
entity: string,
|
||||
): Promise<string | null> {
|
||||
if (!this._hass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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'];
|
||||
}
|
||||
protected _getFrigateCameraNameFromEntity(entity: ExtendedEntity): string | null {
|
||||
if (entity.unique_id && entity.platform === 'frigate') {
|
||||
const match = entity.unique_id.match(/:camera:(?<camera>[^:]+)$/);
|
||||
if (match && match.groups) {
|
||||
return match.groups['camera'];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// Pass.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -775,6 +873,7 @@ export class FrigateCard extends LitElement {
|
||||
this._message = null;
|
||||
this._generateConditionState();
|
||||
this._setLightOrDarkMode();
|
||||
this._untrigger();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -850,6 +949,7 @@ export class FrigateCard extends LitElement {
|
||||
* Called before each update.
|
||||
*/
|
||||
protected willUpdate(): void {
|
||||
// Side load the necessary elements if not already initialized.
|
||||
if (!this._initialized) {
|
||||
sideLoadHomeAssistantElements().then((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.
|
||||
* @param changedProps The changed properties if any.
|
||||
@@ -869,19 +1036,21 @@ export class FrigateCard extends LitElement {
|
||||
let shouldUpdate = !oldHass || changedProps.size != 1;
|
||||
|
||||
if (oldHass) {
|
||||
// Home Assistant pumps a lot of updates through. Re-rendering the card is
|
||||
// necessary at times (e.g. to update the 'clip' view as new clips
|
||||
// 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.
|
||||
if (
|
||||
const selectedCamera = this._getSelectedCameraConfig();
|
||||
if (this._getConfig().view.scan.enabled && this._updateTriggeredCameras(oldHass)) {
|
||||
shouldUpdate ||= true;
|
||||
} else if (
|
||||
// Home Assistant pumps a lot of updates through. Re-rendering the card is
|
||||
// necessary at times (e.g. to update the 'clip' view as new clips
|
||||
// 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() &&
|
||||
shouldUpdateBasedOnHass(
|
||||
this._hass,
|
||||
oldHass,
|
||||
this._getConfig().view.update_entities || [],
|
||||
)
|
||||
isHassDifferent(this._hass, oldHass, [
|
||||
...(this._getConfig().view.update_entities || []),
|
||||
...(selectedCamera?.trigger_by_entities || []),
|
||||
])
|
||||
) {
|
||||
// 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
|
||||
@@ -889,7 +1058,7 @@ export class FrigateCard extends LitElement {
|
||||
this._changeView();
|
||||
shouldUpdate ||= true;
|
||||
} else {
|
||||
shouldUpdate ||= shouldUpdateBasedOnHass(
|
||||
shouldUpdate ||= isHassDifferent(
|
||||
this._hass,
|
||||
oldHass,
|
||||
this._getConfig().view.render_entities || [],
|
||||
@@ -1177,11 +1346,17 @@ export class FrigateCard extends LitElement {
|
||||
*/
|
||||
protected _startInteractionTimer(): void {
|
||||
this._clearInteractionTimer();
|
||||
|
||||
// Interactions reset the trigger state.
|
||||
this._untrigger();
|
||||
|
||||
if (this._getConfig().view.timeout_seconds) {
|
||||
this._interactionTimerID = window.setTimeout(() => {
|
||||
this._changeView();
|
||||
this._clearInteractionTimer();
|
||||
this._setLightOrDarkMode();
|
||||
if (this._isAutomatedViewUpdateAllowed()) {
|
||||
this._changeView();
|
||||
this._setLightOrDarkMode();
|
||||
}
|
||||
}, this._getConfig().view.timeout_seconds * 1000);
|
||||
}
|
||||
this._setLightOrDarkMode();
|
||||
@@ -1213,8 +1388,11 @@ export class FrigateCard extends LitElement {
|
||||
* Determine if an automated view update is allowed.
|
||||
* @returns `true` if it's allowed, `false` otherwise.
|
||||
*/
|
||||
protected _isAutomatedViewUpdateAllowed(): boolean {
|
||||
return this._getConfig().view.update_force || !this._interactionTimerID;
|
||||
protected _isAutomatedViewUpdateAllowed(ignoreTriggers?: boolean): boolean {
|
||||
return (
|
||||
(ignoreTriggers || !this._triggers.size) &&
|
||||
(this._getConfig().view.update_force || !this._interactionTimerID)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1342,21 +1520,21 @@ export class FrigateCard extends LitElement {
|
||||
* required).
|
||||
* @returns A padding percentage.
|
||||
*/
|
||||
protected _getAspectRatioPadding(): number | null {
|
||||
protected _getAspectRatioStyle(): string {
|
||||
if (!this._isAspectRatioEnforced()) {
|
||||
return null;
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
const aspectRatioMode = this._getConfig().dimensions.aspect_ratio_mode;
|
||||
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;
|
||||
if (defaultAspectRatio) {
|
||||
return (defaultAspectRatio[1] / defaultAspectRatio[0]) * 100;
|
||||
return `${defaultAspectRatio[0]} / ${defaultAspectRatio[1]}`;
|
||||
} else {
|
||||
return (9 / 16) * 100;
|
||||
return '16 / 9';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,17 +1567,13 @@ export class FrigateCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const padding = this._getAspectRatioPadding();
|
||||
const outerStyle = {};
|
||||
|
||||
// Padding to force a particular aspect ratio.
|
||||
if (padding != null) {
|
||||
outerStyle['padding-top'] = `${padding}%`;
|
||||
}
|
||||
|
||||
const contentClasses = {
|
||||
'frigate-card-contents': true,
|
||||
absolute: padding != null,
|
||||
const cardStyle = {
|
||||
'aspect-ratio': this._getAspectRatioStyle(),
|
||||
};
|
||||
const mainClasses = {
|
||||
main: true,
|
||||
triggered:
|
||||
!!this._triggers.size && this._getConfig().view.scan.show_trigger_status,
|
||||
};
|
||||
|
||||
const actions = this._getMergedActions();
|
||||
@@ -1412,6 +1586,7 @@ export class FrigateCard extends LitElement {
|
||||
hasHold: frigateCardHasAction(actions.hold_action),
|
||||
hasDoubleClick: frigateCardHasAction(actions.double_tap_action),
|
||||
})}
|
||||
style="${styleMap(cardStyle)}"
|
||||
@action=${(ev: CustomEvent) => this._actionHandler(ev, actions)}
|
||||
@ll-custom=${this._cardActionHandler.bind(this)}
|
||||
@frigate-card:message=${this._messageHandler}
|
||||
@@ -1420,49 +1595,47 @@ export class FrigateCard extends LitElement {
|
||||
@frigate-card:render=${() => this.requestUpdate()}
|
||||
>
|
||||
${renderMenuAbove ? this._renderMenu() : ''}
|
||||
<div class="container outer" style="${styleMap(outerStyle)}">
|
||||
<div class="${classMap(contentClasses)}">
|
||||
${this._cameras === undefined
|
||||
? until(
|
||||
(async () => {
|
||||
await this._loadCameras();
|
||||
// Don't reset messages as errors may have been generated
|
||||
// during the camera load.
|
||||
this._changeView({ resetMessage: false });
|
||||
return this._render();
|
||||
})(),
|
||||
renderProgressIndicator(),
|
||||
)
|
||||
: // Always want to call render even if there's a message, to
|
||||
// ensure live preload is always present (even if not displayed).
|
||||
this._render()}
|
||||
${this._getConfig().elements
|
||||
? // Always show elements to allow for custom menu items (etc.) to
|
||||
// be present even if a particular view has an error. Elements
|
||||
// need to render after the main views so it can render 'on top'.
|
||||
html` <frigate-card-elements
|
||||
${ref(this._refElements)}
|
||||
.hass=${this._hass}
|
||||
.elements=${this._getConfig().elements}
|
||||
.conditionState=${this._conditionState}
|
||||
@frigate-card:menu-add=${(e) => {
|
||||
this._addDynamicMenuButton(e.detail);
|
||||
}}
|
||||
@frigate-card:menu-remove=${(e) => {
|
||||
this._removeDynamicMenuButton(e.detail);
|
||||
}}
|
||||
@frigate-card:condition-state-request=${(ev) => {
|
||||
conditionStateRequestHandler(ev, this._conditionState);
|
||||
}}
|
||||
>
|
||||
</frigate-card-elements>`
|
||||
: ``}
|
||||
${
|
||||
// Keep message rendering to last to show messages that may have
|
||||
// been generated during the render.
|
||||
this._message ? renderMessage(this._message) : ''
|
||||
}
|
||||
</div>
|
||||
<div class="${classMap(mainClasses)}">
|
||||
${this._cameras === undefined
|
||||
? until(
|
||||
(async () => {
|
||||
await this._loadCameras();
|
||||
// Don't reset messages as errors may have been generated
|
||||
// during the camera load.
|
||||
this._changeView({ resetMessage: false });
|
||||
return this._render();
|
||||
})(),
|
||||
renderProgressIndicator(),
|
||||
)
|
||||
: // Always want to call render even if there's a message, to
|
||||
// ensure live preload is always present (even if not displayed).
|
||||
this._render()}
|
||||
${this._getConfig().elements
|
||||
? // Always show elements to allow for custom menu items (etc.) to
|
||||
// be present even if a particular view has an error. Elements
|
||||
// need to render after the main views so it can render 'on top'.
|
||||
html` <frigate-card-elements
|
||||
${ref(this._refElements)}
|
||||
.hass=${this._hass}
|
||||
.elements=${this._getConfig().elements}
|
||||
.conditionState=${this._conditionState}
|
||||
@frigate-card:menu-add=${(e) => {
|
||||
this._addDynamicMenuButton(e.detail);
|
||||
}}
|
||||
@frigate-card:menu-remove=${(e) => {
|
||||
this._removeDynamicMenuButton(e.detail);
|
||||
}}
|
||||
@frigate-card:condition-state-request=${(ev) => {
|
||||
conditionStateRequestHandler(ev, this._conditionState);
|
||||
}}
|
||||
>
|
||||
</frigate-card-elements>`
|
||||
: ``}
|
||||
${
|
||||
// Keep message rendering to last to show messages that may have
|
||||
// been generated during the render.
|
||||
this._message ? renderMessage(this._message) : ''
|
||||
}
|
||||
</div>
|
||||
${!renderMenuAbove ? this._renderMenu() : ''}
|
||||
</ha-card>`;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.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 imageStyle from '../scss/image.scss';
|
||||
import { CameraConfig, ImageViewConfig } from '../types.js';
|
||||
import { shouldUpdateBasedOnHass } from '../utils/ha';
|
||||
import { isHassDifferent } from '../utils/ha';
|
||||
import { dispatchMediaShowEvent } from '../utils/media-info.js';
|
||||
import { View } from '../view.js';
|
||||
import { dispatchErrorMessageEvent } from './message.js';
|
||||
@@ -96,7 +96,7 @@ export class FrigateCardImage extends LitElement {
|
||||
this._imageConfig?.mode === 'camera' &&
|
||||
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
|
||||
// value (will be re-calculated in willUpdate). This is important to
|
||||
// ensure a changed access token is immediately used.
|
||||
|
||||
@@ -1090,7 +1090,8 @@ export class FrigateCardLiveJSMPEG extends LitElement {
|
||||
if (!this.cameraConfig?.camera_name) {
|
||||
return dispatchErrorMessageEvent(
|
||||
this,
|
||||
localize('error.no_camera_name') + `: ${JSON.stringify(this.cameraConfig)}`,
|
||||
localize('error.no_camera_name'),
|
||||
this.cameraConfig,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -1,12 +1,12 @@
|
||||
import type { Corner } from '@material/mwc-menu';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.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 { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../types.js';
|
||||
import {
|
||||
frigateCardHasAction,
|
||||
stopEventFromActivatingCardWideActions
|
||||
frigateCardHasAction,
|
||||
stopEventFromActivatingCardWideActions
|
||||
} from '../utils/action.js';
|
||||
import { refreshDynamicStateParameters, shouldUpdateBasedOnHass } from '../utils/ha';
|
||||
import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha';
|
||||
import { domainIcon } from '../utils/icons/domain-icon.js';
|
||||
|
||||
@customElement('frigate-card-submenu')
|
||||
@@ -139,7 +139,7 @@ export class FrigateCardSubmenuSelect extends LitElement {
|
||||
changedProps.size != 1 ||
|
||||
!this.submenuSelect ||
|
||||
(!!oldHass &&
|
||||
shouldUpdateBasedOnHass(this.hass, oldHass, [this.submenuSelect.entity]))
|
||||
isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity]))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
overrideMultiBrowseMediaQueryParameters
|
||||
} from '../utils/ha/browse-media.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 { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
|
||||
|
||||
+11
-1
@@ -23,6 +23,12 @@ export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER =
|
||||
`${CONF_CAMERAS}.#.live_provider` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS =
|
||||
`${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_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_ENTITIES = `${CONF_VIEW}.update_entities` 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_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||
@@ -142,4 +152,4 @@ export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
|
||||
export const CONF_OVERRIDES = 'overrides' as const;
|
||||
|
||||
// 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
@@ -21,6 +21,9 @@ import {
|
||||
CONF_CAMERAS_ARRAY_LABEL,
|
||||
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
|
||||
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_WEBRTC_CARD_ENTITY,
|
||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
||||
@@ -81,6 +84,9 @@ import {
|
||||
CONF_VIEW_CAMERA_SELECT,
|
||||
CONF_VIEW_DARK_MODE,
|
||||
CONF_VIEW_DEFAULT,
|
||||
CONF_VIEW_SCAN,
|
||||
CONF_VIEW_SCAN_ENABLED,
|
||||
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
|
||||
CONF_VIEW_TIMEOUT_SECONDS,
|
||||
CONF_VIEW_UPDATE_CYCLE_CAMERA,
|
||||
CONF_VIEW_UPDATE_FORCE,
|
||||
@@ -99,11 +105,12 @@ import {
|
||||
} from './types.js';
|
||||
import { arrayMove } from './utils/basic.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_CAMERAS = 'cameras';
|
||||
const MENU_OPTIONS = 'options';
|
||||
const MENU_VIEW_SCAN = 'scan';
|
||||
|
||||
interface EditorOptionsSet {
|
||||
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
|
||||
* @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.
|
||||
* @param button The name of the button.
|
||||
@@ -700,6 +725,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
protected _renderCamera(
|
||||
cameras: RawFrigateCardConfigArray,
|
||||
cameraIndex: number,
|
||||
entities: string[],
|
||||
addNewCamera?: boolean,
|
||||
): TemplateResult | void {
|
||||
const liveProviders: EditorSelectOption[] = [
|
||||
@@ -848,6 +874,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
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>`
|
||||
: ``}
|
||||
`;
|
||||
@@ -935,7 +976,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
}
|
||||
|
||||
const defaults = frigateCardConfigDefaults;
|
||||
|
||||
const entities = getEntitiesFromHASS(this.hass);
|
||||
const cameras = (getConfigValue(this._config, CONF_CAMERAS) ||
|
||||
[]) as RawFrigateCardConfigArray;
|
||||
|
||||
@@ -964,8 +1005,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderOptionSetHeader('cameras')}
|
||||
${this._expandedMenus[MENU_OPTIONS] === 'cameras'
|
||||
? html` <div class="submenu">
|
||||
${cameras.map((_, index) => this._renderCamera(cameras, index))}
|
||||
${this._renderCamera(cameras, cameras.length, true)}
|
||||
${cameras.map((_, index) => this._renderCamera(cameras, index, entities))}
|
||||
${this._renderCamera(cameras, cameras.length, entities, true)}
|
||||
</div>`
|
||||
: ''}
|
||||
${this._renderOptionSetHeader('view')}
|
||||
@@ -985,6 +1026,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_VIEW_UPDATE_CYCLE_CAMERA,
|
||||
defaults.view.update_cycle_camera,
|
||||
)}
|
||||
${this._renderViewScanMenu()}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
"frigate-jsmpeg": "Frigate JSMpeg",
|
||||
"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": {
|
||||
"entity": "WebRTC Card Camera Entity (Not a Frigate camera)",
|
||||
"url": "WebRTC Card Camera URL"
|
||||
@@ -65,7 +68,12 @@
|
||||
"timeout_seconds": "Reset to default view X seconds after user action (0=never)",
|
||||
"update_cycle_camera": "Cycle through cameras when default view updates",
|
||||
"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": {
|
||||
"controls": {
|
||||
|
||||
+21
-21
@@ -9,48 +9,48 @@
|
||||
filter: brightness(50%);
|
||||
}
|
||||
|
||||
.container {
|
||||
div.main {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// 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
|
||||
// boundary.
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
}
|
||||
|
||||
.frigate-card-contents {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
margin: auto;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// Include the borders in the sizing (to keep the triggered warning pulse
|
||||
// visible in fullscreen).
|
||||
box-sizing: border-box;
|
||||
|
||||
// Hide scrollbar: Firefox
|
||||
scrollbar-width: none;
|
||||
// Hide scrollbar: IE and Edge
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.frigate-card-contents::-webkit-scrollbar {
|
||||
display: none;
|
||||
div.main.triggered {
|
||||
@keyframes warning-pulse {
|
||||
0% {
|
||||
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 */
|
||||
.frigate-card-contents.absolute {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
div.main::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The 'hover' menu mode is styling applied outside of the menu itself */
|
||||
|
||||
@@ -42,7 +42,7 @@ div.upgrade span {
|
||||
|
||||
.submenu-header {
|
||||
display: flex;
|
||||
margin-top: 4px;
|
||||
margin-top: 10px;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
+27
-2
@@ -364,6 +364,9 @@ const customSchema = z
|
||||
export const cameraConfigDefault = {
|
||||
client_id: 'frigate' as const,
|
||||
live_provider: 'auto' as const,
|
||||
trigger_by_motion: false,
|
||||
trigger_by_occupancy: true,
|
||||
trigger_by_entities: [],
|
||||
};
|
||||
const webrtcCardCameraConfigSchema = z.object({
|
||||
entity: z.string().optional(),
|
||||
@@ -394,6 +397,10 @@ const cameraConfigSchema = z
|
||||
|
||||
// Set of cameras IDs upon which this camera depends.
|
||||
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);
|
||||
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
||||
@@ -498,6 +505,10 @@ const viewConfigDefault = {
|
||||
update_force: false,
|
||||
update_cycle_camera: false,
|
||||
dark_mode: 'off' as const,
|
||||
scan: {
|
||||
enabled: false,
|
||||
show_trigger_status: true,
|
||||
}
|
||||
};
|
||||
const viewConfigSchema = z
|
||||
.object({
|
||||
@@ -514,6 +525,10 @@ const viewConfigSchema = z
|
||||
update_entities: z.string().array().optional(),
|
||||
render_entities: z.string().array().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)
|
||||
.default(viewConfigDefault);
|
||||
@@ -1221,7 +1236,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.
|
||||
event: z.object({
|
||||
camera: z.string(),
|
||||
end_time: z.number().nullable(),
|
||||
false_positive: z.boolean(),
|
||||
false_positive: z.boolean().nullable(),
|
||||
has_clip: z.boolean(),
|
||||
has_snapshot: z.boolean(),
|
||||
id: z.string(),
|
||||
@@ -1249,8 +1264,18 @@ export const signedPathSchema = z.object({
|
||||
export type SignedPath = z.infer<typeof signedPathSchema>;
|
||||
|
||||
export const entitySchema = z.object({
|
||||
config_entry_id: z.string().nullable(),
|
||||
disabled_by: z.string().nullable(),
|
||||
entity_id: z.string(),
|
||||
unique_id: z.string(),
|
||||
platform: z.string(),
|
||||
});
|
||||
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>;
|
||||
|
||||
@@ -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
@@ -4,11 +4,10 @@ import { StyleInfo } from 'lit/directives/style-map.js';
|
||||
import { ZodSchema } from 'zod';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import {
|
||||
CardHelpers,
|
||||
ExtendedHomeAssistant,
|
||||
SignedPath,
|
||||
signedPathSchema,
|
||||
StateParameters
|
||||
CardHelpers, ExtendedHomeAssistant,
|
||||
SignedPath,
|
||||
signedPathSchema,
|
||||
StateParameters
|
||||
} from '../../types.js';
|
||||
import { stateIcon } from '../icons/state-icon.js';
|
||||
import { getParseErrorKeys } from '../zod.js';
|
||||
@@ -75,32 +74,75 @@ export async function homeAssistantSignPath(
|
||||
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 oldHass The old HA object.
|
||||
* @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,
|
||||
oldHass: HomeAssistant | undefined | null,
|
||||
entities: string[] | null,
|
||||
): boolean {
|
||||
options?: {
|
||||
firstOnly?: boolean;
|
||||
stateOnly?: boolean;
|
||||
},
|
||||
): HassStateDifference[] {
|
||||
if (!newHass || !entities || !entities.length) {
|
||||
return false;
|
||||
}
|
||||
if (!oldHass) {
|
||||
return true;
|
||||
return [];
|
||||
}
|
||||
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const entity = entities[i];
|
||||
if (entity && oldHass.states[entity] !== newHass.states[entity]) {
|
||||
return true;
|
||||
const differences: HassStateDifference[] = [];
|
||||
for (const entity of entities) {
|
||||
const oldState: HassEntity | undefined = oldHass?.states[entity];
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 QuickLRU from 'quick-lru';
|
||||
import { homeAssistantWSRequest } from '.';
|
||||
import {
|
||||
FrigateBrowseMediaSource,
|
||||
ResolvedMedia,
|
||||
resolvedMediaSchema
|
||||
} from '../types.js';
|
||||
import { homeAssistantWSRequest } from './ha';
|
||||
FrigateBrowseMediaSource,
|
||||
ResolvedMedia,
|
||||
resolvedMediaSchema
|
||||
} from '../../types.js';
|
||||
|
||||
// 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
|
||||
@@ -21,19 +21,42 @@ export class ResolvedMediaCache {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 (
|
||||
hass: HomeAssistant,
|
||||
mediaSource?: FrigateBrowseMediaSource,
|
||||
Reference in New Issue
Block a user