First skeleton of scan mode.

This commit is contained in:
Dermot Duffy
2022-05-18 21:24:17 -07:00
parent a4a7c0d738
commit ab3202c9ac
6 changed files with 228 additions and 37 deletions
+113 -6
View File
@@ -79,9 +79,11 @@ 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';
@@ -169,6 +171,9 @@ export class FrigateCard extends LitElement {
// Automated refreshes of the default view.
protected _updateTimerID: number | null = null;
// Untrigger timer.
protected _untriggerTimerID: number | null = null;
// Information about the most recently loaded media item.
protected _mediaShowInfo: MediaShowInfo | null = null;
@@ -191,6 +196,10 @@ export class FrigateCard extends LitElement {
// Whether the card has been successfully initialized.
protected _initialized = false;
@state()
protected _triggered: Date | null = null;
protected _triggers: Map<string, Date> = new Map();
/**
* Set the Home Assistant object.
*/
@@ -488,7 +497,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;
}
@@ -775,6 +783,7 @@ export class FrigateCard extends LitElement {
this._message = null;
this._generateConditionState();
this._setLightOrDarkMode();
this._triggered = null;
}
/**
@@ -850,6 +859,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 +869,96 @@ export class FrigateCard extends LitElement {
}
}
/**
* 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 untriggerCard = true;
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]),
);
const priorTrigger = this._triggers.get(camera);
if (shouldTrigger) {
if (
!priorTrigger ||
(now.getTime() - priorTrigger.getTime()) / 1000 >
this._getConfig().view.scan.trigger_min_seconds
) {
this._clearUntriggerTimer();
this._triggers.set(camera, new Date());
if (this._isAutomatedViewUpdateAllowed()) {
if (!changedCamera) {
this._changeView({ view: this._view.evolve({ camera: camera }) });
changedCamera = true;
if (!this._triggered) {
this._triggered = now;
}
}
}
}
}
untriggerCard &&= shouldUntrigger;
}
if (this._triggered && untriggerCard && !this._untriggerTimerID) {
this._untriggerTimerID = window.setInterval(
this._untriggerTimerHandler.bind(this),
Math.max(
0,
this._getConfig().view.scan.trigger_min_seconds * 1000 -
(now.getTime() - this._triggered.getTime()),
),
);
}
return changedCamera;
}
/**
* Reset the untrigger timer.
*/
protected _clearUntriggerTimer() {
if (this._untriggerTimerID) {
window.clearTimeout(this._untriggerTimerID);
this._untriggerTimerID = null;
}
}
/**
* Untrigger the card.
*/
protected _untrigger(): void {
this._clearUntriggerTimer();
this._triggered = null;
}
/**
* Handler for the untrigger timer.
*/
protected _untriggerTimerHandler(): void {
this._untrigger();
// Change back to the default view if the untrigger is
// timer-based/automated.
this._changeView();
}
/**
* Determine whether the element should be updated.
* @param changedProps The changed properties if any.
@@ -875,9 +975,11 @@ export class FrigateCard extends LitElement {
// 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 (
if (this._updateTriggeredCameras(oldHass)) {
shouldUpdate ||= true;
} else if (
this._isAutomatedViewUpdateAllowed() &&
shouldUpdateBasedOnHass(
isHassDifferent(
this._hass,
oldHass,
this._getConfig().view.update_entities || [],
@@ -889,7 +991,7 @@ export class FrigateCard extends LitElement {
this._changeView();
shouldUpdate ||= true;
} else {
shouldUpdate ||= shouldUpdateBasedOnHass(
shouldUpdate ||= isHassDifferent(
this._hass,
oldHass,
this._getConfig().view.render_entities || [],
@@ -1168,6 +1270,10 @@ 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();
@@ -1189,7 +1295,7 @@ export class FrigateCard extends LitElement {
}
if (this._getConfig().view.update_seconds) {
this._updateTimerID = window.setTimeout(() => {
if (this._isAutomatedViewUpdateAllowed()) {
if (!this._triggered && this._isAutomatedViewUpdateAllowed()) {
this._changeView();
} else {
// Not allowed to update this time around, but try again at the next
@@ -1391,6 +1497,7 @@ export class FrigateCard extends LitElement {
const contentClasses = {
'frigate-card-contents': true,
absolute: padding != null,
triggered: !!this._triggered && this._getConfig().view.scan.trigger_show_border,
};
const actions = this._getMergedActions();
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -18,7 +18,7 @@ import {
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]))
);
}
+18
View File
@@ -33,11 +33,29 @@
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;
}
.frigate-card-contents.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;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.frigate-card-contents::-webkit-scrollbar {
+14
View File
@@ -394,6 +394,10 @@ const cameraConfigSchema = z
// Set of cameras IDs upon which this camera depends.
dependent_cameras: z.string().array().optional(),
trigger_by_motion: z.boolean().optional(),
trigger_by_occupancy: z.boolean().optional(),
trigger_by_entities: z.string().array().optional(),
})
.default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
@@ -498,6 +502,11 @@ const viewConfigDefault = {
update_force: false,
update_cycle_camera: false,
dark_mode: 'off' as const,
scan: {
enabled: false,
trigger_min_seconds: 20,
trigger_show_border: true,
}
};
const viewConfigSchema = z
.object({
@@ -514,6 +523,11 @@ 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),
trigger_min_seconds: z.number().default(viewConfigDefault.scan.trigger_min_seconds),
trigger_show_border: z.boolean().default(viewConfigDefault.scan.trigger_show_border),
}).default(viewConfigDefault.scan)
})
.merge(actionsSchema)
.default(viewConfigDefault);
+65 -13
View File
@@ -75,32 +75,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 = oldHass?.states[entity];
const newState = 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 +297,12 @@ 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);
};