From 2da94cdc97149c2bcb3769e3456404a46673abec Mon Sep 17 00:00:00 2001 From: Koushik Dutta Date: Tue, 5 Mar 2024 18:04:29 -0800 Subject: [PATCH] unifi-protect: debounce motion sensors, attempt to stabilize nativeids --- plugins/unifi-protect/.vscode/launch.json | 2 +- plugins/unifi-protect/src/camera.ts | 42 +++----- plugins/unifi-protect/src/light.ts | 13 ++- plugins/unifi-protect/src/lock.ts | 9 +- plugins/unifi-protect/src/main.ts | 114 +++++++++++++++------- plugins/unifi-protect/src/sensor.ts | 15 ++- plugins/unifi-protect/tsconfig.json | 2 +- 7 files changed, 120 insertions(+), 77 deletions(-) diff --git a/plugins/unifi-protect/.vscode/launch.json b/plugins/unifi-protect/.vscode/launch.json index 03660e3c2..0c868d0cf 100644 --- a/plugins/unifi-protect/.vscode/launch.json +++ b/plugins/unifi-protect/.vscode/launch.json @@ -17,7 +17,7 @@ "sourceMaps": true, "localRoot": "${workspaceFolder}/out", "remoteRoot": "/plugin/", - "type": "pwa-node" + "type": "node" } ] } \ No newline at end of file diff --git a/plugins/unifi-protect/src/camera.ts b/plugins/unifi-protect/src/camera.ts index 9b76210b6..c77906f3e 100644 --- a/plugins/unifi-protect/src/camera.ts +++ b/plugins/unifi-protect/src/camera.ts @@ -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) { 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 { - 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 { @@ -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 { 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 }; diff --git a/plugins/unifi-protect/src/light.ts b/plugins/unifi-protect/src/light.ts index bcf099d1b..ff605db94 100644 --- a/plugins/unifi-protect/src/light.ts +++ b/plugins/unifi-protect/src/light.ts @@ -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) { 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) { @@ -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) { diff --git a/plugins/unifi-protect/src/lock.ts b/plugins/unifi-protect/src/lock.ts index c087f446d..5f180917a 100644 --- a/plugins/unifi-protect/src/lock.ts +++ b/plugins/unifi-protect/src/lock.ts @@ -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 { - 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 { - 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) { diff --git a/plugins/unifi-protect/src/main.ts b/plugins/unifi-protect/src/main.ts index df39509da..ec1265fdf 100644 --- a/plugins/unifi-protect/src/main.ts +++ b/plugins/unifi-protect/src/main.ts @@ -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 { const ret = await this.storageSettings.getSettings(); diff --git a/plugins/unifi-protect/src/sensor.ts b/plugins/unifi-protect/src/sensor.ts index b584d710d..35f95cc19 100644 --- a/plugins/unifi-protect/src/sensor.ts +++ b/plugins/unifi-protect/src/sensor.ts @@ -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) { 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 { @@ -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) { diff --git a/plugins/unifi-protect/tsconfig.json b/plugins/unifi-protect/tsconfig.json index 34a847ad8..ba9b4d395 100644 --- a/plugins/unifi-protect/tsconfig.json +++ b/plugins/unifi-protect/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "commonjs", + "module": "Node16", "target": "ES2021", "resolveJsonModule": true, "moduleResolution": "Node16",