Merge pull request #1414 from dermotduffy/capabilities
Allow camera capabilities to be configured
This commit is contained in:
@@ -138,7 +138,6 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
|
||||
| `live_provider` | `auto` | :white_check_mark: | The choice of live stream provider. See [Live Providers](#live-providers) below.|
|
||||
| `title` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | A friendly name for this camera to use in the card. |
|
||||
| `icon` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. |
|
||||
| `hide` | `false` | :white_check_mark: | Whether or not to hide this as an independent camera (e.g. hidden on the live carousel, media filter, camera menu, and triggers cannot trigger this camera). This may be useful if this camera is exclusively used as a dependency of another camera. |
|
||||
| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | :white_check_mark: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). |
|
||||
| `engine` | `auto` | :white_check_mark: | Which camera engine to use for this camera. If `auto` the card will attempt to choose the correct engine from the specified options. See [engines](#engines) below for valid options.|
|
||||
| `frigate` | | :white_check_mark: | Options for a Frigate camera. See [Frigate configuration](#camera-frigate-configuration) below. |
|
||||
@@ -147,6 +146,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
|
||||
| `webrtc_card` | | :white_check_mark: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. |
|
||||
| `cast` | | :white_check_mark: | Configuration that controls how this camera is "casted" / sent to media players. See below. |
|
||||
| `dimensions` | | :white_check_mark: | Controls the dimensions and layout for media from this camera. See below. |
|
||||
| `capabilities` | | :white_check_mark: | Allows selective disabling of camera capabilities. See below. |
|
||||
|
||||
<a name="live-providers"></a>
|
||||
|
||||
@@ -397,6 +397,35 @@ This block is used to control the fit and position of the media _within_ the cam
|
||||
|
||||
See [media layout examples](#media-layout-examples).
|
||||
|
||||
### Camera Capabilities
|
||||
|
||||
The `capabilities` block allows selected disabling of auto-detected camera capabilities. This is rarely used, with substreams being a notable exception.
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
- capabilities:
|
||||
```
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `disable` | | :white_check_mark: | A list of camera capabilities to disable. By default all capabilities supported by the camera are enabled. |
|
||||
| `disable_except` | | :white_check_mark: | A list of camera capabilities to leave enabled if supported. Everything else will be disabled. |
|
||||
|
||||
#### Capabilities
|
||||
|
||||
| Capability | Purpose |
|
||||
| - | - |
|
||||
| `clips` | Clips can be fetched from the camera. |
|
||||
| `favorite-events` | Events can be favorited. |
|
||||
| `favorite-recordings` | Recordings can be favorited. |
|
||||
| `live` | Live video can be received from the camera. |
|
||||
| `menu` | The camera should show up in the card camera menu. |
|
||||
| `ptz` | The camera can be PTZ controlled. |
|
||||
| `recordings` | Recordings can be fetched from the camera. |
|
||||
| `seek` | Clips can be seeked / scrubbed by the timeline. |
|
||||
| `snapshots` | Snapshots can be fetched from the camera. |
|
||||
| `substream` | The camera can be used as a substream on another camera. |
|
||||
|
||||
<a name="camera-ids"></a>
|
||||
|
||||
#### Camera IDs: Referring to cameras in card configuration
|
||||
@@ -1789,7 +1818,6 @@ cameras:
|
||||
- camera_entity: camera.front_Door
|
||||
live_provider: ha
|
||||
engine: auto
|
||||
hide: false
|
||||
frigate:
|
||||
url: http://my.frigate.local
|
||||
client_id: frigate
|
||||
@@ -1832,8 +1860,6 @@ cameras:
|
||||
title: 'Front entrance'
|
||||
# Custom identifier for the camera to refer to it above.
|
||||
id: 'camera-2'
|
||||
# Don't show this camera on the UI (will only be available as a dependent substream).
|
||||
hide: true
|
||||
webrtc_card:
|
||||
entity: camera.entrance_rtsp
|
||||
url: 'rtsp://username:password@camera:554/av_stream/ch0'
|
||||
@@ -1905,7 +1931,6 @@ Reference: [Cameras Global Options](#camera-global-options).
|
||||
cameras_global:
|
||||
live_provider: ha
|
||||
engine: auto
|
||||
hide: false
|
||||
frigate:
|
||||
url: http://my.frigate.local
|
||||
client_id: frigate
|
||||
@@ -2763,7 +2788,6 @@ overrides:
|
||||
- {} # No overrides for camera index 0.
|
||||
- live_provider: 'ha' # Overrides for camera index 1.
|
||||
engine: auto
|
||||
hide: false
|
||||
frigate:
|
||||
url: http://my.frigate.local
|
||||
client_id: frigate
|
||||
@@ -3598,8 +3622,10 @@ cameras:
|
||||
title: Sitting Room HD
|
||||
live_provider: go2rtc
|
||||
id: sitting_room_hd
|
||||
# Do not show the HD camera independently on the UI.
|
||||
hide: true
|
||||
# This camera serves only as a substream.
|
||||
capabilities:
|
||||
disable_except:
|
||||
- substream
|
||||
menu:
|
||||
buttons:
|
||||
substreams:
|
||||
@@ -3625,13 +3651,17 @@ cameras:
|
||||
- camera_entity: camera.sitting_room
|
||||
live_provider: go2rtc
|
||||
id: sitting_room_go2rtc
|
||||
hide: true
|
||||
capabilities:
|
||||
disable_except:
|
||||
- substream
|
||||
title: Sitting Room go2rtc
|
||||
icon: mdi:alpha-g
|
||||
- camera_entity: camera.sitting_room
|
||||
live_provider: ha
|
||||
id: sitting_room_ha
|
||||
hide: true
|
||||
capabilities:
|
||||
disable_except:
|
||||
- substream
|
||||
title: Sitting Room HA
|
||||
icon: mdi:home
|
||||
```
|
||||
@@ -4151,7 +4181,9 @@ cameras:
|
||||
- camera.office_hd
|
||||
- camera_entity: camera.office_hd
|
||||
live_provider: go2rtc
|
||||
hide: true
|
||||
capabilities:
|
||||
disable_except:
|
||||
- substream
|
||||
automations:
|
||||
- actions:
|
||||
- action: custom:frigate-card-action
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { RequestCache } from '../cache';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { rangesOverlap } from '../range';
|
||||
@@ -142,15 +143,23 @@ export class BrowseMediaCameraManagerEngine
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
const camera = new BrowseMediaCamera(cameraConfig, this, {
|
||||
capabilities: {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: true,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: true,
|
||||
supportsTimeline: true,
|
||||
},
|
||||
capabilities: new Capabilities(
|
||||
{
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
clips: true,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: true,
|
||||
substream: true,
|
||||
},
|
||||
{
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||
},
|
||||
),
|
||||
eventCallback: this._eventCallback,
|
||||
});
|
||||
return await camera.initialize(hass, entityRegistryManager);
|
||||
|
||||
@@ -10,14 +10,15 @@ import {
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraNoIDError } from './error';
|
||||
import { CameraEventCallback, CameraManagerCameraCapabilities } from './types';
|
||||
import { CameraEventCallback } from './types';
|
||||
import { Capabilities } from './capabilities';
|
||||
|
||||
type DestroyCallback = () => Promise<void>;
|
||||
|
||||
export class Camera {
|
||||
protected _config: CameraConfig;
|
||||
protected _engine: CameraManagerEngine;
|
||||
protected _capabilities?: CameraManagerCameraCapabilities;
|
||||
protected _capabilities?: Capabilities;
|
||||
protected _eventCallback?: CameraEventCallback;
|
||||
protected _destroyCallbacks: DestroyCallback[] = [];
|
||||
|
||||
@@ -25,7 +26,7 @@ export class Camera {
|
||||
config: CameraConfig,
|
||||
engine: CameraManagerEngine,
|
||||
options?: {
|
||||
capabilities?: CameraManagerCameraCapabilities;
|
||||
capabilities?: Capabilities;
|
||||
eventCallback?: CameraEventCallback;
|
||||
},
|
||||
) {
|
||||
@@ -97,7 +98,7 @@ export class Camera {
|
||||
return this._engine;
|
||||
}
|
||||
|
||||
public getCapabilities(): CameraManagerCameraCapabilities | null {
|
||||
public getCapabilities(): Capabilities | null {
|
||||
return this._capabilities ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
CapabilitiesRaw,
|
||||
CapabilityKey,
|
||||
capabilityKeys,
|
||||
PTZCapabilities,
|
||||
} from '../types';
|
||||
import { CapabilitySearchOptions } from './types';
|
||||
|
||||
export class Capabilities {
|
||||
private _capabilities: CapabilitiesRaw;
|
||||
|
||||
constructor(
|
||||
capabilities: CapabilitiesRaw,
|
||||
options?: {
|
||||
disable?: CapabilityKey[];
|
||||
disableExcept?: CapabilityKey[];
|
||||
},
|
||||
) {
|
||||
this._capabilities = capabilities;
|
||||
|
||||
for (const key of options?.disable ?? []) {
|
||||
this._disable(key);
|
||||
}
|
||||
for (const key of capabilityKeys) {
|
||||
if (options?.disableExcept?.length && !options.disableExcept.includes(key)) {
|
||||
this._disable(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _disable(capability: CapabilityKey): void {
|
||||
delete this._capabilities[capability];
|
||||
}
|
||||
|
||||
public matches(capability: CapabilitySearchOptions): boolean {
|
||||
let result = true;
|
||||
if (typeof capability === 'string') {
|
||||
result &&= this.has(capability);
|
||||
}
|
||||
if (typeof capability === 'object' && capability.allCapabilities) {
|
||||
result &&= capability.allCapabilities.every((capability) => this.has(capability));
|
||||
}
|
||||
if (typeof capability === 'object' && capability.anyCapabilities) {
|
||||
result &&= capability.anyCapabilities.some((capability) => this.has(capability));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public has(capability: CapabilityKey): boolean {
|
||||
return !!this._capabilities[capability];
|
||||
}
|
||||
|
||||
public getPTZCapabilities(): PTZCapabilities | null {
|
||||
return this._capabilities.ptz ?? null;
|
||||
}
|
||||
|
||||
public getRawCapabilities(): CapabilitiesRaw {
|
||||
return this._capabilities;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cach
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraInitializationError } from './error';
|
||||
import { CameraEventCallback, Engine } from './types';
|
||||
import { getCameraEntityFromConfig } from './utils';
|
||||
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
|
||||
|
||||
export class CameraManagerEngineFactory {
|
||||
protected _entityRegistryManager: EntityRegistryManager;
|
||||
@@ -24,7 +24,10 @@ export class CameraManagerEngineFactory {
|
||||
this._resolvedMediaCache = resolvedMediaCache;
|
||||
}
|
||||
|
||||
public async createEngine(engine: Engine, eventCallback?: CameraEventCallback): Promise<CameraManagerEngine> {
|
||||
public async createEngine(
|
||||
engine: Engine,
|
||||
eventCallback?: CameraEventCallback,
|
||||
): Promise<CameraManagerEngine> {
|
||||
let cameraManagerEngine: CameraManagerEngine;
|
||||
switch (engine) {
|
||||
case Engine.Generic:
|
||||
|
||||
@@ -2,14 +2,15 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import uniq from 'lodash-es/uniq';
|
||||
import { CameraConfig } from '../../config/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { subscribeToTrigger } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
import { Entity } from '../../utils/ha/entity-registry/types';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CameraInitializationError } from '../error';
|
||||
import { PTZCapabilities, PTZMovementType } from '../types';
|
||||
import { getCameraEntityFromConfig } from '../utils.js';
|
||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||
import { getPTZInfo } from './requests';
|
||||
import { PTZInfo, frigateEventChangeTriggerResponseSchema } from './types';
|
||||
|
||||
@@ -98,16 +99,24 @@ export class FrigateCamera extends Camera {
|
||||
const config = this.getConfig();
|
||||
const ptz = await this._getPTZCapabilities(hass, config);
|
||||
const birdseye = isBirdseye(config);
|
||||
this._capabilities = {
|
||||
canFavoriteEvents: !birdseye,
|
||||
canFavoriteRecordings: !birdseye,
|
||||
canSeek: true,
|
||||
supportsClips: !birdseye,
|
||||
supportsSnapshots: !birdseye,
|
||||
supportsRecordings: !birdseye,
|
||||
supportsTimeline: !birdseye,
|
||||
...(ptz && { ptz: ptz }),
|
||||
};
|
||||
this._capabilities = new Capabilities(
|
||||
{
|
||||
'favorite-events': !birdseye,
|
||||
'favorite-recordings': false,
|
||||
seek: !birdseye,
|
||||
clips: !birdseye,
|
||||
snapshots: !birdseye,
|
||||
recordings: !birdseye,
|
||||
live: true,
|
||||
menu: true,
|
||||
substream: true,
|
||||
...(ptz && { ptz: ptz }),
|
||||
},
|
||||
{
|
||||
disable: config.capabilities?.disable,
|
||||
disableExcept: config.capabilities?.disable_except,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
protected _getFrigateCameraNameFromEntity(entity: Entity): string | null {
|
||||
|
||||
@@ -23,8 +23,8 @@ import { ViewMediaClassifier } from '../../view/media-classifier';
|
||||
import { RecordingSegmentsCache, RequestCache } from '../cache';
|
||||
import { Camera } from '../camera';
|
||||
import {
|
||||
CameraManagerEngine,
|
||||
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
CameraManagerEngine,
|
||||
} from '../engine';
|
||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { DateRange } from '../range';
|
||||
@@ -59,18 +59,18 @@ import {
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
} from '../types';
|
||||
import { getDefaultGo2RTCEndpoint } from '../utils.js';
|
||||
import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint';
|
||||
import frigateLogo from './assets/frigate-logo-dark.svg';
|
||||
import { FrigateCamera, isBirdseye } from './camera';
|
||||
import { FrigateViewMediaFactory } from './media';
|
||||
import { FrigateViewMediaClassifier } from './media-classifier';
|
||||
import {
|
||||
getEvents,
|
||||
getEventSummary,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
NativeFrigateEventQuery,
|
||||
NativeFrigateRecordingSegmentsQuery,
|
||||
getEventSummary,
|
||||
getEvents,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
retainEvent,
|
||||
} from './requests';
|
||||
import {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEntityIcon, getEntityTitle } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
@@ -32,7 +33,8 @@ import {
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
} from '../types';
|
||||
import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js';
|
||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||
import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint';
|
||||
|
||||
export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
protected _eventCallback?: CameraEventCallback;
|
||||
@@ -51,15 +53,23 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
return await new Camera(cameraConfig, this, {
|
||||
capabilities: {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
},
|
||||
capabilities: new Capabilities(
|
||||
{
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
clips: false,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
substream: true,
|
||||
},
|
||||
{
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||
},
|
||||
),
|
||||
eventCallback: this._eventCallback,
|
||||
}).initialize(hass, entityRegistryManager);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import { localize } from '../localize/localize.js';
|
||||
import {
|
||||
allPromises,
|
||||
arrayify,
|
||||
isTruthy,
|
||||
recursivelyMergeObjectsNotArrays,
|
||||
setify,
|
||||
} from '../utils/basic.js';
|
||||
import { getCameraID } from '../utils/camera.js';
|
||||
import { log } from '../utils/debug.js';
|
||||
import { ViewMedia } from '../view/media.js';
|
||||
import { Capabilities } from './capabilities.js';
|
||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||
import { CameraManagerEngine } from './engine.js';
|
||||
import { CameraInitializationError } from './error.js';
|
||||
@@ -22,9 +24,7 @@ import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraManagerCameraCapabilities,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
Engine,
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
ResultsMap,
|
||||
} from './types.js';
|
||||
import { sortMedia } from './utils.js';
|
||||
import { sortMedia } from './utils/sort-media.js';
|
||||
|
||||
export class QueryClassifier {
|
||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||
@@ -260,10 +260,6 @@ export class CameraManager {
|
||||
this._store.addCamera(camera);
|
||||
});
|
||||
|
||||
if (!this._store.getVisibleCameraCount()) {
|
||||
throw new CameraInitializationError(localize('error.no_visible_cameras'));
|
||||
}
|
||||
|
||||
log(
|
||||
this._api.getConfigManager().getCardWideConfig(),
|
||||
'Frigate Card CameraManager initialized (Cameras: ',
|
||||
@@ -739,33 +735,29 @@ export class CameraManager {
|
||||
return engine.getCameraMetadata(hass, cameraConfig);
|
||||
}
|
||||
|
||||
public getCameraCapabilities(
|
||||
cameraID: string,
|
||||
): CameraManagerCameraCapabilities | null {
|
||||
public getCameraCapabilities(cameraID: string): Capabilities | null {
|
||||
return this._store.getCamera(cameraID)?.getCapabilities() ?? null;
|
||||
}
|
||||
|
||||
public getAggregateCameraCapabilities(
|
||||
cameraIDs?: Set<string>,
|
||||
): CameraManagerCapabilities | null {
|
||||
const perCameraCapabilities = [...(cameraIDs ?? this._store.getCameraIDs())].map(
|
||||
(cameraID) => this.getCameraCapabilities(cameraID),
|
||||
);
|
||||
public getAggregateCameraCapabilities(cameraIDs?: Set<string>): Capabilities {
|
||||
const cameras = [...(cameraIDs ?? this._store.getCameraIDs())]
|
||||
.map((cameraID) => this._store.getCamera(cameraID))
|
||||
.filter(isTruthy);
|
||||
|
||||
return {
|
||||
canFavoriteEvents: perCameraCapabilities.some((cap) => cap?.canFavoriteEvents),
|
||||
canFavoriteRecordings: perCameraCapabilities.some(
|
||||
(cap) => cap?.canFavoriteRecordings,
|
||||
return new Capabilities({
|
||||
live: cameras.some((camera) => camera.getCapabilities()?.has('live')),
|
||||
clips: cameras.some((camera) => camera.getCapabilities()?.has('clips')),
|
||||
recordings: cameras.some((camera) => camera.getCapabilities()?.has('recordings')),
|
||||
snapshots: cameras.some((camera) => camera.getCapabilities()?.has('snapshots')),
|
||||
'favorite-events': cameras.some((camera) =>
|
||||
camera.getCapabilities()?.has('favorite-events'),
|
||||
),
|
||||
canSeek: perCameraCapabilities.some((cap) => cap?.canSeek),
|
||||
|
||||
supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips),
|
||||
supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings),
|
||||
supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots),
|
||||
supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline),
|
||||
|
||||
supportsPTZ: perCameraCapabilities.some((cap) => !!cap?.ptz),
|
||||
};
|
||||
'favorite-recordings': cameras.some((camera) =>
|
||||
camera.getCapabilities()?.has('favorite-recordings'),
|
||||
),
|
||||
seek: cameras.some((camera) => camera.getCapabilities()?.has('seek')),
|
||||
menu: cameras.some((camera) => camera.getCapabilities()?.has('menu')),
|
||||
});
|
||||
}
|
||||
|
||||
public async executePTZAction(
|
||||
|
||||
+43
-32
@@ -1,9 +1,10 @@
|
||||
import { CameraConfig } from '../config/types';
|
||||
import { CapabilityKey } from '../types';
|
||||
import { allPromises } from '../utils/basic';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { Camera } from './camera';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { Engine } from './types';
|
||||
import { CapabilitySearchOptions, Engine } from './types';
|
||||
|
||||
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
|
||||
|
||||
@@ -14,8 +15,8 @@ export interface CameraManagerReadOnlyConfigStore {
|
||||
hasCameraID(cameraID: string): boolean;
|
||||
|
||||
getCamera(cameraID: string): Camera | null;
|
||||
getCameras(): Map<string, Camera>;
|
||||
getCameraCount(): number;
|
||||
getVisibleCameraCount(): number;
|
||||
|
||||
getCameraConfigs(cameraIDs?: Iterable<string>): IterableIterator<CameraConfig>;
|
||||
getCameraConfigEntries(
|
||||
@@ -23,10 +24,15 @@ export interface CameraManagerReadOnlyConfigStore {
|
||||
): IterableIterator<[string, CameraConfig]>;
|
||||
|
||||
getCameraIDs(): Set<string>;
|
||||
getVisibleCameraIDs(): Set<string>;
|
||||
getDefaultCameraID(): string | null;
|
||||
|
||||
getAllDependentCameras(cameraID: string): Set<string>;
|
||||
getCameraIDsWithCapability(
|
||||
capability: CapabilityKey | CapabilitySearchOptions,
|
||||
): Set<string>;
|
||||
getAllDependentCameras(
|
||||
cameraID: string,
|
||||
capability?: CapabilityKey | CapabilitySearchOptions,
|
||||
): Set<string>;
|
||||
}
|
||||
|
||||
export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
||||
@@ -47,6 +53,9 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
||||
public getCamera(cameraID: string): Camera | null {
|
||||
return this._cameras.get(cameraID) ?? null;
|
||||
}
|
||||
public getCameras(): Map<string, Camera> {
|
||||
return this._cameras;
|
||||
}
|
||||
public getCameraConfig(cameraID: string): CameraConfig | null {
|
||||
return this._cameras.get(cameraID)?.getConfig() ?? null;
|
||||
}
|
||||
@@ -58,18 +67,11 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
||||
public getCameraCount(): number {
|
||||
return this._cameras.size;
|
||||
}
|
||||
public getVisibleCameraCount(): number {
|
||||
return this.getVisibleCameraIDs().size;
|
||||
}
|
||||
|
||||
public getDefaultCameraID(): string | null {
|
||||
return this._cameras.keys().next().value ?? null;
|
||||
}
|
||||
|
||||
public getCameras(): Map<string, Camera> {
|
||||
return this._cameras;
|
||||
}
|
||||
|
||||
public *getCameraConfigs(
|
||||
cameraIDs?: Iterable<string>,
|
||||
): IterableIterator<CameraConfig> {
|
||||
@@ -93,8 +95,17 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
||||
public getCameraIDs(): Set<string> {
|
||||
return new Set(this._cameras.keys());
|
||||
}
|
||||
public getVisibleCameraIDs(): Set<string> {
|
||||
return this._getMatchingCameraIDs((camera) => !camera.getConfig().hide);
|
||||
|
||||
public getCameraIDsWithCapability(
|
||||
capability: CapabilityKey | CapabilitySearchOptions,
|
||||
): Set<string> {
|
||||
const output: Set<string> = new Set();
|
||||
for (const camera of this._cameras.values()) {
|
||||
if (camera.getCapabilities()?.matches(capability)) {
|
||||
output.add(camera.getID());
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
|
||||
@@ -138,35 +149,35 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
|
||||
* @returns A set of dependent cameraIDs or null (since JS sets guarantee order,
|
||||
* the first item in the set is guaranteed to be the cameraID itself).
|
||||
*/
|
||||
public getAllDependentCameras(cameraID: string): Set<string> {
|
||||
const cameraIDs: Set<string> = new Set();
|
||||
public getAllDependentCameras(
|
||||
cameraID: string,
|
||||
capability?: CapabilitySearchOptions,
|
||||
): Set<string> {
|
||||
const visitedCameraIDs = new Set<string>();
|
||||
const matchingCameraIDs: Set<string> = new Set();
|
||||
const getDependentCameras = (cameraID: string): void => {
|
||||
const cameraConfig = this.getCameraConfig(cameraID);
|
||||
if (cameraConfig) {
|
||||
cameraIDs.add(cameraID);
|
||||
visitedCameraIDs.add(cameraID);
|
||||
|
||||
const camera = this.getCamera(cameraID);
|
||||
const cameraConfig = camera?.getConfig();
|
||||
|
||||
if (camera && cameraConfig) {
|
||||
if (!capability || camera.getCapabilities()?.matches(capability)) {
|
||||
matchingCameraIDs.add(cameraID);
|
||||
}
|
||||
const dependentCameras: Set<string> = new Set();
|
||||
cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
|
||||
if (cameraConfig.dependencies.all_cameras) {
|
||||
this.getCameraIDs().forEach((cameraID) => dependentCameras.add(cameraID));
|
||||
}
|
||||
for (const eventCameraID of dependentCameras) {
|
||||
if (!cameraIDs.has(eventCameraID)) {
|
||||
getDependentCameras(eventCameraID);
|
||||
for (const dependentCameraID of dependentCameras) {
|
||||
if (!visitedCameraIDs.has(dependentCameraID)) {
|
||||
getDependentCameras(dependentCameraID);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
getDependentCameras(cameraID);
|
||||
return cameraIDs;
|
||||
}
|
||||
|
||||
protected _getMatchingCameraIDs(func: (camera: Camera) => boolean): Set<string> {
|
||||
const output = new Set<string>();
|
||||
for (const [cameraID, camera] of this._cameras.entries()) {
|
||||
if (func(camera)) {
|
||||
output.add(cameraID);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
return matchingCameraIDs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CapabilityKey } from '../types';
|
||||
import { FrigateCardView } from '../config/types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
|
||||
@@ -91,32 +92,11 @@ export interface MediaMetadata {
|
||||
what?: Set<string>;
|
||||
}
|
||||
|
||||
export type PTZMovementType = 'relative' | 'continuous';
|
||||
|
||||
export interface PTZCapabilities {
|
||||
panTilt?: PTZMovementType[];
|
||||
zoom?: PTZMovementType[];
|
||||
presets?: string[];
|
||||
}
|
||||
|
||||
interface BaseCapabilities {
|
||||
canFavoriteEvents: boolean;
|
||||
canFavoriteRecordings: boolean;
|
||||
canSeek: boolean;
|
||||
|
||||
supportsClips: boolean;
|
||||
supportsRecordings: boolean;
|
||||
supportsSnapshots: boolean;
|
||||
supportsTimeline: boolean;
|
||||
}
|
||||
|
||||
export interface CameraManagerCapabilities extends BaseCapabilities {
|
||||
supportsPTZ: boolean;
|
||||
}
|
||||
|
||||
export interface CameraManagerCameraCapabilities extends BaseCapabilities {
|
||||
ptz?: PTZCapabilities;
|
||||
interface CapabilitySearchAllAny {
|
||||
allCapabilities?: CapabilityKey[];
|
||||
anyCapabilities?: CapabilityKey[];
|
||||
}
|
||||
export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny;
|
||||
|
||||
export interface CameraManagerMediaCapabilities {
|
||||
canFavorite: boolean;
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import endOfMinute from 'date-fns/endOfMinute';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import uniqBy from 'lodash-es/uniqBy';
|
||||
import { CameraConfig } from '../config/types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { DateRange } from './range';
|
||||
import { CameraEndpoint } from './types';
|
||||
|
||||
export const convertRangeToCacheFriendlyTimes = (
|
||||
range: DateRange,
|
||||
options?: {
|
||||
endCap?: boolean;
|
||||
},
|
||||
): DateRange => {
|
||||
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
|
||||
let cacheableStart: Date;
|
||||
let cacheableEnd: Date;
|
||||
|
||||
if (widthSeconds <= 60 * 60) {
|
||||
cacheableStart = startOfHour(range.start);
|
||||
cacheableEnd = endOfHour(range.end);
|
||||
} else {
|
||||
cacheableStart = startOfDay(range.start);
|
||||
cacheableEnd = endOfDay(range.end);
|
||||
}
|
||||
|
||||
if (options?.endCap) {
|
||||
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
|
||||
}
|
||||
|
||||
return {
|
||||
start: cacheableStart,
|
||||
end: cacheableEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export const capEndDate = (end: Date): Date => {
|
||||
const now = new Date();
|
||||
return end > now ? now : end;
|
||||
};
|
||||
|
||||
export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
|
||||
return orderBy(
|
||||
// Ensure uniqueness by the ID (if specified), otherwise all elements
|
||||
// are assumed to be unique.
|
||||
uniqBy(mediaArray, (media) => media.getID() ?? media),
|
||||
|
||||
// Sort all items leading oldest -> youngest (so media is loaded in this
|
||||
// order in the viewer which matches the left-to-right timeline order).
|
||||
(media) => media.getStartTime() ?? media.getID(),
|
||||
'asc',
|
||||
);
|
||||
};
|
||||
|
||||
export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => {
|
||||
return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null;
|
||||
};
|
||||
|
||||
export const getDefaultGo2RTCEndpoint = (
|
||||
cameraConfig: CameraConfig,
|
||||
options?: {
|
||||
url?: string;
|
||||
stream?: string;
|
||||
},
|
||||
): CameraEndpoint | null => {
|
||||
const url = options?.url ?? cameraConfig.go2rtc?.url;
|
||||
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
|
||||
|
||||
if (!url || !stream) {
|
||||
return null;
|
||||
}
|
||||
const endpoint = `${url}/api/ws?src=${stream}`;
|
||||
|
||||
return {
|
||||
endpoint: endpoint,
|
||||
|
||||
// Only sign the endpoint if it's local to HA.
|
||||
sign: endpoint.startsWith('/'),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CameraConfig } from '../../config/types';
|
||||
|
||||
export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => {
|
||||
return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export const capEndDate = (end: Date): Date => {
|
||||
const now = new Date();
|
||||
return end > now ? now : end;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CameraConfig } from '../../config/types';
|
||||
import { CameraEndpoint } from '../types';
|
||||
|
||||
export const getDefaultGo2RTCEndpoint = (
|
||||
cameraConfig: CameraConfig,
|
||||
options?: {
|
||||
url?: string;
|
||||
stream?: string;
|
||||
},
|
||||
): CameraEndpoint | null => {
|
||||
const url = options?.url ?? cameraConfig.go2rtc?.url;
|
||||
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
|
||||
|
||||
if (!url || !stream) {
|
||||
return null;
|
||||
}
|
||||
const endpoint = `${url}/api/ws?src=${stream}`;
|
||||
|
||||
return {
|
||||
endpoint: endpoint,
|
||||
|
||||
// Only sign the endpoint if it's local to HA.
|
||||
sign: endpoint.startsWith('/'),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import endOfMinute from 'date-fns/endOfMinute';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import { DateRange } from '../range';
|
||||
import { capEndDate } from './cap-end-date';
|
||||
|
||||
export const convertRangeToCacheFriendlyTimes = (
|
||||
range: DateRange,
|
||||
options?: {
|
||||
endCap?: boolean;
|
||||
},
|
||||
): DateRange => {
|
||||
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
|
||||
let cacheableStart: Date;
|
||||
let cacheableEnd: Date;
|
||||
|
||||
if (widthSeconds <= 60 * 60) {
|
||||
cacheableStart = startOfHour(range.start);
|
||||
cacheableEnd = endOfHour(range.end);
|
||||
} else {
|
||||
cacheableStart = startOfDay(range.start);
|
||||
cacheableEnd = endOfDay(range.end);
|
||||
}
|
||||
|
||||
if (options?.endCap) {
|
||||
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
|
||||
}
|
||||
|
||||
return {
|
||||
start: cacheableStart,
|
||||
end: cacheableEnd,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import uniqBy from 'lodash-es/uniqBy';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
|
||||
export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
|
||||
return orderBy(
|
||||
// Ensure uniqueness by the ID (if specified), otherwise all elements
|
||||
// are assumed to be unique.
|
||||
uniqBy(mediaArray, (media) => media.getID() ?? media),
|
||||
|
||||
// Sort all items leading oldest -> youngest (so media is loaded in this
|
||||
// order in the viewer which matches the left-to-right timeline order).
|
||||
(media) => media.getStartTime() ?? media.getID(),
|
||||
'asc',
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { ViewContext } from 'view';
|
||||
import {
|
||||
FRIGATE_CARD_VIEW_DEFAULT,
|
||||
@@ -5,18 +6,21 @@ import {
|
||||
FrigateCardView,
|
||||
ViewDisplayMode,
|
||||
} from '../config/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { log } from '../utils/debug';
|
||||
import { executeMediaQueryForView } from '../utils/media-to-view';
|
||||
import { View } from '../view/view';
|
||||
import { getCameraIDsForViewName } from '../view/view-to-cameras';
|
||||
import { CardViewAPI } from './types';
|
||||
|
||||
interface ViewManagerSetViewDefaultParameters {
|
||||
cameraID?: string;
|
||||
substream?: string;
|
||||
|
||||
// When failSafe is true, the view will be changed to an "always-works" view
|
||||
// (e.g. `live`) if the proposed view is unsupported. By default the view will
|
||||
// just not be changed.
|
||||
// When failSafe is true, the view will be changed to the default view, or the
|
||||
// `live` view if the default view is not supported, or failing that an error
|
||||
// message is shown. Without `failSafe` the view will just not be changed if
|
||||
// unsupported.
|
||||
failSafe?: boolean;
|
||||
}
|
||||
|
||||
@@ -49,9 +53,11 @@ export class ViewManager {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (config) {
|
||||
let forceCameraID: string | null = params?.cameraID ?? null;
|
||||
const viewName = config.view.default;
|
||||
|
||||
if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) {
|
||||
const cameraIDs = [
|
||||
...this._api.getCameraManager().getStore().getVisibleCameraIDs(),
|
||||
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
|
||||
];
|
||||
const currentIndex = cameraIDs.indexOf(this._view.camera);
|
||||
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
|
||||
@@ -60,7 +66,7 @@ export class ViewManager {
|
||||
|
||||
this.setViewByParameters({
|
||||
...params,
|
||||
viewName: config.view.default,
|
||||
viewName: viewName,
|
||||
...(forceCameraID && { cameraID: forceCameraID }),
|
||||
});
|
||||
|
||||
@@ -76,38 +82,82 @@ export class ViewManager {
|
||||
if (config) {
|
||||
let cameraID: string | null = null;
|
||||
|
||||
const cameras = this._api.getCameraManager().getStore().getVisibleCameraIDs();
|
||||
if (cameras.size) {
|
||||
if (params?.cameraID && cameras.has(params.cameraID)) {
|
||||
cameraID = params.cameraID;
|
||||
} else {
|
||||
// Reset to the default camera.
|
||||
cameraID = cameras.keys().next().value;
|
||||
}
|
||||
}
|
||||
let viewName = params?.viewName ?? this._view?.view ?? config.view.default;
|
||||
if (cameraID && viewName) {
|
||||
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
|
||||
if (params.failSafe) {
|
||||
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
|
||||
if (params?.cameraID && allCameraIDs.has(params.cameraID)) {
|
||||
cameraID = params.cameraID;
|
||||
} else {
|
||||
const viewCameraIDs = getCameraIDsForViewName(
|
||||
this._api.getCameraManager(),
|
||||
viewName,
|
||||
);
|
||||
|
||||
// Reset to the default camera.
|
||||
cameraID = viewCameraIDs.keys().next().value;
|
||||
}
|
||||
|
||||
if (!cameraID) {
|
||||
if (params.failSafe) {
|
||||
const camerasToCapabilities = [
|
||||
...this._api.getCameraManager().getStore().getCameras(),
|
||||
].reduce((acc, [cameraID, camera]) => {
|
||||
const capabilities = camera.getCapabilities()?.getRawCapabilities();
|
||||
if (capabilities) {
|
||||
acc[cameraID] = capabilities;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
||||
type: 'error',
|
||||
message: localize('error.no_supported_cameras'),
|
||||
context: {
|
||||
view: viewName,
|
||||
cameras_capabilities: camerasToCapabilities,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
|
||||
if (params.failSafe) {
|
||||
if (this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)) {
|
||||
viewName = FRIGATE_CARD_VIEW_DEFAULT;
|
||||
} else {
|
||||
const capabilities = this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getCamera(cameraID)
|
||||
?.getCapabilities()
|
||||
?.getRawCapabilities();
|
||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
||||
type: 'error',
|
||||
message: localize('error.no_supported_camera'),
|
||||
context: {
|
||||
view: viewName,
|
||||
camera: cameraID,
|
||||
...(capabilities && { camera_capabilities: capabilities }),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const displayMode =
|
||||
this._view?.displayMode ??
|
||||
this._getDefaultDisplayModeForView(viewName, config);
|
||||
let view: View = new View({
|
||||
view: viewName,
|
||||
camera: cameraID,
|
||||
displayMode: displayMode,
|
||||
});
|
||||
if (params.substream) {
|
||||
view = this._createViewWithSelectedSubstream(view, params.substream);
|
||||
}
|
||||
this._setView(view);
|
||||
}
|
||||
|
||||
const displayMode =
|
||||
this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName, config);
|
||||
let view: View = new View({
|
||||
view: viewName,
|
||||
camera: cameraID,
|
||||
displayMode: displayMode,
|
||||
});
|
||||
if (params.substream) {
|
||||
view = this._createViewWithSelectedSubstream(view, params.substream);
|
||||
}
|
||||
this._setView(view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +171,12 @@ export class ViewManager {
|
||||
this._view = null;
|
||||
}
|
||||
|
||||
protected _getCameraIDsInvolvedInView(view: View): Set<string> {
|
||||
return view.supportsMultipleDisplayModes() && view.isGrid()
|
||||
? getCameraIDsForViewName(this._api.getCameraManager(), view.view)
|
||||
: getCameraIDsForViewName(this._api.getCameraManager(), view.view, view.camera);
|
||||
}
|
||||
|
||||
public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise<void> {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
|
||||
@@ -129,18 +185,10 @@ export class ViewManager {
|
||||
displayMode: displayMode,
|
||||
});
|
||||
|
||||
const cameraCount = this._api
|
||||
.getCameraManager()
|
||||
.getStore()
|
||||
.getVisibleCameraCount();
|
||||
const queryCameraCount = view.query?.getQueryCameraIDs()?.size ?? 0;
|
||||
const generateNewQuery =
|
||||
view?.query &&
|
||||
queryCameraCount &&
|
||||
((view.isGrid() && queryCameraCount < cameraCount) ||
|
||||
(!view.isGrid() && queryCameraCount > 1));
|
||||
const expectedCameraIDs = this._getCameraIDsInvolvedInView(view);
|
||||
const queryCameraIDs = view.query?.getQueryCameraIDs();
|
||||
|
||||
if (generateNewQuery && view && view.query) {
|
||||
if (!isEqual(expectedCameraIDs, queryCameraIDs) && view && view.query) {
|
||||
// If the user requests a grid but the current query does not have a
|
||||
// query for more than one camera, reset the query results, change the
|
||||
// existing query to refer to all cameras and execute it to fetch new
|
||||
@@ -150,13 +198,7 @@ export class ViewManager {
|
||||
viewWithNewQuery = await executeMediaQueryForView(
|
||||
this._api.getCameraManager(),
|
||||
view,
|
||||
view.query
|
||||
.clone()
|
||||
.setQueryCameraIDs(
|
||||
view.isGrid()
|
||||
? this._api.getCameraManager().getStore().getVisibleCameraIDs()
|
||||
: view.camera,
|
||||
),
|
||||
view.query.clone().setQueryCameraIDs(expectedCameraIDs),
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||
@@ -190,42 +232,7 @@ export class ViewManager {
|
||||
}
|
||||
|
||||
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
|
||||
const dependentCamerasCapabilities = this._api
|
||||
.getCameraManager()
|
||||
.getAggregateCameraCapabilities(
|
||||
this._api.getCameraManager().getStore().getAllDependentCameras(cameraID),
|
||||
);
|
||||
const allCamerasCapabilities = this._api
|
||||
.getCameraManager()
|
||||
.getAggregateCameraCapabilities(
|
||||
this._api.getCameraManager().getStore().getCameraIDs(),
|
||||
);
|
||||
|
||||
switch (view) {
|
||||
case 'live':
|
||||
case 'image':
|
||||
case 'diagnostics':
|
||||
return true;
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
return !!dependentCamerasCapabilities?.supportsClips;
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
return !!dependentCamerasCapabilities?.supportsSnapshots;
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
return !!dependentCamerasCapabilities?.supportsRecordings;
|
||||
case 'timeline':
|
||||
// Show the timeline if any camera supports it, even cameras unrelated
|
||||
// to the currently selected camera.
|
||||
return !!allCamerasCapabilities?.supportsTimeline;
|
||||
case 'media':
|
||||
return (
|
||||
!!dependentCamerasCapabilities?.supportsClips ||
|
||||
!!dependentCamerasCapabilities?.supportsSnapshots ||
|
||||
!!dependentCamerasCapabilities?.supportsRecordings
|
||||
);
|
||||
}
|
||||
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
|
||||
}
|
||||
|
||||
protected _getDefaultDisplayModeForView(
|
||||
|
||||
@@ -182,11 +182,11 @@ export class MediaFilterController {
|
||||
return null;
|
||||
};
|
||||
|
||||
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
|
||||
if (!visibleCameraIDs.size || !values.mediaType) {
|
||||
const cameraIDs =
|
||||
getArrayValueAsSet(values.camera) ?? this._getAllCameraIDs(cameraManager);
|
||||
if (!cameraIDs.size || !values.mediaType) {
|
||||
return;
|
||||
}
|
||||
const cameraIDs = getArrayValueAsSet(values.camera) ?? visibleCameraIDs;
|
||||
|
||||
const when = this._getWhen(values.when);
|
||||
const favorite = values.favorite
|
||||
@@ -280,10 +280,16 @@ export class MediaFilterController {
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
protected _getAllCameraIDs(cameraManager: CameraManager): Set<string> {
|
||||
return cameraManager.getStore().getCameraIDsWithCapability({
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings'],
|
||||
});
|
||||
}
|
||||
|
||||
public computeInitialDefaultsFromView(cameraManager: CameraManager, view: View): void {
|
||||
const queries = view.query?.getQueries();
|
||||
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
|
||||
if (!queries || !visibleCameraIDs.size) {
|
||||
const allCameraIDs = this._getAllCameraIDs(cameraManager);
|
||||
if (!queries || !allCameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -300,7 +306,7 @@ export class MediaFilterController {
|
||||
);
|
||||
// Special note: If all visible cameras are selected, this is the same as no
|
||||
// selector at all.
|
||||
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, visibleCameraIDs)) {
|
||||
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, allCameraIDs)) {
|
||||
cameraIDs = [...queries[0].cameraIDs];
|
||||
}
|
||||
|
||||
@@ -375,8 +381,7 @@ export class MediaFilterController {
|
||||
}
|
||||
|
||||
public computeCameraOptions(cameraManager: CameraManager): void {
|
||||
const cameras = cameraManager.getStore().getVisibleCameraIDs();
|
||||
this._cameraOptions = [...cameras].map((cameraID) => ({
|
||||
this._cameraOptions = [...this._getAllCameraIDs(cameraManager)].map((cameraID) => ({
|
||||
value: cameraID,
|
||||
label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID,
|
||||
}));
|
||||
@@ -451,9 +456,9 @@ export class MediaFilterController {
|
||||
events: events,
|
||||
recordings: recordings,
|
||||
favorites: events
|
||||
? !!managerCapabilities?.canFavoriteEvents
|
||||
? managerCapabilities?.has('favorite-events')
|
||||
: recordings
|
||||
? !!managerCapabilities?.canFavoriteRecordings
|
||||
? managerCapabilities?.has('favorite-recordings')
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getEntityIcon, getEntityTitle } from '../utils/ha';
|
||||
import { hasUsablePTZ } from '../utils/ptz';
|
||||
import { hasSubstream } from '../utils/substream';
|
||||
import { View } from '../view/view';
|
||||
import { getCameraIDsForViewName } from '../view/view-to-cameras';
|
||||
|
||||
export interface MenuButtonControllerOptions {
|
||||
currentMediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
@@ -62,16 +63,12 @@ export class MenuButtonController {
|
||||
view: View,
|
||||
options?: MenuButtonControllerOptions,
|
||||
): MenuItem[] {
|
||||
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
|
||||
const selectedCameraID = view.camera;
|
||||
const substreamAwareCameraID =
|
||||
view.context?.live?.overrides?.get(selectedCameraID) ?? selectedCameraID;
|
||||
const selectedCameraConfig = cameraManager
|
||||
.getStore()
|
||||
.getCameraConfig(selectedCameraID);
|
||||
const allSelectedCameraIDs = cameraManager
|
||||
.getStore()
|
||||
.getAllDependentCameras(selectedCameraID);
|
||||
|
||||
const substreamAwareCameraCapabilities =
|
||||
cameraManager.getCameraCapabilities(substreamAwareCameraID);
|
||||
@@ -98,9 +95,12 @@ export class MenuButtonController {
|
||||
) as FrigateCardCustomAction,
|
||||
});
|
||||
|
||||
if (visibleCameraIDs.size) {
|
||||
// Show all cameras in the menu rather than just cameras that support the
|
||||
// current view for a less surprising UX.
|
||||
const menuCameraIDs = cameraManager.getStore().getCameraIDsWithCapability('menu');
|
||||
if (menuCameraIDs.size) {
|
||||
const menuItems = Array.from(
|
||||
cameraManager.getStore().getCameraConfigEntries(visibleCameraIDs),
|
||||
cameraManager.getStore().getCameraConfigEntries(menuCameraIDs),
|
||||
([cameraID, config]) => {
|
||||
const action = createFrigateCardCameraAction('camera_select', cameraID);
|
||||
const metadata = cameraManager.getCameraMetadata(cameraID);
|
||||
@@ -126,10 +126,15 @@ export class MenuButtonController {
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedCameraID && allSelectedCameraIDs && view.is('live')) {
|
||||
const dependencies = [...allSelectedCameraIDs];
|
||||
const substreamCameraIDs = cameraManager
|
||||
.getStore()
|
||||
.getAllDependentCameras(selectedCameraID, 'substream');
|
||||
|
||||
if (dependencies.length === 2) {
|
||||
if (selectedCameraID && substreamCameraIDs && view.is('live')) {
|
||||
const substreams = [...substreamCameraIDs].filter((cameraID) => cameraID !== selectedCameraID);
|
||||
const streams = [selectedCameraID, ...substreams];
|
||||
|
||||
if (streams.length === 2) {
|
||||
// If there are only two dependencies (the main camera, and 1 other)
|
||||
// then use a button not a menu to toggle.
|
||||
buttons.push({
|
||||
@@ -145,14 +150,14 @@ export class MenuButtonController {
|
||||
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
|
||||
) as FrigateCardCustomAction,
|
||||
});
|
||||
} else if (dependencies.length > 2) {
|
||||
const menuItems = Array.from(dependencies, (cameraID) => {
|
||||
} else if (streams.length > 2) {
|
||||
const menuItems = Array.from(streams, (streamID) => {
|
||||
const action = createFrigateCardCameraAction(
|
||||
'live_substream_select',
|
||||
cameraID,
|
||||
streamID,
|
||||
);
|
||||
const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
|
||||
const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID);
|
||||
const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined;
|
||||
const cameraConfig = cameraManager.getStore().getCameraConfig(streamID);
|
||||
return {
|
||||
enabled: true,
|
||||
icon: metadata?.icon,
|
||||
@@ -161,7 +166,7 @@ export class MenuButtonController {
|
||||
title: metadata?.title,
|
||||
selected:
|
||||
(view.context?.live?.overrides?.get(selectedCameraID) ??
|
||||
selectedCameraID) === cameraID,
|
||||
selectedCameraID) === streamID,
|
||||
...(action && { tap_action: action }),
|
||||
};
|
||||
});
|
||||
@@ -409,7 +414,8 @@ export class MenuButtonController {
|
||||
});
|
||||
}
|
||||
|
||||
if (view.supportsMultipleDisplayModes() && visibleCameraIDs.size > 1) {
|
||||
const viewCameraIDs = getCameraIDsForViewName(cameraManager, view.view);
|
||||
if (view.supportsMultipleDisplayModes() && viewCameraIDs.size > 1) {
|
||||
const isGrid = view.isGrid();
|
||||
buttons.push({
|
||||
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
|
||||
|
||||
@@ -137,7 +137,7 @@ export class PTZController {
|
||||
|
||||
const ptzCapabilities = this._cameraManager.getCameraCapabilities(
|
||||
this._cameraID,
|
||||
)?.ptz;
|
||||
)?.getPTZCapabilities();
|
||||
|
||||
const defaultActions: FrigateCardPTZActions = {};
|
||||
const panTilt = ptzCapabilities?.panTilt;
|
||||
|
||||
@@ -4,14 +4,15 @@ import { DataSet } from 'vis-data';
|
||||
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import {
|
||||
compressRanges,
|
||||
ExpiringMemoryRangeSet,
|
||||
MemoryRangeSet,
|
||||
compressRanges,
|
||||
} from '../camera-manager/range';
|
||||
import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types';
|
||||
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/utils';
|
||||
import { capEndDate } from '../camera-manager/utils/cap-end-date';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly';
|
||||
import { ClipsOrSnapshotsOrAll } from '../types';
|
||||
import { ModifyInterface, errorToConsole } from '../utils/basic.js';
|
||||
import { errorToConsole, ModifyInterface } from '../utils/basic.js';
|
||||
import { ViewMedia } from '../view/media';
|
||||
|
||||
// Allow timeline freshness to be at least this number of seconds out of date
|
||||
|
||||
@@ -324,7 +324,7 @@ export class FrigateCardLiveGrid extends LitElement {
|
||||
}
|
||||
|
||||
protected _needsGrid(): boolean {
|
||||
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||
return (
|
||||
!!this.view?.isGrid() &&
|
||||
!!this.view?.supportsMultipleDisplayModes() &&
|
||||
@@ -343,8 +343,8 @@ export class FrigateCardLiveGrid extends LitElement {
|
||||
if (!this.conditionsManagerEpoch || !this.nonOverriddenLiveConfig) {
|
||||
return;
|
||||
}
|
||||
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||
if (!cameraIDs || !this._needsGrid()) {
|
||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||
if (!cameraIDs?.size || !this._needsGrid()) {
|
||||
return this._renderCarousel();
|
||||
}
|
||||
return html`
|
||||
@@ -416,8 +416,8 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected _getSelectedCameraIndex(): number {
|
||||
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||
if (!cameraIDs || !this.view || this.viewFilterCameraID) {
|
||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||
if (!cameraIDs?.size || !this.view || this.viewFilterCameraID) {
|
||||
// If the carousel is limited to a single cameraID, the first (only)
|
||||
// element is always the selected one.
|
||||
return 0;
|
||||
@@ -481,7 +481,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
|
||||
const cameraIDs = this.viewFilterCameraID
|
||||
? new Set([this.viewFilterCameraID])
|
||||
: this.cameraManager.getStore().getVisibleCameraIDs();
|
||||
: this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||
|
||||
const slides: TemplateResult[] = [];
|
||||
const cameraToSlide: Record<string, number> = {};
|
||||
@@ -508,8 +508,8 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
|
||||
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||
if (cameraIDs && ev.detail.index !== this._getSelectedCameraIndex()) {
|
||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||
if (cameraIDs?.size && ev.detail.index !== this._getSelectedCameraIndex()) {
|
||||
this._setViewCameraID([...cameraIDs][ev.detail.index]);
|
||||
}
|
||||
}
|
||||
@@ -597,7 +597,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
|
||||
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
|
||||
const cameraIDs = this.cameraManager
|
||||
? [...this.cameraManager.getStore().getVisibleCameraIDs()]
|
||||
? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')]
|
||||
: [];
|
||||
if (this.viewFilterCameraID || cameraIDs.length <= 1 || !this.view || !this.hass) {
|
||||
return [null, null];
|
||||
|
||||
@@ -159,14 +159,22 @@ export class FrigateCardSurround extends LitElement {
|
||||
}
|
||||
|
||||
protected _getCameraIDsForTimeline(): Set<string> | null {
|
||||
if (!this.view) {
|
||||
if (!this.view || !this.cameraManager) {
|
||||
return null;
|
||||
}
|
||||
if (this.view?.is('live')) {
|
||||
return this.view.isGrid()
|
||||
? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null
|
||||
: this.cameraManager?.getStore().getAllDependentCameras(this.view.camera) ??
|
||||
null;
|
||||
if (this.view.is('live')) {
|
||||
const capabilitySearch = {
|
||||
anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const],
|
||||
};
|
||||
if (this.view.supportsMultipleDisplayModes() && this.view.isGrid()) {
|
||||
return this.cameraManager
|
||||
.getStore()
|
||||
.getCameraIDsWithCapability(capabilitySearch);
|
||||
} else {
|
||||
return this.cameraManager
|
||||
.getStore()
|
||||
.getAllDependentCameras(this.view.camera, capabilitySearch);
|
||||
}
|
||||
}
|
||||
if (this.view.isViewerView()) {
|
||||
return this.view.query?.getQueryCameraIDs() ?? null;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { rangesOverlap } from '../camera-manager/range';
|
||||
import { MediaQuery } from '../camera-manager/types';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly';
|
||||
import {
|
||||
FrigateCardTimelineItem,
|
||||
TimelineDataSource,
|
||||
@@ -72,7 +72,6 @@ import { MediaQueriesResults } from '../view/media-queries-results';
|
||||
import { View } from '../view/view';
|
||||
import './date-picker.js';
|
||||
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
|
||||
import { dispatchMessageEvent } from './message.js';
|
||||
import './thumbnail.js';
|
||||
|
||||
interface FrigateCardGroupData {
|
||||
@@ -275,18 +274,11 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
request.detail.view = this.view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const cameraIDs = this._getTimelineCameraIDs();
|
||||
|
||||
if (!this.hass || !this.view || !this.timelineConfig || !cameraIDs) {
|
||||
if (!this.hass || !this.view || !this.timelineConfig || !this.cameraIDs?.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs);
|
||||
const panMode = this._getEffectivePanMode();
|
||||
|
||||
const panTitle =
|
||||
@@ -306,55 +298,43 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
? 'mdi:play-box-lock'
|
||||
: 'mdi:camera-lock';
|
||||
|
||||
return html` ${capabilities?.supportsTimeline
|
||||
? html` <div
|
||||
@frigate-card:timeline:thumbnail-data-request=${this._handleThumbnailDataRequest.bind(
|
||||
this,
|
||||
)}
|
||||
class="timeline"
|
||||
${ref(this._refTimeline)}
|
||||
>
|
||||
<div class="timeline-tools">
|
||||
${this._shouldSupportSeeking()
|
||||
? html` <ha-icon
|
||||
.icon=${panIcon}
|
||||
@click=${() => {
|
||||
this._panMode =
|
||||
panMode === 'pan'
|
||||
? 'seek'
|
||||
: panMode === 'seek'
|
||||
? 'seek-in-media'
|
||||
: panMode === 'seek-in-media'
|
||||
? 'seek-in-camera'
|
||||
: 'pan';
|
||||
}}
|
||||
aria-label="${panTitle}"
|
||||
title="${panTitle}"
|
||||
>
|
||||
</ha-icon>`
|
||||
: ''}
|
||||
<frigate-card-date-picker
|
||||
${ref(this._refDatePicker)}
|
||||
@frigate-card:date-picker:change=${(ev: CustomEvent<DatePickerEvent>) => {
|
||||
if (ev.detail.date) {
|
||||
this._timeline?.moveTo(ev.detail.date);
|
||||
}
|
||||
return html` <div
|
||||
@frigate-card:timeline:thumbnail-data-request=${this._handleThumbnailDataRequest.bind(
|
||||
this,
|
||||
)}
|
||||
class="timeline"
|
||||
${ref(this._refTimeline)}
|
||||
>
|
||||
<div class="timeline-tools">
|
||||
${this._shouldSupportSeeking()
|
||||
? html` <ha-icon
|
||||
.icon=${panIcon}
|
||||
@click=${() => {
|
||||
this._panMode =
|
||||
panMode === 'pan'
|
||||
? 'seek'
|
||||
: panMode === 'seek'
|
||||
? 'seek-in-media'
|
||||
: panMode === 'seek-in-media'
|
||||
? 'seek-in-camera'
|
||||
: 'pan';
|
||||
}}
|
||||
aria-label="${panTitle}"
|
||||
title="${panTitle}"
|
||||
>
|
||||
</frigate-card-date-picker>
|
||||
</div>
|
||||
</div>`
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the keys of the cameras in scope for this timeline.
|
||||
* @returns A set of camera ids (may be empty).
|
||||
*/
|
||||
protected _getTimelineCameraIDs(): Set<string> | null {
|
||||
return (
|
||||
this.cameraIDs ?? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null
|
||||
);
|
||||
</ha-icon>`
|
||||
: ''}
|
||||
<frigate-card-date-picker
|
||||
${ref(this._refDatePicker)}
|
||||
@frigate-card:date-picker:change=${(ev: CustomEvent<DatePickerEvent>) => {
|
||||
if (ev.detail.date) {
|
||||
this._timeline?.moveTo(ev.detail.date);
|
||||
}
|
||||
}}
|
||||
>
|
||||
</frigate-card-date-picker>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -555,7 +535,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
stopEventFromActivatingCardWideActions(properties.event);
|
||||
}
|
||||
|
||||
const timelineCameraIDs = this._getTimelineCameraIDs();
|
||||
if (
|
||||
this._ignoreClick ||
|
||||
!this.hass ||
|
||||
@@ -563,7 +542,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
!this.view ||
|
||||
!this.cameraManager ||
|
||||
!this.cardWideConfig ||
|
||||
!timelineCameraIDs ||
|
||||
!this.cameraIDs ||
|
||||
!this.cameraIDs.size ||
|
||||
!this._timelineSource ||
|
||||
!properties.what
|
||||
) {
|
||||
@@ -767,14 +747,13 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
*/
|
||||
protected _getGroups(): DataGroupCollectionType {
|
||||
const groups: FrigateCardGroupData[] = [];
|
||||
(this._getTimelineCameraIDs() ?? []).forEach((cameraID) => {
|
||||
(this.cameraIDs ?? []).forEach((cameraID: string) => {
|
||||
if (!this.hass || !this.cameraManager) {
|
||||
return;
|
||||
}
|
||||
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
|
||||
const cameraCapabilities = this.cameraManager.getCameraCapabilities(cameraID);
|
||||
|
||||
if (cameraMetadata && cameraCapabilities?.supportsTimeline) {
|
||||
if (cameraMetadata) {
|
||||
groups.push({
|
||||
id: cameraID,
|
||||
content: cameraMetadata.title,
|
||||
@@ -1142,11 +1121,10 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
changedProps.has('timelineConfig') ||
|
||||
changedProps.has('cameraIDs')
|
||||
) {
|
||||
const cameraIDs = this._getTimelineCameraIDs();
|
||||
if (cameraIDs && this.cameraManager && this.timelineConfig) {
|
||||
if (this.cameraIDs?.size && this.cameraManager && this.timelineConfig) {
|
||||
this._timelineSource = new TimelineDataSource(
|
||||
this.cameraManager,
|
||||
cameraIDs,
|
||||
this.cameraIDs,
|
||||
this.timelineConfig.events_media_type,
|
||||
this.timelineConfig.show_recordings,
|
||||
);
|
||||
@@ -1191,12 +1169,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
const groups = this._getGroups();
|
||||
if (!groups.length) {
|
||||
if (!this.mini) {
|
||||
// Don't show an empty timeline, show a message instead.
|
||||
dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', {
|
||||
icon: 'mdi:chart-gantt',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ export class FrigateCardTimeline extends LitElement {
|
||||
.timelineConfig=${this.timelineConfig}
|
||||
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cameraIDs=${this.cameraManager?.getStore().getCameraIDsWithCapability({
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings'],
|
||||
})}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none'
|
||||
? 'play'
|
||||
|
||||
@@ -678,4 +678,10 @@ const UPGRADES = [
|
||||
typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {},
|
||||
);
|
||||
},
|
||||
upgradeArrayOfObjects(
|
||||
CONF_CAMERAS,
|
||||
upgradeMoveToWithOverrides('hide', 'capabilities', {
|
||||
transform: (val) => (val === true ? { disable_except: 'substream' } : null),
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
+11
-12
@@ -9,6 +9,7 @@ import {
|
||||
} from '@dermotduffy/custom-card-helpers';
|
||||
import { z } from 'zod';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../const.js';
|
||||
import { capabilityKeys } from '../types.js';
|
||||
import { deepRemoveDefaults } from '../utils/zod.js';
|
||||
|
||||
// *************************************************************************
|
||||
@@ -44,6 +45,8 @@ const FRIGATE_CARD_VIEWS = [
|
||||
] as const;
|
||||
|
||||
export type FrigateCardView = (typeof FRIGATE_CARD_VIEWS)[number];
|
||||
|
||||
// The default view (may not be supported on all cameras).
|
||||
export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const;
|
||||
|
||||
export const MEDIA_ACTION_NEGATIVE_CONDITIONS = ['unselected', 'hidden'] as const;
|
||||
@@ -730,12 +733,7 @@ const timelineCoreConfigDefault = {
|
||||
pan_mode: 'pan' as const,
|
||||
};
|
||||
|
||||
export const timelinePanModeSchema = z.enum([
|
||||
'pan',
|
||||
'seek',
|
||||
'seek-in-media',
|
||||
'seek-in-camera',
|
||||
]);
|
||||
const timelinePanModeSchema = z.enum(['pan', 'seek', 'seek-in-media', 'seek-in-camera']);
|
||||
export type TimelinePanMode = z.infer<typeof timelinePanModeSchema>;
|
||||
|
||||
const timelineCoreConfigSchema = z.object({
|
||||
@@ -757,9 +755,7 @@ const timelineCoreConfigSchema = z.object({
|
||||
.optional()
|
||||
.default(timelineCoreConfigDefault.show_recordings),
|
||||
style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style),
|
||||
pan_mode: timelinePanModeSchema
|
||||
.optional()
|
||||
.default(timelineCoreConfigDefault.pan_mode),
|
||||
pan_mode: timelinePanModeSchema.optional().default(timelineCoreConfigDefault.pan_mode),
|
||||
});
|
||||
export type TimelineCoreConfig = z.infer<typeof timelineCoreConfigSchema>;
|
||||
|
||||
@@ -1119,7 +1115,6 @@ const cameraConfigDefault = {
|
||||
frigate: {
|
||||
client_id: 'frigate' as const,
|
||||
},
|
||||
hide: false,
|
||||
image: {
|
||||
refresh_seconds: 1,
|
||||
},
|
||||
@@ -1151,8 +1146,12 @@ export const cameraConfigSchema = z
|
||||
icon: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
|
||||
// Used to hide the camera (e.g. when used only as a dependency).
|
||||
hide: z.boolean().optional(),
|
||||
capabilities: z
|
||||
.object({
|
||||
disable: z.enum(capabilityKeys).array().optional(),
|
||||
disable_except: z.enum(capabilityKeys).array().optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
// Optional identifier to separate different camera configurations used in
|
||||
// this card.
|
||||
|
||||
+4
-1
@@ -8,6 +8,10 @@ export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY =
|
||||
`${CONF_CAMERAS}.#.camera_entity` as const;
|
||||
export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME =
|
||||
`${CONF_CAMERAS}.#.frigate.camera_name` as const;
|
||||
export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE =
|
||||
`${CONF_CAMERAS}.#.capabilities.disable` as const;
|
||||
export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT =
|
||||
`${CONF_CAMERAS}.#.capabilities.disable_except` as const;
|
||||
export const CONF_CAMERAS_ARRAY_CAST_METHOD = `${CONF_CAMERAS}.#.cast.method` as const;
|
||||
export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH =
|
||||
`${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const;
|
||||
@@ -25,7 +29,6 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES =
|
||||
export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const;
|
||||
export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM =
|
||||
`${CONF_CAMERAS}.#.go2rtc.stream` as const;
|
||||
export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` as const;
|
||||
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
|
||||
export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const;
|
||||
export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS =
|
||||
|
||||
+81
-11
@@ -31,8 +31,9 @@ import {
|
||||
THUMBNAIL_WIDTH_MIN,
|
||||
} from './config/types.js';
|
||||
import {
|
||||
CONF_CAMERAS,
|
||||
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
|
||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
|
||||
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH,
|
||||
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH,
|
||||
CONF_CAMERAS_ARRAY_CAST_METHOD,
|
||||
@@ -49,7 +50,6 @@ import {
|
||||
CONF_CAMERAS_ARRAY_FRIGATE_ZONES,
|
||||
CONF_CAMERAS_ARRAY_GO2RTC_MODES,
|
||||
CONF_CAMERAS_ARRAY_GO2RTC_STREAM,
|
||||
CONF_CAMERAS_ARRAY_HIDE,
|
||||
CONF_CAMERAS_ARRAY_ICON,
|
||||
CONF_CAMERAS_ARRAY_ID,
|
||||
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
|
||||
@@ -67,8 +67,9 @@ import {
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
|
||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
||||
CONF_DIMENSIONS_ASPECT_RATIO,
|
||||
CONF_CAMERAS,
|
||||
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
|
||||
CONF_DIMENSIONS_ASPECT_RATIO,
|
||||
CONF_DIMENSIONS_MAX_HEIGHT,
|
||||
CONF_DIMENSIONS_MIN_HEIGHT,
|
||||
CONF_IMAGE_MODE,
|
||||
@@ -91,7 +92,6 @@ import {
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_MODE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
|
||||
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||
@@ -157,8 +158,8 @@ import {
|
||||
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
||||
CONF_MEDIA_VIEWER_ZOOMABLE,
|
||||
CONF_MENU_ALIGNMENT,
|
||||
CONF_MENU_BUTTONS,
|
||||
CONF_MENU_BUTTON_SIZE,
|
||||
CONF_MENU_BUTTONS,
|
||||
CONF_MENU_POSITION,
|
||||
CONF_MENU_STYLE,
|
||||
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
|
||||
@@ -182,14 +183,14 @@ import {
|
||||
CONF_VIEW_DEFAULT,
|
||||
CONF_VIEW_INTERACTION_SECONDS,
|
||||
CONF_VIEW_RESET_AFTER_INTERACTION,
|
||||
CONF_VIEW_TRIGGERS,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS,
|
||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
||||
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
|
||||
CONF_VIEW_TRIGGERS,
|
||||
CONF_VIEW_UPDATE_CYCLE_CAMERA,
|
||||
CONF_VIEW_UPDATE_FORCE,
|
||||
CONF_VIEW_UPDATE_SECONDS,
|
||||
@@ -208,6 +209,7 @@ import {
|
||||
|
||||
const MENU_BUTTONS = 'buttons';
|
||||
const MENU_CAMERAS = 'cameras';
|
||||
const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities';
|
||||
const MENU_CAMERAS_CAST = 'cameras.cast';
|
||||
const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
|
||||
const MENU_CAMERAS_DIMENSIONS = 'cameras.dimensions';
|
||||
@@ -741,6 +743,50 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
},
|
||||
];
|
||||
|
||||
protected _capabilities: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'live',
|
||||
label: localize('config.cameras.capabilities.capabilities.live'),
|
||||
},
|
||||
{
|
||||
value: 'substream',
|
||||
label: localize('config.cameras.capabilities.capabilities.substream'),
|
||||
},
|
||||
{
|
||||
value: 'clips',
|
||||
label: localize('config.cameras.capabilities.capabilities.clips'),
|
||||
},
|
||||
{
|
||||
value: 'recordings',
|
||||
label: localize('config.cameras.capabilities.capabilities.recordings'),
|
||||
},
|
||||
{
|
||||
value: 'snapshots',
|
||||
label: localize('config.cameras.capabilities.capabilities.snapshots'),
|
||||
},
|
||||
{
|
||||
value: 'favorite-events',
|
||||
label: localize('config.cameras.capabilities.capabilities.favorite-events'),
|
||||
},
|
||||
{
|
||||
value: 'favorite-recordings',
|
||||
label: localize('config.cameras.capabilities.capabilities.favorite-recordings'),
|
||||
},
|
||||
{
|
||||
value: 'seek',
|
||||
label: localize('config.cameras.capabilities.capabilities.seek'),
|
||||
},
|
||||
{
|
||||
value: 'ptz',
|
||||
label: localize('config.cameras.capabilities.capabilities.ptz'),
|
||||
},
|
||||
{
|
||||
value: 'menu',
|
||||
label: localize('config.cameras.capabilities.capabilities.menu'),
|
||||
},
|
||||
];
|
||||
|
||||
public setConfig(config: RawFrigateCardConfig): void {
|
||||
// Note: This does not use Zod to parse the configuration, so it may be
|
||||
// partially or completely invalid. It's more useful to have a partially
|
||||
@@ -1688,10 +1734,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_ID, cameraIndex),
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_HIDE, cameraIndex),
|
||||
this._defaults.cameras.hide,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_ENGINE,
|
||||
true,
|
||||
@@ -1933,6 +1975,34 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_CAPABILITIES,
|
||||
cameraIndex,
|
||||
'config.cameras.capabilities.editor_label',
|
||||
{ name: 'mdi:cog-stop' },
|
||||
html`
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
|
||||
cameraIndex,
|
||||
),
|
||||
this._capabilities,
|
||||
{
|
||||
multiple: true,
|
||||
},
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
|
||||
cameraIndex,
|
||||
),
|
||||
this._capabilities,
|
||||
{
|
||||
multiple: true,
|
||||
},
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: ``}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,23 @@
|
||||
"config": {
|
||||
"cameras": {
|
||||
"camera_entity": "Entitat de càmera",
|
||||
"capabilities": {
|
||||
"disable": "",
|
||||
"disable_except": "",
|
||||
"editor_label": "",
|
||||
"capabilities": {
|
||||
"clips": "",
|
||||
"favorite-events": "",
|
||||
"favorite-recordings": "",
|
||||
"live": "",
|
||||
"menu": "",
|
||||
"ptz": "",
|
||||
"recordings": "",
|
||||
"seek": "",
|
||||
"snapshots": "",
|
||||
"substream": ""
|
||||
}
|
||||
},
|
||||
"cast": {
|
||||
"dashboard": {
|
||||
"dashboard_path": "Ruta del tauler de control",
|
||||
@@ -520,7 +537,8 @@
|
||||
"no_camera_name": "No s'ha pogut determinar el nom de la càmera Frigate per a la càmera (o una de les seves dependències), especifiqueu 'camera_entity' o 'camera_name'",
|
||||
"no_dashboard_or_view": "Tant els paràmetres 'dashboard_path' com 'view_path' són necessaris per al mètode d'emissió 'dashboard'",
|
||||
"no_live_camera": "El paràmetre 'camera_entity' s'ha de configurar i validar per a aquest proveïdor en directe",
|
||||
"no_visible_cameras": "No s'han trobat càmeres visibles, heu de configurar almenys una càmera no oculta",
|
||||
"no_supported_camera": "",
|
||||
"no_supported_cameras": "",
|
||||
"reconnecting": "Reconnectant",
|
||||
"timeline_no_cameras": "No hi ha càmeres Frigate per mostrar a la línia de temps",
|
||||
"too_many_automations": "Hi ha massa trucades d'automatització imbricades, comproveu la vostra configuració per veure si hi ha bucles",
|
||||
|
||||
@@ -10,6 +10,23 @@
|
||||
"config": {
|
||||
"cameras": {
|
||||
"camera_entity": "Camera Entity",
|
||||
"capabilities": {
|
||||
"disable": "Disable",
|
||||
"disable_except": "Disable except",
|
||||
"editor_label": "Camera capabilities",
|
||||
"capabilities": {
|
||||
"clips": "Clips",
|
||||
"favorite-events": "Favorite Events",
|
||||
"favorite-recordings": "Favorite Recordings",
|
||||
"live": "Live",
|
||||
"menu": "Menu",
|
||||
"ptz": "PTZ",
|
||||
"recordings": "Recordings",
|
||||
"seek": "Seeking",
|
||||
"snapshots": "Snapshots",
|
||||
"substream": "Substream"
|
||||
}
|
||||
},
|
||||
"cast": {
|
||||
"dashboard": {
|
||||
"dashboard_path": "Dashboard path",
|
||||
@@ -50,7 +67,7 @@
|
||||
"frigate": {
|
||||
"camera_name": "Frigate camera name (Autodetected from entity)",
|
||||
"client_id": "Frigate client id (For >1 Frigate server)",
|
||||
"editor_label": "Frigate Options",
|
||||
"editor_label": "Frigate options",
|
||||
"labels": "Frigate labels/object filters",
|
||||
"url": "Frigate server URL",
|
||||
"zones": "Frigate zones"
|
||||
@@ -87,7 +104,7 @@
|
||||
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
|
||||
},
|
||||
"motioneye": {
|
||||
"editor_label": "MotionEye Options",
|
||||
"editor_label": "MotionEye options",
|
||||
"images": {
|
||||
"directory_pattern": "Images directory pattern",
|
||||
"file_pattern": "Images file pattern"
|
||||
@@ -527,9 +544,9 @@
|
||||
"no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'",
|
||||
"no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method",
|
||||
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
|
||||
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
|
||||
"no_supported_camera": "The selected camera does not support this view",
|
||||
"no_supported_cameras": "No cameras support this view",
|
||||
"reconnecting": "Reconnecting",
|
||||
"timeline_no_cameras": "No Frigate cameras to show in timeline",
|
||||
"too_many_automations": "Too many nested automation calls, please check your configuration for loops",
|
||||
"troubleshooting": "Check troubleshooting",
|
||||
"unknown": "Unknown error",
|
||||
|
||||
@@ -10,6 +10,23 @@
|
||||
"config": {
|
||||
"cameras": {
|
||||
"camera_entity": "Entità della telecamera",
|
||||
"capabilities": {
|
||||
"disable": "",
|
||||
"disable_except": "",
|
||||
"editor_label": "",
|
||||
"capabilities": {
|
||||
"clips": "",
|
||||
"favorite-events": "",
|
||||
"favorite-recordings": "",
|
||||
"live": "",
|
||||
"menu": "",
|
||||
"ptz": "",
|
||||
"recordings": "",
|
||||
"seek": "",
|
||||
"snapshots": "",
|
||||
"substream": ""
|
||||
}
|
||||
},
|
||||
"cast": {
|
||||
"dashboard": {
|
||||
"dashboard_path": "",
|
||||
@@ -518,7 +535,8 @@
|
||||
"no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'",
|
||||
"no_dashboard_or_view": "",
|
||||
"no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live",
|
||||
"no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta",
|
||||
"no_supported_camera": "",
|
||||
"no_supported_cameras": "",
|
||||
"reconnecting": "Riconnessione",
|
||||
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
|
||||
"too_many_automations": "",
|
||||
|
||||
@@ -10,6 +10,23 @@
|
||||
"config": {
|
||||
"cameras": {
|
||||
"camera_entity": "Entidade da Câmera",
|
||||
"capabilities": {
|
||||
"disable": "",
|
||||
"disable_except": "",
|
||||
"editor_label": "",
|
||||
"capabilities": {
|
||||
"clips": "",
|
||||
"favorite-events": "",
|
||||
"favorite-recordings": "",
|
||||
"live": "",
|
||||
"menu": "",
|
||||
"ptz": "",
|
||||
"recordings": "",
|
||||
"seek": "",
|
||||
"snapshots": "",
|
||||
"substream": ""
|
||||
}
|
||||
},
|
||||
"cast": {
|
||||
"dashboard": {
|
||||
"dashboard_path": "",
|
||||
@@ -525,7 +542,8 @@
|
||||
"no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir",
|
||||
"no_dashboard_or_view": "",
|
||||
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo",
|
||||
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
|
||||
"no_supported_camera": "",
|
||||
"no_supported_cameras": "",
|
||||
"reconnecting": "Reconectando",
|
||||
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
|
||||
"too_many_automations": "",
|
||||
|
||||
@@ -10,6 +10,23 @@
|
||||
"config": {
|
||||
"cameras": {
|
||||
"camera_entity": "Entidade da Câmera",
|
||||
"capabilities": {
|
||||
"disable": "",
|
||||
"disable_except": "",
|
||||
"editor_label": "",
|
||||
"capabilities": {
|
||||
"clips": "",
|
||||
"favorite-events": "",
|
||||
"favorite-recordings": "",
|
||||
"live": "",
|
||||
"menu": "",
|
||||
"ptz": "",
|
||||
"recordings": "",
|
||||
"seek": "",
|
||||
"snapshots": "",
|
||||
"substream": ""
|
||||
}
|
||||
},
|
||||
"cast": {
|
||||
"dashboard": {
|
||||
"dashboard_path": "",
|
||||
@@ -511,7 +528,8 @@
|
||||
"no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir",
|
||||
"no_dashboard_or_view": "",
|
||||
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo",
|
||||
"no_visible_cameras": "Sem camaras visiveis",
|
||||
"no_supported_camera": "",
|
||||
"no_supported_cameras": "",
|
||||
"reconnecting": "A voltar a ligar",
|
||||
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
|
||||
"too_many_automations": "",
|
||||
|
||||
+45
-1
@@ -1,4 +1,8 @@
|
||||
import { HomeAssistant, LovelaceCardConfig, Themes } from '@dermotduffy/custom-card-helpers';
|
||||
import {
|
||||
HomeAssistant,
|
||||
LovelaceCardConfig,
|
||||
Themes,
|
||||
} from '@dermotduffy/custom-card-helpers';
|
||||
import { StyleInfo } from 'lit/directives/style-map.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -87,6 +91,46 @@ export interface CardHelpers {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type PTZMovementType = 'relative' | 'continuous';
|
||||
|
||||
export interface PTZCapabilities {
|
||||
panTilt?: PTZMovementType[];
|
||||
zoom?: PTZMovementType[];
|
||||
presets?: string[];
|
||||
}
|
||||
|
||||
export interface CapabilitiesRaw {
|
||||
live?: boolean;
|
||||
substream?: boolean;
|
||||
|
||||
clips?: boolean;
|
||||
recordings?: boolean;
|
||||
snapshots?: boolean;
|
||||
|
||||
'favorite-events'?: boolean;
|
||||
'favorite-recordings'?: boolean;
|
||||
|
||||
seek?: boolean;
|
||||
|
||||
ptz?: PTZCapabilities;
|
||||
|
||||
menu?: boolean;
|
||||
}
|
||||
|
||||
export type CapabilityKey = keyof CapabilitiesRaw;
|
||||
export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [
|
||||
'clips',
|
||||
'favorite-events',
|
||||
'favorite-recordings',
|
||||
'live',
|
||||
'menu',
|
||||
'ptz',
|
||||
'recordings',
|
||||
'seek',
|
||||
'snapshots',
|
||||
'substream',
|
||||
] as const;
|
||||
|
||||
// *************************************************************************
|
||||
// Home Assistant API types.
|
||||
// *************************************************************************
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { MediaQuery } from '../camera-manager/types';
|
||||
import { CapabilitySearchOptions, MediaQuery } from '../camera-manager/types';
|
||||
import { dispatchFrigateCardErrorEvent } from '../components/message';
|
||||
import { CardWideConfig, FrigateCardView } from '../config/types';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
|
||||
@@ -30,9 +30,16 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
useCache?: boolean;
|
||||
},
|
||||
): Promise<void> => {
|
||||
const capabilitySearch: CapabilitySearchOptions =
|
||||
!options?.eventsMediaType || options?.eventsMediaType === 'all'
|
||||
? {
|
||||
anyCapabilities: ['clips', 'snapshots'],
|
||||
}
|
||||
: options.eventsMediaType;
|
||||
|
||||
const cameraIDs = options?.allCameras
|
||||
? cameraManager.getStore().getVisibleCameraIDs()
|
||||
: cameraManager.getStore().getAllDependentCameras(view.camera);
|
||||
? cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch)
|
||||
: cameraManager.getStore().getAllDependentCameras(view.camera, capabilitySearch);
|
||||
if (!cameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
@@ -99,8 +106,8 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
},
|
||||
): Promise<void> => {
|
||||
const cameraIDs = options?.allCameras
|
||||
? cameraManager.getStore().getVisibleCameraIDs()
|
||||
: cameraManager.getStore().getAllDependentCameras(view.camera);
|
||||
? cameraManager.getStore().getCameraIDsWithCapability('recordings')
|
||||
: cameraManager.getStore().getAllDependentCameras(view.camera, 'recordings');
|
||||
if (!cameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
+8
-3
@@ -1,8 +1,8 @@
|
||||
import { CameraManagerCameraCapabilities } from '../camera-manager/types';
|
||||
import { Capabilities } from '../camera-manager/capabilities';
|
||||
import { FrigateCardPTZConfig, PTZ_CONTROL_ACTIONS } from '../config/types';
|
||||
|
||||
export const hasUsablePTZ = (
|
||||
capabilities: CameraManagerCameraCapabilities | null,
|
||||
capabilities: Capabilities | null,
|
||||
config: FrigateCardPTZConfig,
|
||||
): boolean => {
|
||||
for (const actionName of PTZ_CONTROL_ACTIONS) {
|
||||
@@ -10,5 +10,10 @@ export const hasUsablePTZ = (
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return !!capabilities?.ptz;
|
||||
const ptzCapabilities = capabilities?.getPTZCapabilities();
|
||||
return (
|
||||
!!ptzCapabilities?.panTilt?.length ||
|
||||
!!ptzCapabilities?.zoom?.length ||
|
||||
!!ptzCapabilities?.presets?.length
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { FrigateCardView } from '../config/types';
|
||||
|
||||
/**
|
||||
* Get cameraIDs that are relevant for a given view name based on camera
|
||||
* capability (if camera specified).
|
||||
*/
|
||||
export const getCameraIDsForViewName = (
|
||||
cameraManager: CameraManager,
|
||||
viewName: FrigateCardView,
|
||||
cameraID?: string,
|
||||
): Set<string> => {
|
||||
switch (viewName) {
|
||||
case 'image':
|
||||
case 'diagnostics':
|
||||
return cameraManager.getStore().getCameraIDs();
|
||||
|
||||
case 'live':
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
const capability =
|
||||
viewName === 'clip'
|
||||
? 'clips'
|
||||
: viewName === 'snapshot'
|
||||
? 'snapshots'
|
||||
: viewName === 'recording'
|
||||
? 'recordings'
|
||||
: viewName;
|
||||
return cameraID
|
||||
? cameraManager.getStore().getAllDependentCameras(cameraID, capability)
|
||||
: cameraManager.getStore().getCameraIDsWithCapability(capability);
|
||||
|
||||
case 'timeline':
|
||||
return cameraManager.getStore().getCameraIDsWithCapability({
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings'],
|
||||
});
|
||||
|
||||
case 'media':
|
||||
return cameraID
|
||||
? cameraManager.getStore().getAllDependentCameras(cameraID, {
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings'],
|
||||
})
|
||||
: cameraManager.getStore().getCameraIDsWithCapability({
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings'],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,8 +5,8 @@ import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/eng
|
||||
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js';
|
||||
import {
|
||||
callHASubscribeMessageHandler,
|
||||
createCameraCapabilities,
|
||||
createCameraConfig,
|
||||
createCapabilities,
|
||||
createHASS,
|
||||
} from '../test-utils.js';
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('Camera', () => {
|
||||
|
||||
describe('should get capabilities', async () => {
|
||||
it('when populated', async () => {
|
||||
const capabilities = createCameraCapabilities();
|
||||
const capabilities = createCapabilities();
|
||||
const camera = new Camera(createCameraConfig(), new GenericCameraManagerEngine(), {
|
||||
capabilities: capabilities,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Capabilities } from '../../src/camera-manager/capabilities';
|
||||
import { CapabilityKey, PTZCapabilities, capabilityKeys } from '../../src/types';
|
||||
|
||||
describe('Capabilities', () => {
|
||||
it('default capabilities', () => {
|
||||
const capabilities = new Capabilities({});
|
||||
for (const key of capabilityKeys) {
|
||||
expect(capabilities.has(key)).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
describe('simple presence checks', () => {
|
||||
it.each([
|
||||
'clips',
|
||||
'favorite-events',
|
||||
'favorite-recordings',
|
||||
'live',
|
||||
'menu',
|
||||
'recordings',
|
||||
'seek',
|
||||
'snapshots',
|
||||
'substream',
|
||||
] as const)('%s', (capability: CapabilityKey) => {
|
||||
const capabilities = new Capabilities({
|
||||
[capability]: true,
|
||||
});
|
||||
expect(capabilities.has(capability)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex matches', () => {
|
||||
it('string matcher', () => {
|
||||
const capabilities = new Capabilities({
|
||||
live: true,
|
||||
});
|
||||
expect(capabilities.matches('live')).toBeTruthy();
|
||||
expect(capabilities.matches('clips')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('allCapabilities matcher', () => {
|
||||
const capabilities = new Capabilities({
|
||||
live: true,
|
||||
clips: true,
|
||||
});
|
||||
expect(capabilities.matches({ allCapabilities: ['live', 'clips'] })).toBeTruthy();
|
||||
expect(
|
||||
capabilities.matches({ allCapabilities: ['live', 'snapshots'] }),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('anyCapabilities matcher', () => {
|
||||
const capabilities = new Capabilities({
|
||||
live: true,
|
||||
clips: true,
|
||||
});
|
||||
expect(capabilities.matches({ anyCapabilities: ['live', 'clips'] })).toBeTruthy();
|
||||
expect(
|
||||
capabilities.matches({ anyCapabilities: ['live', 'snapshots'] }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
capabilities.matches({ anyCapabilities: ['snapshots', 'menu'] }),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPTZCapabilities', () => {
|
||||
it('when unset', () => {
|
||||
const capabilities = new Capabilities({});
|
||||
expect(capabilities.getPTZCapabilities()).toBeNull();
|
||||
});
|
||||
|
||||
it('when set', () => {
|
||||
const ptz: PTZCapabilities = {
|
||||
panTilt: ['continuous' as const],
|
||||
presets: ['1', '2'],
|
||||
};
|
||||
const capabilities = new Capabilities({
|
||||
ptz: ptz,
|
||||
});
|
||||
expect(capabilities.getPTZCapabilities()).toBe(ptz);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable capabilities', () => {
|
||||
it('disable', () => {
|
||||
const capabilities = new Capabilities(
|
||||
{
|
||||
live: true,
|
||||
clips: true,
|
||||
snapshots: true,
|
||||
},
|
||||
{
|
||||
disable: ['live', 'clips'],
|
||||
},
|
||||
);
|
||||
expect(capabilities.has('live')).toBeFalsy();
|
||||
expect(capabilities.has('clips')).toBeFalsy();
|
||||
expect(capabilities.has('snapshots')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('disableExcept', () => {
|
||||
const capabilities = new Capabilities(
|
||||
{
|
||||
live: true,
|
||||
clips: true,
|
||||
snapshots: true,
|
||||
},
|
||||
{
|
||||
disableExcept: ['live', 'clips'],
|
||||
},
|
||||
);
|
||||
expect(capabilities.has('live')).toBeTruthy();
|
||||
expect(capabilities.has('clips')).toBeTruthy();
|
||||
expect(capabilities.has('snapshots')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -131,16 +131,6 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
|
||||
describe('should detect capabilities', () => {
|
||||
const nonBirdseyeBaseCapabilities = {
|
||||
canFavoriteEvents: true,
|
||||
canFavoriteRecordings: true,
|
||||
canSeek: true,
|
||||
supportsClips: true,
|
||||
supportsSnapshots: true,
|
||||
supportsRecordings: true,
|
||||
supportsTimeline: true,
|
||||
};
|
||||
|
||||
it('basic non-birdseye', async () => {
|
||||
const camera = new FrigateCamera(
|
||||
createCameraConfig({
|
||||
@@ -153,7 +143,13 @@ describe('FrigateCamera', () => {
|
||||
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual(nonBirdseyeBaseCapabilities);
|
||||
expect(camera.getCapabilities()?.has('favorite-events')).toBeTruthy();
|
||||
expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('seek')).toBeTruthy();
|
||||
expect(camera.getCapabilities()?.has('clips')).toBeTruthy();
|
||||
expect(camera.getCapabilities()?.has('live')).toBeTruthy();
|
||||
expect(camera.getCapabilities()?.has('snapshots')).toBeTruthy();
|
||||
expect(camera.getCapabilities()?.has('recordings')).toBeTruthy();
|
||||
expect(vi.mocked(getPTZInfo)).toBeCalled();
|
||||
});
|
||||
|
||||
@@ -169,15 +165,13 @@ describe('FrigateCamera', () => {
|
||||
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual({
|
||||
...nonBirdseyeBaseCapabilities,
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
supportsClips: false,
|
||||
supportsSnapshots: false,
|
||||
supportsRecordings: false,
|
||||
supportsTimeline: false,
|
||||
});
|
||||
expect(camera.getCapabilities()?.has('favorite-events')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('seek')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('clips')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('live')).toBeTruthy();
|
||||
expect(camera.getCapabilities()?.has('snapshots')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('recordings')).toBeFalsy();
|
||||
expect(vi.mocked(getPTZInfo)).not.toBeCalled();
|
||||
});
|
||||
|
||||
@@ -196,9 +190,7 @@ describe('FrigateCamera', () => {
|
||||
vi.mocked(getPTZInfo).mockRejectedValue(new Error());
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual({
|
||||
...nonBirdseyeBaseCapabilities,
|
||||
});
|
||||
expect(camera.getCapabilities()?.has('ptz')).toBeFalsy();
|
||||
expect(consoleSpy).toBeCalled();
|
||||
});
|
||||
|
||||
@@ -220,13 +212,11 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual({
|
||||
...nonBirdseyeBaseCapabilities,
|
||||
ptz: {
|
||||
panTilt: ['continuous'],
|
||||
zoom: ['continuous'],
|
||||
presets: ['preset01'],
|
||||
},
|
||||
expect(camera.getCapabilities()?.has('ptz')).toBeTruthy();
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
panTilt: ['continuous'],
|
||||
zoom: ['continuous'],
|
||||
presets: ['preset01'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,13 +238,11 @@ describe('FrigateCamera', () => {
|
||||
});
|
||||
await camera.initialize(createHASS(), mock<EntityRegistryManager>());
|
||||
|
||||
expect(camera.getCapabilities()).toEqual({
|
||||
...nonBirdseyeBaseCapabilities,
|
||||
ptz: {
|
||||
panTilt: ['relative'],
|
||||
zoom: ['relative'],
|
||||
presets: ['preset01'],
|
||||
},
|
||||
expect(camera.getCapabilities()?.has('ptz')).toBeTruthy();
|
||||
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||
panTilt: ['relative'],
|
||||
zoom: ['relative'],
|
||||
presets: ['preset01'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,15 +34,13 @@ describe('GenericCameraManagerEngine', () => {
|
||||
);
|
||||
|
||||
expect(camera.getConfig()).toEqual(config);
|
||||
expect(camera.getCapabilities()).toEqual({
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
});
|
||||
expect(camera.getCapabilities()).toBeTruthy();
|
||||
expect(camera.getCapabilities()?.has('favorite-events')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('favorite-recordings')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('seek')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('clips')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('recordings')).toBeFalsy();
|
||||
expect(camera.getCapabilities()?.has('snapshots')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should generate default event query', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import add from 'date-fns/add';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { Camera } from '../../src/camera-manager/camera';
|
||||
import { Capabilities } from '../../src/camera-manager/capabilities';
|
||||
import { CameraManagerEngine } from '../../src/camera-manager/engine';
|
||||
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
|
||||
import {
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraEvent,
|
||||
CameraManagerCameraCapabilities,
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerMediaCapabilities,
|
||||
Engine,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
QueryResultsType,
|
||||
QueryType,
|
||||
} from '../../src/camera-manager/types';
|
||||
import { sortMedia } from '../../src/camera-manager/utils';
|
||||
import { sortMedia } from '../../src/camera-manager/utils/sort-media';
|
||||
import { CardController } from '../../src/card-controller/controller';
|
||||
import { CameraConfig } from '../../src/config/types';
|
||||
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
|
||||
@@ -35,8 +35,8 @@ import { ViewMedia } from '../../src/view/media';
|
||||
import {
|
||||
TestViewMedia,
|
||||
createCamera,
|
||||
createCameraCapabilities,
|
||||
createCameraConfig,
|
||||
createCapabilities,
|
||||
createCardAPI,
|
||||
createConfig,
|
||||
createHASS,
|
||||
@@ -219,7 +219,7 @@ describe('CameraManager', async () => {
|
||||
cameras: {
|
||||
config?: CameraConfig;
|
||||
engineType?: Engine | null;
|
||||
capabilties?: CameraManagerCameraCapabilities;
|
||||
capabilties?: Capabilities;
|
||||
}[] = [{}],
|
||||
factory?: CameraManagerEngineFactory,
|
||||
): CameraManager => {
|
||||
@@ -249,7 +249,7 @@ describe('CameraManager', async () => {
|
||||
createCamera(
|
||||
cameraConfig,
|
||||
mockEngine,
|
||||
camera.capabilties ?? createCameraCapabilities(),
|
||||
camera.capabilties ?? createCapabilities(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -367,19 +367,6 @@ describe('CameraManager', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('with no cameras', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
|
||||
const manager = createCameraManager(api, mock<CameraManagerEngine>(), []);
|
||||
expect(await manager.initializeCamerasFromConfig()).toBeFalsy();
|
||||
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(
|
||||
new Error(
|
||||
'No visible cameras found, you must configure at least one non-hidden camera',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass events to triggers manager', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
@@ -1043,74 +1030,72 @@ describe('CameraManager', async () => {
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
|
||||
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
|
||||
expect(manager.getCameraCapabilities('id')).toEqual(createCameraCapabilities());
|
||||
expect(manager.getCameraCapabilities('id')).toEqual(createCapabilities());
|
||||
});
|
||||
});
|
||||
|
||||
describe('should get aggregate camera capabilities', () => {
|
||||
it('without camera', () => {
|
||||
const manager = createCameraManager(createCardAPI());
|
||||
expect(manager.getAggregateCameraCapabilities()).toEqual({
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
const capabilities = manager.getAggregateCameraCapabilities();
|
||||
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
expect(capabilities.has('favorite-events')).toBeFalsy();
|
||||
expect(capabilities.has('favorite-recordings')).toBeFalsy();
|
||||
expect(capabilities.has('seek')).toBeFalsy();
|
||||
|
||||
supportsPTZ: false,
|
||||
});
|
||||
expect(capabilities.has('live')).toBeFalsy();
|
||||
expect(capabilities.has('clips')).toBeFalsy();
|
||||
expect(capabilities.has('recordings')).toBeFalsy();
|
||||
expect(capabilities.has('snapshots')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('successfully', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = createCameraManager(api, mock<CameraManagerEngine>(), [
|
||||
{
|
||||
capabilties: {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
capabilties: new Capabilities({
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
seek: false,
|
||||
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
},
|
||||
live: false,
|
||||
clips: false,
|
||||
recordings: false,
|
||||
snapshots: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
config: createCameraConfig({ baseCameraConfig, id: 'another' }),
|
||||
capabilties: {
|
||||
canFavoriteEvents: true,
|
||||
canFavoriteRecordings: true,
|
||||
canSeek: true,
|
||||
capabilties: new Capabilities({
|
||||
'favorite-events': true,
|
||||
'favorite-recordings': true,
|
||||
seek: true,
|
||||
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
supportsTimeline: true,
|
||||
live: true,
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
|
||||
ptz: {},
|
||||
},
|
||||
ptz: {
|
||||
panTilt: ['continuous'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const hass = createHASS();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
|
||||
|
||||
expect(manager.getAggregateCameraCapabilities()).toEqual({
|
||||
canFavoriteEvents: true,
|
||||
canFavoriteRecordings: true,
|
||||
canSeek: true,
|
||||
const capabilities = manager.getAggregateCameraCapabilities();
|
||||
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
supportsTimeline: true,
|
||||
expect(capabilities.has('favorite-events')).toBeTruthy();
|
||||
expect(capabilities.has('favorite-recordings')).toBeTruthy();
|
||||
expect(capabilities.has('seek')).toBeTruthy();
|
||||
|
||||
supportsPTZ: true,
|
||||
});
|
||||
expect(capabilities.has('live')).toBeTruthy();
|
||||
expect(capabilities.has('clips')).toBeTruthy();
|
||||
expect(capabilities.has('recordings')).toBeTruthy();
|
||||
expect(capabilities.has('snapshots')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { Camera } from '../../src/camera-manager/camera.js';
|
||||
import { Capabilities } from '../../src/camera-manager/capabilities.js';
|
||||
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
|
||||
import { CameraManagerStore } from '../../src/camera-manager/store.js';
|
||||
import { Engine } from '../../src/camera-manager/types.js';
|
||||
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js';
|
||||
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js';
|
||||
import { createCameraConfig, TestViewMedia } from '../test-utils.js';
|
||||
import { TestViewMedia, createCameraConfig } from '../test-utils.js';
|
||||
|
||||
describe('CameraManagerStore', async () => {
|
||||
const configVisible = createCameraConfig({
|
||||
@@ -56,11 +57,6 @@ describe('CameraManagerStore', async () => {
|
||||
expect(store.getCameraCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('getVisibleCameraCount', async () => {
|
||||
const store = setupStore();
|
||||
expect(store.getVisibleCameraCount()).toBe(1);
|
||||
});
|
||||
|
||||
describe('getDefaultCameraID', () => {
|
||||
it('with camera', async () => {
|
||||
const store = setupStore();
|
||||
@@ -127,11 +123,6 @@ describe('CameraManagerStore', async () => {
|
||||
expect(store.getCameraIDs()).toEqual(new Set(['camera-visible', 'camera-hidden']));
|
||||
});
|
||||
|
||||
it('getVisibleCameraIDs', async () => {
|
||||
const store = setupStore();
|
||||
expect(store.getVisibleCameraIDs()).toEqual(new Set(['camera-visible']));
|
||||
});
|
||||
|
||||
it('reset', async () => {
|
||||
const store = setupStore();
|
||||
const cameras = [...store.getCameras().values()];
|
||||
@@ -139,7 +130,6 @@ describe('CameraManagerStore', async () => {
|
||||
await store.reset();
|
||||
|
||||
expect(store.getCameraCount()).toBe(0);
|
||||
expect(store.getVisibleCameraCount()).toBe(0);
|
||||
for (const camera of cameras) {
|
||||
expect(camera.destroy).toBeCalled();
|
||||
}
|
||||
@@ -258,5 +248,60 @@ describe('CameraManagerStore', async () => {
|
||||
);
|
||||
expect(store.getAllDependentCameras('one')).toEqual(new Set(['one', 'two']));
|
||||
});
|
||||
|
||||
it('should return cameras with specific capabilities', () => {
|
||||
const store = new CameraManagerStore();
|
||||
store.addCamera(
|
||||
new Camera(
|
||||
createCameraConfig({
|
||||
id: 'one',
|
||||
dependencies: {
|
||||
all_cameras: true,
|
||||
},
|
||||
}),
|
||||
engineGeneric,
|
||||
),
|
||||
);
|
||||
store.addCamera(
|
||||
new Camera(
|
||||
createCameraConfig({
|
||||
id: 'two',
|
||||
}),
|
||||
engineGeneric,
|
||||
{
|
||||
capabilities: new Capabilities({
|
||||
clips: true,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(store.getAllDependentCameras('one', 'clips')).toEqual(new Set(['two']));
|
||||
});
|
||||
});
|
||||
|
||||
it('getCameraIDsWithCapability', () => {
|
||||
const store = new CameraManagerStore();
|
||||
store.addCamera(
|
||||
new Camera(
|
||||
createCameraConfig({
|
||||
id: 'one',
|
||||
}),
|
||||
engineGeneric,
|
||||
{
|
||||
capabilities: new Capabilities({
|
||||
clips: true,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
store.addCamera(
|
||||
new Camera(
|
||||
createCameraConfig({
|
||||
id: 'two',
|
||||
}),
|
||||
engineGeneric,
|
||||
),
|
||||
);
|
||||
expect(store.getCameraIDsWithCapability('clips')).toEqual(new Set(['one']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
capEndDate,
|
||||
convertRangeToCacheFriendlyTimes,
|
||||
getCameraEntityFromConfig,
|
||||
getDefaultGo2RTCEndpoint,
|
||||
sortMedia,
|
||||
} from '../../src/camera-manager/utils.js';
|
||||
import { CameraConfig, cameraConfigSchema } from '../../src/config/types.js';
|
||||
import { TestViewMedia, createCameraConfig } from '../test-utils.js';
|
||||
|
||||
describe('convertRangeToCacheFriendlyTimes', () => {
|
||||
it('should return cache friendly within hour range', () => {
|
||||
expect(
|
||||
convertRangeToCacheFriendlyTimes({
|
||||
start: new Date('2023-04-29T14:01:02'),
|
||||
end: new Date('2023-04-29T14:11:03'),
|
||||
}),
|
||||
).toEqual({
|
||||
start: new Date('2023-04-29T14:00:00'),
|
||||
end: new Date('2023-04-29T14:59:59.999'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return cache friendly within day range', () => {
|
||||
expect(
|
||||
convertRangeToCacheFriendlyTimes({
|
||||
start: new Date('2023-04-29T14:01:02'),
|
||||
end: new Date('2023-04-29T15:11:03'),
|
||||
}),
|
||||
).toEqual({
|
||||
start: new Date('2023-04-29T00:00:00'),
|
||||
end: new Date('2023-04-29T23:59:59.999'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should cap end date', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2023-04-29T14:25'));
|
||||
expect(
|
||||
convertRangeToCacheFriendlyTimes(
|
||||
{
|
||||
start: new Date('2023-04-29T14:01:02'),
|
||||
end: new Date('2023-04-29T14:11:03'),
|
||||
},
|
||||
{ endCap: true },
|
||||
),
|
||||
).toEqual({
|
||||
start: new Date('2023-04-29T14:00:00'),
|
||||
end: new Date('2023-04-29T14:25:59.999'),
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capEndDate', () => {
|
||||
it('should cap end date', () => {
|
||||
const fakeNow = new Date('2023-04-29T14:25');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeNow);
|
||||
|
||||
expect(capEndDate(new Date('2023-04-29T15:02'))).toEqual(fakeNow);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not cap end date', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2023-04-29T14:25'));
|
||||
|
||||
const testDate = new Date('2023-04-29T14:24');
|
||||
expect(capEndDate(testDate)).toEqual(testDate);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortMedia', () => {
|
||||
const media_1 = new TestViewMedia({
|
||||
id: 'id-1',
|
||||
startTime: new Date('2023-04-29T14:25'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const media_2 = new TestViewMedia({
|
||||
id: 'id-2',
|
||||
startTime: new Date('2023-04-29T14:26'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const media_3_dup_id = new TestViewMedia({
|
||||
id: 'id-2',
|
||||
startTime: new Date('2023-04-29T14:26'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const media_4_no_id = new TestViewMedia({
|
||||
id: null,
|
||||
startTime: new Date('2023-04-29T14:27'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
|
||||
it('should sort sorted media', () => {
|
||||
const media = [media_1, media_2];
|
||||
expect(sortMedia(media)).toEqual(media);
|
||||
});
|
||||
it('should sort unsorted media', () => {
|
||||
expect(sortMedia([media_2, media_1])).toEqual([media_1, media_2]);
|
||||
});
|
||||
it('should remove duplicate id', () => {
|
||||
expect(sortMedia([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]);
|
||||
});
|
||||
it('should sort by id when time not available', () => {
|
||||
expect(
|
||||
sortMedia([
|
||||
new TestViewMedia({ id: 'snake' }),
|
||||
new TestViewMedia({ id: 'zebra' }),
|
||||
new TestViewMedia({ id: 'aardvark' }),
|
||||
]),
|
||||
).toEqual([
|
||||
new TestViewMedia({ id: 'aardvark' }),
|
||||
new TestViewMedia({ id: 'snake' }),
|
||||
new TestViewMedia({ id: 'zebra' }),
|
||||
]);
|
||||
});
|
||||
it('should remove de-duplicate by object if no id', () => {
|
||||
expect(sortMedia([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([
|
||||
media_1,
|
||||
media_2,
|
||||
media_4_no_id,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCameraEntityFromConfig', () => {
|
||||
const createCameraConfig = (config: Partial<CameraConfig>): CameraConfig => {
|
||||
return cameraConfigSchema.parse(config);
|
||||
};
|
||||
|
||||
it('should get camera_entity', () => {
|
||||
expect(getCameraEntityFromConfig(createCameraConfig({ camera_entity: 'foo' }))).toBe(
|
||||
'foo',
|
||||
);
|
||||
});
|
||||
it('should get camera_entity from webrtc_card config', () => {
|
||||
expect(
|
||||
getCameraEntityFromConfig(createCameraConfig({ webrtc_card: { entity: 'bar' } })),
|
||||
).toBe('bar');
|
||||
});
|
||||
it('should get no camera_entity', () => {
|
||||
expect(getCameraEntityFromConfig(createCameraConfig({}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultGo2RTCEndpoint', () => {
|
||||
it('with local configuration', () => {
|
||||
expect(
|
||||
getDefaultGo2RTCEndpoint(
|
||||
createCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
url: '/local/path',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
endpoint: '/local/path/api/ws?src=stream',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('with remote configuration', () => {
|
||||
expect(
|
||||
getDefaultGo2RTCEndpoint(
|
||||
createCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
url: 'https://my-custom-go2rtc',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
endpoint: 'https://my-custom-go2rtc/api/ws?src=stream',
|
||||
sign: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('without configuration', () => {
|
||||
expect(getDefaultGo2RTCEndpoint(createCameraConfig())).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getCameraEntityFromConfig } from '../../../src/camera-manager/utils/camera-entity-from-config.js';
|
||||
import { CameraConfig, cameraConfigSchema } from '../../../src/config/types.js';
|
||||
|
||||
describe('getCameraEntityFromConfig', () => {
|
||||
const createCameraConfig = (config: Partial<CameraConfig>): CameraConfig => {
|
||||
return cameraConfigSchema.parse(config);
|
||||
};
|
||||
|
||||
it('should get camera_entity', () => {
|
||||
expect(getCameraEntityFromConfig(createCameraConfig({ camera_entity: 'foo' }))).toBe(
|
||||
'foo',
|
||||
);
|
||||
});
|
||||
it('should get camera_entity from webrtc_card config', () => {
|
||||
expect(
|
||||
getCameraEntityFromConfig(createCameraConfig({ webrtc_card: { entity: 'bar' } })),
|
||||
).toBe('bar');
|
||||
});
|
||||
it('should get no camera_entity', () => {
|
||||
expect(getCameraEntityFromConfig(createCameraConfig({}))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { capEndDate } from '../../../src/camera-manager/utils/cap-end-date';
|
||||
|
||||
describe('capEndDate', () => {
|
||||
it('should cap end date', () => {
|
||||
const fakeNow = new Date('2023-04-29T14:25');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeNow);
|
||||
|
||||
expect(capEndDate(new Date('2023-04-29T15:02'))).toEqual(fakeNow);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not cap end date', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2023-04-29T14:25'));
|
||||
|
||||
const testDate = new Date('2023-04-29T14:24');
|
||||
expect(capEndDate(testDate)).toEqual(testDate);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDefaultGo2RTCEndpoint } from '../../../src/camera-manager/utils/go2rtc-endpoint.js';
|
||||
import { createCameraConfig } from '../../test-utils.js';
|
||||
|
||||
describe('getDefaultGo2RTCEndpoint', () => {
|
||||
it('with local configuration', () => {
|
||||
expect(
|
||||
getDefaultGo2RTCEndpoint(
|
||||
createCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
url: '/local/path',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
endpoint: '/local/path/api/ws?src=stream',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('with remote configuration', () => {
|
||||
expect(
|
||||
getDefaultGo2RTCEndpoint(
|
||||
createCameraConfig({
|
||||
go2rtc: {
|
||||
stream: 'stream',
|
||||
url: 'https://my-custom-go2rtc',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
endpoint: 'https://my-custom-go2rtc/api/ws?src=stream',
|
||||
sign: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('without configuration', () => {
|
||||
expect(getDefaultGo2RTCEndpoint(createCameraConfig())).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../../../src/camera-manager/utils/range-to-cache-friendly';
|
||||
|
||||
describe('convertRangeToCacheFriendlyTimes', () => {
|
||||
it('should return cache friendly within hour range', () => {
|
||||
expect(
|
||||
convertRangeToCacheFriendlyTimes({
|
||||
start: new Date('2023-04-29T14:01:02'),
|
||||
end: new Date('2023-04-29T14:11:03'),
|
||||
}),
|
||||
).toEqual({
|
||||
start: new Date('2023-04-29T14:00:00'),
|
||||
end: new Date('2023-04-29T14:59:59.999'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return cache friendly within day range', () => {
|
||||
expect(
|
||||
convertRangeToCacheFriendlyTimes({
|
||||
start: new Date('2023-04-29T14:01:02'),
|
||||
end: new Date('2023-04-29T15:11:03'),
|
||||
}),
|
||||
).toEqual({
|
||||
start: new Date('2023-04-29T00:00:00'),
|
||||
end: new Date('2023-04-29T23:59:59.999'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should cap end date', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2023-04-29T14:25'));
|
||||
expect(
|
||||
convertRangeToCacheFriendlyTimes(
|
||||
{
|
||||
start: new Date('2023-04-29T14:01:02'),
|
||||
end: new Date('2023-04-29T14:11:03'),
|
||||
},
|
||||
{ endCap: true },
|
||||
),
|
||||
).toEqual({
|
||||
start: new Date('2023-04-29T14:00:00'),
|
||||
end: new Date('2023-04-29T14:25:59.999'),
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sortMedia } from '../../../src/camera-manager/utils/sort-media.js';
|
||||
import { TestViewMedia } from '../../test-utils.js';
|
||||
|
||||
describe('sortMedia', () => {
|
||||
const media_1 = new TestViewMedia({
|
||||
id: 'id-1',
|
||||
startTime: new Date('2023-04-29T14:25'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const media_2 = new TestViewMedia({
|
||||
id: 'id-2',
|
||||
startTime: new Date('2023-04-29T14:26'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const media_3_dup_id = new TestViewMedia({
|
||||
id: 'id-2',
|
||||
startTime: new Date('2023-04-29T14:26'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const media_4_no_id = new TestViewMedia({
|
||||
id: null,
|
||||
startTime: new Date('2023-04-29T14:27'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
|
||||
it('should sort sorted media', () => {
|
||||
const media = [media_1, media_2];
|
||||
expect(sortMedia(media)).toEqual(media);
|
||||
});
|
||||
it('should sort unsorted media', () => {
|
||||
expect(sortMedia([media_2, media_1])).toEqual([media_1, media_2]);
|
||||
});
|
||||
it('should remove duplicate id', () => {
|
||||
expect(sortMedia([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]);
|
||||
});
|
||||
it('should sort by id when time not available', () => {
|
||||
expect(
|
||||
sortMedia([
|
||||
new TestViewMedia({ id: 'snake' }),
|
||||
new TestViewMedia({ id: 'zebra' }),
|
||||
new TestViewMedia({ id: 'aardvark' }),
|
||||
]),
|
||||
).toEqual([
|
||||
new TestViewMedia({ id: 'aardvark' }),
|
||||
new TestViewMedia({ id: 'snake' }),
|
||||
new TestViewMedia({ id: 'zebra' }),
|
||||
]);
|
||||
});
|
||||
it('should remove de-duplicate by object if no id', () => {
|
||||
expect(sortMedia([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([
|
||||
media_1,
|
||||
media_2,
|
||||
media_4_no_id,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,9 @@ import { EventMediaQueries } from '../../src/view/media-queries';
|
||||
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||
import { View } from '../../src/view/view';
|
||||
import {
|
||||
createAggregateCameraCapabilities,
|
||||
createCameraCapabilities,
|
||||
createCameraConfig,
|
||||
createCameraManager,
|
||||
createCapabilities,
|
||||
createCardAPI,
|
||||
createConfig,
|
||||
createHASS,
|
||||
@@ -120,6 +119,7 @@ describe('ViewManager.setViewDefault', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
capabilities: createCapabilities({ live: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -150,9 +150,11 @@ describe('ViewManager.setViewDefault', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera_1',
|
||||
capabilities: createCapabilities({ live: true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera_2',
|
||||
capabilities: createCapabilities({ live: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -186,9 +188,11 @@ describe('ViewManager.setViewDefault', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ live: true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ live: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -216,12 +220,13 @@ describe('ViewManager.setViewByParameters', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ clips: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||
createAggregateCameraCapabilities({
|
||||
supportsClips: true,
|
||||
createCapabilities({
|
||||
clips: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -242,15 +247,17 @@ describe('ViewManager.setViewByParameters', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ clips: true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({ clips: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||
createAggregateCameraCapabilities({
|
||||
supportsClips: true,
|
||||
createCapabilities({
|
||||
clips: true,
|
||||
}),
|
||||
);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
@@ -276,6 +283,7 @@ describe('ViewManager.setViewByParameters', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ live: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -302,49 +310,102 @@ describe('ViewManager.setViewByParameters', () => {
|
||||
expect(manager.getView()).toBeNull();
|
||||
});
|
||||
|
||||
it('should not set view by parameters without visible cameras', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
config: createCameraConfig({
|
||||
hide: true,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
|
||||
const manager = new ViewManager(api);
|
||||
manager.setViewByParameters({
|
||||
viewName: 'live',
|
||||
});
|
||||
|
||||
expect(manager.getView()).toBeNull();
|
||||
});
|
||||
|
||||
describe('should handle unsupported view', () => {
|
||||
it('without failsafe', () => {
|
||||
it('without camera without failsafe', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ snapshots: false }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
|
||||
const manager = new ViewManager(api);
|
||||
manager.setViewByParameters({
|
||||
// Since no camera is specified, and no camera supports the capabilities
|
||||
// necessary for this view, the view will be null.
|
||||
viewName: 'snapshots',
|
||||
});
|
||||
|
||||
expect(manager.getView()).toBeNull();
|
||||
});
|
||||
|
||||
it('without camera with failsafe', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ snapshots: false }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
// No capabilities.
|
||||
capabilities: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
|
||||
const manager = new ViewManager(api);
|
||||
manager.setViewByParameters({
|
||||
// Since no camera is specified, and no camera supports the capabilities
|
||||
// necessary for this view, and since failSafe is specified, an error
|
||||
// will be shown.
|
||||
viewName: 'snapshots',
|
||||
failSafe: true,
|
||||
});
|
||||
|
||||
expect(manager.getView()).toBeNull();
|
||||
expect(manager.hasView()).toBeFalsy();
|
||||
expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'No cameras support this view',
|
||||
context: {
|
||||
view: 'snapshots',
|
||||
cameras_capabilities: {
|
||||
'camera.kitchen': {
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
clips: false,
|
||||
live: false,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('with camera without failsafe', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ snapshots: false }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||
createAggregateCameraCapabilities({
|
||||
supportsSnapshots: false,
|
||||
createCapabilities({
|
||||
snapshots: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new ViewManager(api);
|
||||
manager.setViewByParameters({
|
||||
cameraID: 'camera.kitchen',
|
||||
viewName: 'snapshots',
|
||||
});
|
||||
|
||||
@@ -352,25 +413,28 @@ describe('ViewManager.setViewByParameters', () => {
|
||||
expect(manager.getView()).toBeNull();
|
||||
});
|
||||
|
||||
it('with failsafe', () => {
|
||||
it('with camera with failsafe', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ snapshots: false, live: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||
createAggregateCameraCapabilities({
|
||||
supportsSnapshots: false,
|
||||
createCapabilities({
|
||||
snapshots: false,
|
||||
live: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new ViewManager(api);
|
||||
manager.setViewByParameters({
|
||||
cameraID: 'camera.kitchen',
|
||||
viewName: 'snapshots',
|
||||
failSafe: true,
|
||||
});
|
||||
@@ -378,6 +442,55 @@ describe('ViewManager.setViewByParameters', () => {
|
||||
expect(manager.hasView()).toBeTruthy();
|
||||
expect(manager.getView()?.view).toBe('live');
|
||||
});
|
||||
|
||||
it('with camera with failsafe when live unsupported', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
|
||||
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({ snapshots: false, live: false }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||
createCapabilities({
|
||||
snapshots: false,
|
||||
live: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new ViewManager(api);
|
||||
manager.setViewByParameters({
|
||||
cameraID: 'camera.kitchen',
|
||||
viewName: 'snapshots',
|
||||
failSafe: true,
|
||||
});
|
||||
|
||||
expect(manager.hasView()).toBeFalsy();
|
||||
expect(manager.getView()).toBeNull();
|
||||
expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'The selected camera does not support this view',
|
||||
context: {
|
||||
view: 'snapshots',
|
||||
camera: 'camera.kitchen',
|
||||
camera_capabilities: {
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
clips: false,
|
||||
live: false,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should set view by parameters and respect display mode in config for view', () => {
|
||||
@@ -394,14 +507,21 @@ describe('ViewManager.setViewByParameters', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({
|
||||
live: true,
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||
createAggregateCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
createCapabilities({
|
||||
live: true,
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -445,14 +565,21 @@ describe('ViewManager.setViewByParameters', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCapabilities({
|
||||
live: true,
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
|
||||
createAggregateCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
createCapabilities({
|
||||
live: true,
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -502,18 +629,18 @@ describe('ViewManager.setViewWithNewDisplayMode', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
capabilities: createCapabilities({
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
capabilities: createCapabilities({
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
@@ -556,18 +683,18 @@ describe('ViewManager.setViewWithNewDisplayMode', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
capabilities: createCapabilities({
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
capabilities: createCapabilities({
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
@@ -614,18 +741,18 @@ describe('ViewManager.setViewWithNewDisplayMode', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
capabilities: createCapabilities({
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
capabilities: createCapabilities({
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
@@ -662,18 +789,18 @@ describe('ViewManager.setViewWithNewDisplayMode', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: createCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
capabilities: createCapabilities({
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCameraCapabilities({
|
||||
supportsClips: true,
|
||||
supportsRecordings: true,
|
||||
supportsSnapshots: true,
|
||||
capabilities: createCapabilities({
|
||||
clips: true,
|
||||
recordings: true,
|
||||
snapshots: true,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
@@ -873,7 +1000,7 @@ describe('ViewManager.setViewWithSubstream', () => {
|
||||
|
||||
describe('ViewManager.isViewSupportedByCamera', () => {
|
||||
it.each([
|
||||
['live' as const, true],
|
||||
['live' as const, false],
|
||||
['image' as const, true],
|
||||
['diagnostics' as const, true],
|
||||
['clip' as const, false],
|
||||
@@ -891,15 +1018,15 @@ describe('ViewManager.isViewSupportedByCamera', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
capabilities: {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
},
|
||||
capabilities: createCapabilities({
|
||||
live: false,
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
seek: false,
|
||||
clips: false,
|
||||
recordings: false,
|
||||
snapshots: false,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import startOfDay from 'date-fns/startOfDay';
|
||||
import sub from 'date-fns/sub';
|
||||
import { LitElement } from 'lit';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Capabilities } from '../../src/camera-manager/capabilities';
|
||||
import { CameraManagerStore } from '../../src/camera-manager/store';
|
||||
import { QueryType } from '../../src/camera-manager/types';
|
||||
import {
|
||||
@@ -10,21 +11,21 @@ import {
|
||||
MediaFilterCoreDefaults,
|
||||
MediaFilterCoreFavoriteSelection,
|
||||
MediaFilterCoreWhen,
|
||||
MediaFilterMediaType
|
||||
MediaFilterMediaType,
|
||||
} from '../../src/components-lib/media-filter-controller';
|
||||
import { executeMediaQueryForViewWithErrorDispatching } from '../../src/utils/media-to-view';
|
||||
import {
|
||||
EventMediaQueries,
|
||||
MediaQueries,
|
||||
RecordingMediaQueries
|
||||
RecordingMediaQueries,
|
||||
} from '../../src/view/media-queries';
|
||||
import {
|
||||
createAggregateCameraCapabilities,
|
||||
createCameraConfig,
|
||||
createCameraManager,
|
||||
createCapabilities,
|
||||
createPerformanceConfig,
|
||||
createStore,
|
||||
createView
|
||||
createView,
|
||||
} from '../test-utils';
|
||||
|
||||
vi.mock('../../src/utils/media-to-view');
|
||||
@@ -35,13 +36,22 @@ const createHost = (): LitElement => {
|
||||
return host;
|
||||
};
|
||||
|
||||
const createCameraStore = (): CameraManagerStore => {
|
||||
const createCameraStore = (options?: {
|
||||
capabilities: Capabilities;
|
||||
}): CameraManagerStore => {
|
||||
return createStore([
|
||||
{
|
||||
cameraID: 'camera.kitchen',
|
||||
config: createCameraConfig({
|
||||
camera_entity: 'camera.kitchen',
|
||||
}),
|
||||
capabilities:
|
||||
options?.capabilities ??
|
||||
new Capabilities({
|
||||
clips: true,
|
||||
snapshots: true,
|
||||
recordings: true,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -152,6 +162,27 @@ describe('MediaFilterController', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('with camera that does not support media', () => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
|
||||
title: 'Kitchen Camera',
|
||||
icon: 'mdi:camera',
|
||||
});
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||
createCameraStore({
|
||||
capabilities: new Capabilities({
|
||||
clips: false,
|
||||
snapshots: false,
|
||||
recordings: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const controller = new MediaFilterController(createHost());
|
||||
controller.computeCameraOptions(cameraManager);
|
||||
expect(controller.getCameraOptions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('without camera metadata', () => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue(null);
|
||||
@@ -297,7 +328,7 @@ describe('MediaFilterController', () => {
|
||||
const view = createView({ query: new EventMediaQueries() });
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
|
||||
createAggregateCameraCapabilities({ canFavoriteEvents: true }),
|
||||
createCapabilities({ 'favorite-events': true }),
|
||||
);
|
||||
|
||||
const controller = new MediaFilterController(createHost());
|
||||
@@ -310,7 +341,7 @@ describe('MediaFilterController', () => {
|
||||
const view = createView({ query: new RecordingMediaQueries() });
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
|
||||
createAggregateCameraCapabilities({ canFavoriteRecordings: true }),
|
||||
createCapabilities({ 'favorite-recordings': true }),
|
||||
);
|
||||
|
||||
const controller = new MediaFilterController(createHost());
|
||||
@@ -915,7 +946,7 @@ describe('MediaFilterController', () => {
|
||||
},
|
||||
]),
|
||||
{
|
||||
mediaType: MediaFilterMediaType.Recordings
|
||||
mediaType: MediaFilterMediaType.Recordings,
|
||||
},
|
||||
],
|
||||
])(
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ViewMedia } from '../../src/view/media';
|
||||
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||
import { View } from '../../src/view/view';
|
||||
import {
|
||||
createCameraCapabilities,
|
||||
createCapabilities,
|
||||
createCameraConfig,
|
||||
createCameraManager,
|
||||
createCardAPI,
|
||||
@@ -108,7 +108,11 @@ describe('MenuButtonController', () => {
|
||||
it('with multiple cameras', () => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||
createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]),
|
||||
createStore([
|
||||
{ cameraID: 'camera-1', capabilities: createCapabilities({ menu: true }) },
|
||||
{ cameraID: 'camera-2', capabilities: createCapabilities({ menu: true }) },
|
||||
{ cameraID: 'camera-3', capabilities: createCapabilities({ menu: false }) },
|
||||
]),
|
||||
);
|
||||
vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({
|
||||
title: 'title',
|
||||
@@ -181,7 +185,10 @@ describe('MenuButtonController', () => {
|
||||
cameraID: 'camera-1',
|
||||
config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
|
||||
},
|
||||
{ cameraID: 'camera-2' },
|
||||
{
|
||||
cameraID: 'camera-2',
|
||||
capabilities: createCapabilities({ substream: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
||||
@@ -208,7 +215,10 @@ describe('MenuButtonController', () => {
|
||||
cameraID: 'camera-1',
|
||||
config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
|
||||
},
|
||||
{ cameraID: 'camera-2' },
|
||||
{
|
||||
cameraID: 'camera-2',
|
||||
capabilities: createCapabilities({ substream: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const view = createView({
|
||||
@@ -248,18 +258,21 @@ describe('MenuButtonController', () => {
|
||||
camera_entity: 'camera.1',
|
||||
dependencies: { cameras: ['camera-2', 'camera-3'] },
|
||||
}),
|
||||
capabilities: createCapabilities({ substream: true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera-2',
|
||||
config: createCameraConfig({
|
||||
camera_entity: 'camera.2',
|
||||
}),
|
||||
capabilities: createCapabilities({ substream: true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera-3',
|
||||
config: createCameraConfig({
|
||||
camera_entity: 'camera.3',
|
||||
}),
|
||||
capabilities: createCapabilities({ substream: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -340,18 +353,21 @@ describe('MenuButtonController', () => {
|
||||
camera_entity: 'camera.1',
|
||||
dependencies: { cameras: ['camera-2', 'camera-3'] },
|
||||
}),
|
||||
capabilities: createCapabilities({ substream: true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera-2',
|
||||
config: createCameraConfig({
|
||||
camera_entity: 'camera.2',
|
||||
}),
|
||||
capabilities: createCapabilities({ substream: true }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera-3',
|
||||
config: createCameraConfig({
|
||||
camera_entity: 'camera.3',
|
||||
}),
|
||||
capabilities: createCapabilities({ substream: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -1207,10 +1223,17 @@ describe('MenuButtonController', () => {
|
||||
it.each([['single' as const], ['grid' as const]])(
|
||||
'%s',
|
||||
(displayMode: ViewDisplayMode) => {
|
||||
const view = createView({ view: 'live', displayMode: displayMode });
|
||||
const view = createView({
|
||||
camera: 'camera-1',
|
||||
view: 'live',
|
||||
displayMode: displayMode,
|
||||
});
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||
createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]),
|
||||
createStore([
|
||||
{ cameraID: 'camera-1', capabilities: createCapabilities({ live: true }) },
|
||||
{ cameraID: 'camera-2', capabilities: createCapabilities({ live: true }) },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -1239,7 +1262,7 @@ describe('MenuButtonController', () => {
|
||||
it('when the selected camera is not PTZ enabled', () => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
|
||||
createCameraCapabilities(),
|
||||
createCapabilities(),
|
||||
);
|
||||
|
||||
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
||||
@@ -1264,7 +1287,7 @@ describe('MenuButtonController', () => {
|
||||
it('when the selected camera is PTZ enabled', () => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
|
||||
createCameraCapabilities({ ptz: {} }),
|
||||
createCapabilities({ ptz: { panTilt: ['relative'] } }),
|
||||
);
|
||||
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
|
||||
|
||||
@@ -1288,7 +1311,7 @@ describe('MenuButtonController', () => {
|
||||
it('when the context has PTZ visiblity turned off', () => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
|
||||
createCameraCapabilities({ ptz: {} }),
|
||||
createCapabilities({ ptz: { panTilt: ['relative'] } }),
|
||||
);
|
||||
const view = createView({
|
||||
camera: 'camera-1',
|
||||
@@ -1328,9 +1351,9 @@ describe('MenuButtonController', () => {
|
||||
vi.mocked(cameraManager.getCameraCapabilities).mockImplementation(
|
||||
(cameraID: string) => {
|
||||
if (cameraID === 'camera-2') {
|
||||
return createCameraCapabilities({ ptz: {} });
|
||||
return createCapabilities({ ptz: { panTilt: ['relative'] } });
|
||||
}
|
||||
return createCameraCapabilities();
|
||||
return createCapabilities();
|
||||
},
|
||||
);
|
||||
const view = createView({
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getActionConfigGivenAction,
|
||||
} from '../../src/utils/action.js';
|
||||
import {
|
||||
createCameraCapabilities,
|
||||
createCapabilities,
|
||||
createCameraManager,
|
||||
createHASS,
|
||||
} from '../test-utils';
|
||||
@@ -145,7 +145,7 @@ describe('PTZController', () => {
|
||||
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue(
|
||||
createCameraCapabilities({
|
||||
createCapabilities({
|
||||
ptz: {
|
||||
panTilt: ['continuous'],
|
||||
zoom: ['continuous'],
|
||||
@@ -185,7 +185,7 @@ describe('PTZController', () => {
|
||||
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue(
|
||||
createCameraCapabilities({
|
||||
createCapabilities({
|
||||
ptz: {
|
||||
panTilt: ['relative'],
|
||||
zoom: ['relative'],
|
||||
@@ -210,7 +210,7 @@ describe('PTZController', () => {
|
||||
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue(
|
||||
createCameraCapabilities({
|
||||
createCapabilities({
|
||||
ptz: {
|
||||
presets: ['preset-foo'],
|
||||
},
|
||||
@@ -237,7 +237,7 @@ describe('PTZController', () => {
|
||||
['down' as const],
|
||||
['zoom_in' as const],
|
||||
['zoom_out' as const],
|
||||
])('%s', (actionName: PTZControlAction) => {
|
||||
])('configured %s', (actionName: PTZControlAction) => {
|
||||
const controller = new PTZController(document.createElement('div'));
|
||||
controller.setConfig(
|
||||
createConfig({
|
||||
@@ -253,7 +253,7 @@ describe('PTZController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('home', () => {
|
||||
it('configured home', () => {
|
||||
const controller = new PTZController(document.createElement('div'));
|
||||
controller.setConfig(
|
||||
createConfig({
|
||||
|
||||
+533
-509
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ describe('config defaults', () => {
|
||||
frigate: {
|
||||
client_id: 'frigate',
|
||||
},
|
||||
hide: false,
|
||||
image: {
|
||||
refresh_seconds: 1,
|
||||
},
|
||||
|
||||
+18
-33
@@ -3,6 +3,7 @@ import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { Camera } from '../src/camera-manager/camera';
|
||||
import { Capabilities } from '../src/camera-manager/capabilities';
|
||||
import { CameraManagerEngine } from '../src/camera-manager/engine';
|
||||
import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types';
|
||||
import { GenericCameraManagerEngine } from '../src/camera-manager/generic/engine-generic';
|
||||
@@ -10,10 +11,9 @@ import { CameraManager } from '../src/camera-manager/manager';
|
||||
import { CameraManagerStore } from '../src/camera-manager/store';
|
||||
import {
|
||||
CameraEventCallback,
|
||||
CameraManagerCameraCapabilities,
|
||||
CameraManagerCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
} from '../src/camera-manager/types';
|
||||
import { CapabilitiesRaw } from '../src/types';
|
||||
import { ActionsManager } from '../src/card-controller/actions-manager';
|
||||
import { AutoUpdateManager } from '../src/card-controller/auto-update-manager';
|
||||
import { AutomationsManager } from '../src/card-controller/automations-manager';
|
||||
@@ -75,7 +75,7 @@ export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig =
|
||||
export const createCamera = (
|
||||
config: CameraConfig,
|
||||
engine: CameraManagerEngine,
|
||||
capabilities?: CameraManagerCameraCapabilities,
|
||||
capabilities?: Capabilities,
|
||||
): Camera => {
|
||||
return new Camera(config, engine, { capabilities: capabilities });
|
||||
};
|
||||
@@ -185,7 +185,7 @@ export const createStore = (
|
||||
cameraID: string;
|
||||
engine?: CameraManagerEngine;
|
||||
config?: CameraConfig;
|
||||
capabilities?: CameraManagerCameraCapabilities;
|
||||
capabilities?: Capabilities | null;
|
||||
eventCallback?: CameraEventCallback;
|
||||
}[],
|
||||
): CameraManagerStore => {
|
||||
@@ -196,7 +196,10 @@ export const createStore = (
|
||||
cameraProps.config ?? createCameraConfig(),
|
||||
cameraProps.engine ?? new GenericCameraManagerEngine(eventCallback),
|
||||
{
|
||||
capabilities: cameraProps.capabilities ?? createCameraCapabilities(),
|
||||
capabilities:
|
||||
cameraProps.capabilities === undefined
|
||||
? createCapabilities()
|
||||
: cameraProps.capabilities ?? undefined,
|
||||
eventCallback: eventCallback,
|
||||
},
|
||||
);
|
||||
@@ -212,35 +215,17 @@ export const createCameraManager = (): CameraManager => {
|
||||
return cameraManager;
|
||||
};
|
||||
|
||||
export const createAggregateCameraCapabilities = (
|
||||
capabilities?: Partial<CameraManagerCapabilities>,
|
||||
): CameraManagerCapabilities => {
|
||||
return {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
supportsPTZ: false,
|
||||
export const createCapabilities = (capabilities?: CapabilitiesRaw): Capabilities => {
|
||||
return new Capabilities({
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
clips: false,
|
||||
live: false,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
...capabilities,
|
||||
};
|
||||
};
|
||||
|
||||
export const createCameraCapabilities = (
|
||||
capabilities?: Partial<CameraManagerCameraCapabilities>,
|
||||
): CameraManagerCameraCapabilities => {
|
||||
return {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
...capabilities,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const createMediaCapabilities = (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getCameraID } from '../../src/utils/camera.js';
|
||||
import { createCameraConfig } from '../test-utils.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEndpointAddressOrDispatchError } from '../../src/utils/endpoint';
|
||||
import { homeAssistantSignPath } from '../../src/utils/ha';
|
||||
import { createHASS } from '../test-utils';
|
||||
|
||||
vi.mock('../../src/utils/ha');
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('getEndpointAddressOrDispatchError', () => {
|
||||
it('without signing', async () => {
|
||||
expect(
|
||||
await getEndpointAddressOrDispatchError(
|
||||
document.createElement('div'),
|
||||
createHASS(),
|
||||
{
|
||||
endpoint: 'http://example.com',
|
||||
sign: false,
|
||||
},
|
||||
),
|
||||
).toBe('http://example.com');
|
||||
});
|
||||
|
||||
describe('with signing', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('successful', async () => {
|
||||
vi.mocked(homeAssistantSignPath).mockResolvedValue('http://signed.com');
|
||||
|
||||
expect(
|
||||
await getEndpointAddressOrDispatchError(
|
||||
document.createElement('div'),
|
||||
createHASS(),
|
||||
{
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
},
|
||||
),
|
||||
).toBe('ws://signed.com');
|
||||
});
|
||||
|
||||
it('with null response', async () => {
|
||||
const element = document.createElement('div');
|
||||
const listener = vi.fn();
|
||||
element.addEventListener('frigate-card:message', listener);
|
||||
|
||||
vi.mocked(homeAssistantSignPath).mockResolvedValue(null);
|
||||
|
||||
expect(
|
||||
await getEndpointAddressOrDispatchError(element, createHASS(), {
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(listener).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.objectContaining({
|
||||
message: 'Could not sign Home Assistant URL',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('with exception on signing', async () => {
|
||||
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||
|
||||
const element = document.createElement('div');
|
||||
const listener = vi.fn();
|
||||
element.addEventListener('frigate-card:message', listener);
|
||||
|
||||
vi.mocked(homeAssistantSignPath).mockRejectedValue(new Error());
|
||||
|
||||
expect(
|
||||
await getEndpointAddressOrDispatchError(element, createHASS(), {
|
||||
endpoint: 'http://example.com',
|
||||
sign: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(consoleSpy).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import add from 'date-fns/add';
|
||||
import sub from 'date-fns/sub';
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, Mock, MockedFunction, vi } from 'vitest';
|
||||
import { QueryType } from '../../src/camera-manager/types';
|
||||
import {
|
||||
changeViewToRecentEventsForCameraAndDependents,
|
||||
@@ -13,6 +13,7 @@ import { ViewMedia } from '../../src/view/media';
|
||||
import { EventMediaQueries } from '../../src/view/media-queries';
|
||||
import {
|
||||
createCameraManager,
|
||||
createCapabilities,
|
||||
createPerformanceConfig,
|
||||
createStore,
|
||||
createView,
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
|
||||
const createElementListenForView = (): {
|
||||
element: HTMLElement;
|
||||
viewHandler: Mock<any, any>;
|
||||
messageHandler: Mock<any, any>;
|
||||
viewHandler: EventListener;
|
||||
messageHandler: EventListener;
|
||||
} => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
@@ -114,6 +115,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
capabilities: createCapabilities({ clips: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -138,7 +140,9 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||
},
|
||||
);
|
||||
expect(elementHandler.viewHandler).toBeCalled();
|
||||
expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray);
|
||||
expect(getMediaFromHandlerCall(vi.mocked(elementHandler.viewHandler))).toBe(
|
||||
mediaArray,
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch error message on fail', async () => {
|
||||
@@ -150,6 +154,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
capabilities: createCapabilities({ clips: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -177,6 +182,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
capabilities: createCapabilities({ clips: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -208,6 +214,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
capabilities: createCapabilities({ clips: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -246,6 +253,7 @@ describe('changeViewToRecentEventsForCameraAndDependents', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
capabilities: createCapabilities({ [mediaType]: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -440,6 +448,7 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
capabilities: createCapabilities({ recordings: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -464,7 +473,9 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => {
|
||||
},
|
||||
);
|
||||
expect(elementHandler.viewHandler).toBeCalled();
|
||||
expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray);
|
||||
expect(getMediaFromHandlerCall(vi.mocked(elementHandler.viewHandler))).toBe(
|
||||
mediaArray,
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect media chunk size', async () => {
|
||||
@@ -473,6 +484,7 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
capabilities: createCapabilities({ recordings: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -504,6 +516,7 @@ describe('changeViewToRecentRecordingForCameraAndDependents', () => {
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera',
|
||||
capabilities: createCapabilities({ recordings: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FrigateCardPTZConfig, frigateCardPTZSchema } from '../../src/config/types';
|
||||
import { hasUsablePTZ } from '../../src/utils/ptz';
|
||||
import { createCameraCapabilities } from '../test-utils';
|
||||
import { createCapabilities } from '../test-utils';
|
||||
|
||||
const createPTZConfig = (
|
||||
config?: Partial<FrigateCardPTZConfig>,
|
||||
@@ -13,7 +13,7 @@ describe('hasUsablePTZ', () => {
|
||||
it('should return true with manual actions', () => {
|
||||
expect(
|
||||
hasUsablePTZ(
|
||||
createCameraCapabilities(),
|
||||
createCapabilities(),
|
||||
createPTZConfig({
|
||||
actions_left: {},
|
||||
}),
|
||||
@@ -23,14 +23,16 @@ describe('hasUsablePTZ', () => {
|
||||
it('should return true with capabilities', () => {
|
||||
expect(
|
||||
hasUsablePTZ(
|
||||
createCameraCapabilities({
|
||||
ptz: {},
|
||||
createCapabilities({
|
||||
ptz: {
|
||||
panTilt: ['continuous'],
|
||||
},
|
||||
}),
|
||||
createPTZConfig(),
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
it('should return false with manual actions or capabilities', () => {
|
||||
expect(hasUsablePTZ(createCameraCapabilities(), createPTZConfig())).toBeFalsy();
|
||||
expect(hasUsablePTZ(createCapabilities(), createPTZConfig())).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { FrigateCardView } from '../../src/config/types';
|
||||
import { CapabilityKey } from '../../src/types';
|
||||
import { getCameraIDsForViewName } from '../../src/view/view-to-cameras';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createCameraManager,
|
||||
createCapabilities,
|
||||
createStore,
|
||||
} from '../test-utils';
|
||||
|
||||
describe('getCameraIDsForViewName', () => {
|
||||
describe('views that are always supported', () => {
|
||||
it.each([['image' as const], ['diagnostics' as const]])(
|
||||
'%s',
|
||||
(viewName: FrigateCardView) => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
|
||||
},
|
||||
{ cameraID: 'camera-2' },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual(
|
||||
new Set(['camera-1', 'camera-2']),
|
||||
);
|
||||
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual(
|
||||
new Set(['camera-1', 'camera-2']),
|
||||
);
|
||||
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual(
|
||||
new Set(['camera-1', 'camera-2']),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('views that respect dependencies and need a capability', () => {
|
||||
it.each([
|
||||
['live' as const, 'live' as const],
|
||||
['clip' as const, 'clips' as const],
|
||||
['clips' as const, 'clips' as const],
|
||||
['snapshot' as const, 'snapshots' as const],
|
||||
['snapshots' as const, 'snapshots' as const],
|
||||
['recording' as const, 'recordings' as const],
|
||||
['recordings' as const, 'recordings' as const],
|
||||
['media' as const, 'clips' as const],
|
||||
['media' as const, 'snapshots' as const],
|
||||
['media' as const, 'recordings' as const],
|
||||
])('%s', (viewName: FrigateCardView, capabilityKey: CapabilityKey) => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||
createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
|
||||
},
|
||||
{
|
||||
cameraID: 'camera-2',
|
||||
capabilities: createCapabilities({ [capabilityKey]: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual(
|
||||
new Set(['camera-2']),
|
||||
);
|
||||
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual(
|
||||
new Set(['camera-2']),
|
||||
);
|
||||
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual(
|
||||
new Set(['camera-2']),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
-13
@@ -10,6 +10,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
|
||||
'camera-manager/frigate/util.ts',
|
||||
'camera-manager/generic/*.ts',
|
||||
'camera-manager/motioneye/icon.ts',
|
||||
'camera-manager/utils/*.ts',
|
||||
'card-controller/*.ts',
|
||||
'components-lib/cached-value-controller.ts',
|
||||
'components-lib/media-filter-controller.ts',
|
||||
@@ -29,6 +30,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
|
||||
'utils/diagnostics.ts',
|
||||
'utils/download.ts',
|
||||
'utils/embla/**/*.ts',
|
||||
'utils/endpoint.ts',
|
||||
'utils/ha/entity-registry/types.ts',
|
||||
'utils/ha/types.ts',
|
||||
'utils/media-info.ts',
|
||||
@@ -72,21 +74,9 @@ export default defineConfig({
|
||||
coverage: {
|
||||
// Favor istanbul for coverage over v8 due to better accuracy.
|
||||
provider: 'istanbul',
|
||||
|
||||
thresholds: {
|
||||
// Thresholds will automatically be updated as coverage improves to avoid
|
||||
// back-sliding.
|
||||
autoUpdate: true,
|
||||
|
||||
// Expected thresholds for anything that does not have 100% coverage
|
||||
// yet.
|
||||
statements: 10.69,
|
||||
branches: 4.9,
|
||||
functions: 7.81,
|
||||
lines: 10.94,
|
||||
|
||||
...calculateFullCoverageThresholds(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user