unifi-protect: debounce motion sensors, attempt to stabilize nativeids

This commit is contained in:
Koushik Dutta
2024-03-05 18:04:29 -08:00
parent b4293e3363
commit 2da94cdc97
7 changed files with 120 additions and 77 deletions

View File

@@ -17,7 +17,7 @@
"sourceMaps": true,
"localRoot": "${workspaceFolder}/out",
"remoteRoot": "/plugin/",
"type": "pwa-node"
"type": "node"
}
]
}

View File

@@ -6,6 +6,7 @@ import { once } from "events";
import { Readable } from "stream";
import WS from 'ws';
import { UnifiProtect } from "./main";
import { MOTION_SENSOR_TIMEOUT, UnifiMotionDevice, debounceMotionDetected } from './motion';
import { FeatureFlagsShim } from "./shim";
import { ProtectCameraChannelConfig, ProtectCameraConfigInterface, ProtectCameraLcdMessagePayload } from "./unifi-protect";
@@ -39,11 +40,10 @@ export class UnifiPackageCamera extends ScryptedDeviceBase implements Camera, Vi
}
}
export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Intercom, Camera, VideoCamera, VideoCameraConfiguration, MotionSensor, Settings, ObjectDetector, DeviceProvider, OnOff, PanTiltZoom, Online {
export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Intercom, Camera, VideoCamera, VideoCameraConfiguration, MotionSensor, Settings, ObjectDetector, DeviceProvider, OnOff, PanTiltZoom, Online, UnifiMotionDevice {
motionTimeout: NodeJS.Timeout;
detectionTimeout: NodeJS.Timeout;
ringTimeout: NodeJS.Timeout;
lastMotion: number;
lastRing: number;
lastSeen: number;
intercomProcess?: ChildProcess;
@@ -51,7 +51,6 @@ export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Interco
constructor(public protect: UnifiProtect, nativeId: string, protectCamera: Readonly<ProtectCameraConfigInterface>) {
super(nativeId);
this.lastMotion = protectCamera?.lastMotion;
this.lastRing = protectCamera?.lastRing;
this.lastSeen = protectCamera?.lastSeen;
@@ -226,14 +225,14 @@ export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Interco
}
async getSettings(): Promise<Setting[]> {
const vsos = await this.getVideoStreamOptions();
// const vsos = await this.getVideoStreamOptions();
return [
{
title: 'Sensor Timeout',
key: 'sensorTimeout',
value: this.storage.getItem('sensorTimeout') || defaultSensorTimeout,
description: 'Time to wait in seconds before clearing the motion, doorbell button, or object detection state.',
}
// {
// title: 'Sensor Timeout',
// key: 'sensorTimeout',
// value: this.storage.getItem('sensorTimeout') || defaultSensorTimeout,
// description: 'Time to wait in seconds before clearing the motion, doorbell button, or object detection state.',
// }
];
}
@@ -242,17 +241,6 @@ export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Interco
this.onDeviceEvent(ScryptedInterface.Settings, undefined);
}
getSensorTimeout() {
return (parseInt(this.storage.getItem('sensorTimeout')) || 10) * 1000;
}
resetMotionTimeout() {
clearTimeout(this.motionTimeout);
this.motionTimeout = setTimeout(() => {
this.setMotionDetected(false);
}, this.getSensorTimeout());
}
resetDetectionTimeout() {
clearTimeout(this.detectionTimeout);
this.detectionTimeout = setTimeout(() => {
@@ -261,14 +249,14 @@ export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Interco
detections: []
}
this.onDeviceEvent(ScryptedInterface.ObjectDetector, detect);
}, this.getSensorTimeout());
}, MOTION_SENSOR_TIMEOUT);
}
resetRingTimeout() {
clearTimeout(this.ringTimeout);
this.ringTimeout = setTimeout(() => {
this.binaryState = false;
}, this.getSensorTimeout());
}, MOTION_SENSOR_TIMEOUT);
}
async getSnapshot(options?: PictureOptions, suffix?: string): Promise<Buffer> {
@@ -287,7 +275,7 @@ export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Interco
catch (e) {
}
const url = `https://${this.protect.getSetting('ip')}/proxy/protect/api/cameras/${this.nativeId}/${suffix}?ts=${Date.now()}${size}`
const url = `https://${this.protect.getSetting('ip')}/proxy/protect/api/cameras/${this.findCamera().id}/${suffix}?ts=${Date.now()}${size}`
const abort = new AbortController();
const timeout = setTimeout(() => abort.abort('Unifi Protect Snapshot timed out after 10 seconds. Aborted.'), 10000);
@@ -307,7 +295,8 @@ export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Interco
return this.createMediaObject(buffer, 'image/jpeg');
}
findCamera() {
return this.protect.api.cameras.find(camera => camera.id === this.nativeId);
const id = this.protect.findId(this.nativeId);
return this.protect.api.cameras.find(camera => camera.id === id);
}
async getVideoStream(options?: MediaStreamOptions): Promise<MediaObject> {
const camera = this.findCamera();
@@ -424,7 +413,8 @@ export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Interco
return;
this.on = !!camera.ledSettings?.isEnabled;
this.online = !!camera.isConnected;
this.setMotionDetected(!!camera.isMotionDetected);
if (!!camera.isMotionDetected)
debounceMotionDetected(this);
if (!!camera.featureFlags.canOpticalZoom) {
this.ptzCapabilities = { pan: false, tilt: false, zoom: true };

View File

@@ -1,8 +1,11 @@
import { ScryptedDeviceBase, MotionSensor, TemperatureUnit, OnOff, Brightness } from "@scrypted/sdk";
import { Brightness, MotionSensor, OnOff, ScryptedDeviceBase, TemperatureUnit } from "@scrypted/sdk";
import { UnifiProtect } from "./main";
import { UnifiMotionDevice, debounceMotionDetected } from "./motion";
import { ProtectLightConfig } from "./unifi-protect";
export class UnifiLight extends ScryptedDeviceBase implements OnOff, Brightness, MotionSensor {
export class UnifiLight extends ScryptedDeviceBase implements OnOff, Brightness, MotionSensor, UnifiMotionDevice {
motionTimeout: NodeJS.Timeout;
constructor(public protect: UnifiProtect, nativeId: string, protectLight: Readonly<ProtectLightConfig>) {
super(nativeId);
this.temperatureUnit = TemperatureUnit.C;
@@ -26,7 +29,8 @@ export class UnifiLight extends ScryptedDeviceBase implements OnOff, Brightness,
}
findLight() {
return this.protect.api.lights.find(light => light.id === this.nativeId);
const id = this.protect.findId(this.nativeId);
return this.protect.api.lights.find(light => light.id === id);
}
updateState(light?: Readonly<ProtectLightConfig>) {
@@ -36,7 +40,8 @@ export class UnifiLight extends ScryptedDeviceBase implements OnOff, Brightness,
this.on = !!light.isLightOn;
// The Protect ledLevel settings goes from 1 - 6. HomeKit expects percentages, so we convert it like so.
this.brightness = (light.lightDeviceSettings.ledLevel - 1) * 20;
this.setMotionDetected(!!light.isPirMotionDetected);
if (!!light.isPirMotionDetected)
debounceMotionDetected(this);
}
setMotionDetected(motionDetected: boolean) {

View File

@@ -1,4 +1,4 @@
import { ScryptedDeviceBase, Lock, LockState } from "@scrypted/sdk";
import { Lock, LockState, ScryptedDeviceBase } from "@scrypted/sdk";
import { UnifiProtect } from "./main";
import { ProtectDoorLockConfig } from "./unifi-protect";
@@ -11,19 +11,20 @@ export class UnifiLock extends ScryptedDeviceBase implements Lock {
}
async lock(): Promise<void> {
await this.protect.loginFetch(this.protect.api.doorlocksUrl() + `/${this.nativeId}/close`, {
await this.protect.loginFetch(this.protect.api.doorlocksUrl() + `/${this.findLock().id}/close`, {
method: 'POST',
});
}
async unlock(): Promise<void> {
await this.protect.loginFetch(this.protect.api.doorlocksUrl() + `/${this.nativeId}/open`, {
await this.protect.loginFetch(this.protect.api.doorlocksUrl() + `/${this.findLock().id}/open`, {
method: 'POST',
});
}
findLock() {
return this.protect.api.doorlocks.find(doorlock => doorlock.id === this.nativeId);
const id = this.protect.findId(this.nativeId);
return this.protect.api.doorlocks.find(doorlock => doorlock.id === id);
}
updateState(lock?: Readonly<ProtectDoorLockConfig>) {

View File

@@ -1,14 +1,15 @@
import sdk, { ScryptedDeviceBase, DeviceProvider, Settings, Setting, ScryptedDeviceType, Device, ScryptedInterface, ObjectsDetected, ObjectDetectionResult } from "@scrypted/sdk";
import { ProtectApi, ProtectApiUpdates, ProtectNvrUpdatePayloadCameraUpdate, ProtectNvrUpdatePayloadEventAdd } from "./unifi-protect";
import { createInstanceableProviderPlugin, enableInstanceableProviderMode, isInstanceableProviderModeEnabled } from '@scrypted/common/src/provider-plugin';
import { defaultSensorTimeout, UnifiCamera } from "./camera";
import { FeatureFlagsShim, LastSeenShim } from "./shim";
import { UnifiSensor } from "./sensor";
import { sleep } from "@scrypted/common/src/sleep";
import sdk, { Device, DeviceProvider, ObjectDetectionResult, ObjectsDetected, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings } from "@scrypted/sdk";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import axios from "axios";
import { UnifiCamera } from "./camera";
import { UnifiLight } from "./light";
import { UnifiLock } from "./lock";
import { sleep } from "@scrypted/common/src/sleep";
import axios from "axios";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import { debounceMotionDetected } from "./motion";
import { UnifiSensor } from "./sensor";
import { FeatureFlagsShim, LastSeenShim } from "./shim";
import { ProtectApi, ProtectApiUpdates, ProtectNvrUpdatePayloadCameraUpdate, ProtectNvrUpdatePayloadEventAdd } from "./unifi-protect";
const { deviceManager } = sdk;
@@ -64,10 +65,12 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
Object.assign(device, packet.payload);
const ret = this.sensors.get(packet.action.id) ||
this.locks.get(packet.action.id) ||
this.cameras.get(packet.action.id) ||
this.lights.get(packet.action.id);
const nativeId = this.getNativeId(device, false);
const ret = this.sensors.get(nativeId) ||
this.locks.get(nativeId) ||
this.cameras.get(nativeId) ||
this.lights.get(nativeId);
const keys = new Set(Object.keys(packet.payload));
for (const k of filter) {
@@ -78,13 +81,6 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
return ret;
}
sanityCheckMotion(device: UnifiCamera | UnifiSensor | UnifiLight, payload: ProtectNvrUpdatePayloadCameraUpdate & LastSeenShim) {
if (device.motionDetected && payload.lastSeen > payload.lastMotion + defaultSensorTimeout) {
// something weird happened, lets set unset any motion state
device.setMotionDetected(false);
}
}
public async loginFetch(url: string, options?: { method?: string, signal?: AbortSignal, responseType?: axios.ResponseType }) {
const api = this.api as any;
if (!(await api.login()))
@@ -132,13 +128,12 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
return;
const payload = updatePacket.payload as any as ProtectNvrUpdatePayloadCameraUpdate & LastSeenShim;
this.sanityCheckMotion(unifiDevice as any, payload);
if (updatePacket.action.modelKey !== "camera")
return;
const unifiCamera = unifiDevice as UnifiCamera;
if (payload.lastRing && unifiCamera.binaryState && payload.lastSeen > payload.lastRing + unifiCamera.getSensorTimeout()) {
if (payload.lastRing && unifiCamera.binaryState && payload.lastSeen > payload.lastRing + 25000) {
// something weird happened, lets set unset any binary sensor state
unifiCamera.binaryState = false;
}
@@ -207,11 +202,7 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
unifiCamera.resetRingTimeout();
}
else if (payload.type === 'motion') {
unifiCamera.setMotionDetected(true);
unifiCamera.lastMotion = payload.start;
// i don't think this is necessary anymore?
// the event stream will set and unset motion.
unifiCamera.resetMotionTimeout();
debounceMotionDetected(unifiCamera);
}
}
@@ -340,7 +331,7 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
const d: Device = {
providerNativeId: this.nativeId,
name: camera.name,
nativeId: camera.id,
nativeId: this.getNativeId(camera, true),
info: {
manufacturer: 'Ubiquiti',
model: camera.type,
@@ -387,7 +378,7 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
const d: Device = {
providerNativeId: this.nativeId,
name: sensor.name,
nativeId: sensor.id,
nativeId: this.getNativeId(sensor, true),
info: {
manufacturer: 'Ubiquiti',
model: sensor.type,
@@ -414,7 +405,7 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
const d: Device = {
providerNativeId: this.nativeId,
name: light.name,
nativeId: light.id,
nativeId: this.getNativeId(light, true),
info: {
manufacturer: 'Ubiquiti',
model: light.type,
@@ -438,7 +429,7 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
const d: Device = {
providerNativeId: this.nativeId,
name: lock.name,
nativeId: lock.id,
nativeId: this.getNativeId(lock, true),
info: {
manufacturer: 'Ubiquiti',
model: lock.type,
@@ -470,7 +461,7 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
continue;
const nativeId = camera.id + '-packageCamera';
const d: Device = {
providerNativeId: camera.id,
providerNativeId: this.getNativeId(camera, true),
name: camera.name + ' Package Camera',
nativeId,
info: {
@@ -489,7 +480,7 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
};
await deviceManager.onDevicesChanged({
providerNativeId: camera.id,
providerNativeId: this.getNativeId(camera, true),
devices: [d],
});
}
@@ -513,25 +504,27 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
return this.lights.get(nativeId);
if (this.locks.has(nativeId))
return this.locks.get(nativeId);
const camera = this.api.cameras.find(camera => camera.id === nativeId);
const id = this.findId(nativeId);
const camera = this.api.cameras.find(camera => camera.id === id);
if (camera) {
const ret = new UnifiCamera(this, nativeId, camera);
this.cameras.set(nativeId, ret);
return ret;
}
const sensor = this.api.sensors.find(sensor => sensor.id === nativeId);
const sensor = this.api.sensors.find(sensor => sensor.id === id);
if (sensor) {
const ret = new UnifiSensor(this, nativeId, sensor);
this.sensors.set(nativeId, ret);
return ret;
}
const light = this.api.lights.find(light => light.id === nativeId);
const light = this.api.lights.find(light => light.id === id);
if (light) {
const ret = new UnifiLight(this, nativeId, light);
this.lights.set(nativeId, ret);
return ret;
}
const lock = this.api.doorlocks?.find(lock => lock.id === nativeId);
const lock = this.api.doorlocks?.find(lock => lock.id === id);
if (lock) {
const ret = new UnifiLock(this, nativeId, lock);
this.locks.set(nativeId, ret);
@@ -576,8 +569,57 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
group: 'Advanced',
type: 'boolean',
},
idMaps: {
hide: true,
json: true,
defaultValue: {
mac: {},
anonymousDeviceId: {},
id: {},
nativeId: {},
},
}
});
findId(nativeId: string) {
// the native id should be mapped to an id...
return this.storageSettings.values.idMaps.nativeId?.[nativeId] || nativeId;
}
getNativeId(device: any, update: boolean) {
const { id, mac, anonymousDeviceId } = device;
const idMaps = this.storageSettings.values.idMaps;
// try to find an existing nativeId given the mac and anonymous device id
const found = (mac && idMaps.mac[mac]) || (anonymousDeviceId && idMaps.anonymousDeviceId[anonymousDeviceId]);
// use the found id if one exists (device got provisioned a new id), otherwise use the id provided by the device.
const nativeId = found || id;
if (!update)
return nativeId;
// map the mac and anonymous device id to the native id.
if (mac) {
idMaps.mac ||= {};
idMaps.mac[mac] = nativeId;
}
if (anonymousDeviceId) {
idMaps.anonymousDeviceId ||= {};
idMaps.anonymousDeviceId[anonymousDeviceId] = nativeId;
}
// map the id and native id to each other.
idMaps.id ||= {};
idMaps.id[id] = nativeId;
idMaps.nativeId ||= {};
idMaps.nativeId[nativeId] = id;
this.storageSettings.values.idMaps = idMaps;
return nativeId;
}
async getSettings(): Promise<Setting[]> {
const ret = await this.storageSettings.getSettings();

View File

@@ -1,8 +1,11 @@
import { ScryptedDeviceBase, MotionSensor, BinarySensor, AudioSensor, HumiditySensor, Thermometer, TemperatureUnit } from "@scrypted/sdk";
import { ProtectSensorConfig } from "./unifi-protect";
import { AudioSensor, BinarySensor, HumiditySensor, MotionSensor, ScryptedDeviceBase, TemperatureUnit, Thermometer } from "@scrypted/sdk";
import { UnifiProtect } from "./main";
import { UnifiMotionDevice, debounceMotionDetected } from "./motion";
import { ProtectSensorConfig } from "./unifi-protect";
export class UnifiSensor extends ScryptedDeviceBase implements Thermometer, HumiditySensor, AudioSensor, BinarySensor, MotionSensor, UnifiMotionDevice {
motionTimeout: NodeJS.Timeout;
export class UnifiSensor extends ScryptedDeviceBase implements Thermometer, HumiditySensor, AudioSensor, BinarySensor, MotionSensor {
constructor(public protect: UnifiProtect, nativeId: string, protectSensor: Readonly<ProtectSensorConfig>) {
super(nativeId);
this.temperatureUnit = TemperatureUnit.C;
@@ -11,7 +14,8 @@ export class UnifiSensor extends ScryptedDeviceBase implements Thermometer, Humi
}
findSensor() {
return this.protect.api.sensors.find(sensor => sensor.id === this.nativeId);
const id = this.protect.findId(this.nativeId);
return this.protect.api.sensors.find(sensor => sensor.id === id);
}
async setTemperatureUnit(temperatureUnit: TemperatureUnit): Promise<void> {
@@ -28,7 +32,8 @@ export class UnifiSensor extends ScryptedDeviceBase implements Thermometer, Humi
this.binaryState = sensor.isOpened;
this.audioDetected = !!sensor.alarmTriggeredAt;
this.flooded = !!sensor.leakDetectedAt;
this.setMotionDetected(!!sensor.isMotionDetected);
if (!!sensor.isMotionDetected)
debounceMotionDetected(this);
}
setMotionDetected(motionDetected: boolean) {

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"module": "commonjs",
"module": "Node16",
"target": "ES2021",
"resolveJsonModule": true,
"moduleResolution": "Node16",