Merge remote-tracking branch 'upstream/release-4.0.0' into switch-to-live
This commit is contained in:
+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>`;
|
||||
|
||||
Reference in New Issue
Block a user