Files
scrypted/plugins/ring/src/location.ts
Alex Leeds 8ddf91d13b ring: fix location mode subscriptions (#646)
* ring: fix location mode subscriptions

* Update package-lock.json

* ring: probe devices after discovery
2023-03-20 11:28:50 -07:00

398 lines
15 KiB
TypeScript

import sdk, { Battery, Brightness, Device, DeviceProvider, EntrySensor, FloodSensor, Lock, LockState, MotionSensor, OnOff, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, SecuritySystem, SecuritySystemMode, TamperSensor } from '@scrypted/sdk';
import { RingCameraDevice } from './camera';
import RingPlugin from './main';
import { Location, LocationMode, RingCamera, RingDevice, RingDeviceCategory, RingDeviceData, RingDeviceType } from './ring-client-api';
const { deviceManager } = sdk;
class RingLock extends ScryptedDeviceBase implements Battery, Lock {
device: RingDevice
constructor(nativeId: string, device: RingDevice) {
super(nativeId);
this.device = device;
this.updateState(device.data);
device.onData.subscribe(async (data: RingDeviceData) => {
this.updateState(data);
});
}
async lock(): Promise<void> {
return this.device.sendCommand('lock.lock');
}
async unlock(): Promise<void> {
return this.device.sendCommand('lock.unlock');
}
updateState(data: RingDeviceData) {
this.batteryLevel = data.batteryLevel;
switch (data.locked) {
case 'locked':
this.lockState = LockState.Locked;
break;
case 'unlocked':
this.lockState = LockState.Unlocked;
break;
case 'jammed':
this.lockState = LockState.Jammed;
break;
default:
this.lockState = undefined;
}
}
}
class RingLight extends ScryptedDeviceBase implements Battery, TamperSensor, MotionSensor, OnOff, Brightness {
device: RingDevice;
data: RingDeviceData;
constructor(nativeId: string, device: RingDevice) {
super(nativeId);
this.device = device;
this.data = device.data;
this.updateState(device.data);
device.onData.subscribe(async (data: RingDeviceData) => {
this.data = data;
this.updateState(data);
});
}
private isBeamDevice() {
return [RingDeviceType.BeamsMultiLevelSwitch, RingDeviceType.BeamsSwitch, RingDeviceType.BeamsTransformerSwitch].includes(this.device.deviceType);
}
updateState(data: RingDeviceData) {
this.batteryLevel = data.batteryLevel;
this.tampered = data.tamperStatus === 'tamper';
this.motionDetected = data.motionStatus === 'faulted';
this.on = data.on;
this.brightness = data.level && !isNaN(data.level) ? 100 * data.level : 0;
}
turnOff(): Promise<void> {
if (this.isBeamDevice()) {
this.device.sendCommand('light-mode.set', { lightMode: 'default' });
return;
} else {
return this.device.setInfo({ device: { v1: { on: false } } });
}
}
turnOn(): Promise<void> {
if (this.isBeamDevice()) {
this.device.sendCommand('light-mode.set', { lightMode: 'on' });
return;
} else {
return this.device.setInfo({ device: { v1: { on: true } } });
}
}
setBrightness(brightness: number): Promise<void> {
return this.device.setInfo({
device: { v1: { level: brightness / 100 } },
});
}
}
class RingSwitch extends ScryptedDeviceBase implements OnOff {
device: RingDevice;
data: RingDeviceData;
constructor(nativeId: string, device: RingDevice) {
super(nativeId);
this.device = device;
this.updateState(device.data);
device.onData.subscribe(async (data: RingDeviceData) => {
this.updateState(data);
});
}
updateState(data: RingDeviceData) {
this.on = data.on;
}
turnOff(): Promise<void> {
return this.device.setInfo({ device: { v1: { on: false } } });
}
turnOn(): Promise<void> {
return this.device.setInfo({ device: { v1: { on: true } } });
}
}
class RingSensor extends ScryptedDeviceBase implements TamperSensor, Battery, EntrySensor, MotionSensor, FloodSensor {
device: RingDevice;
constructor(nativeId: string, device: RingDevice) {
super(nativeId);
this.device = device;
this.updateState(device.data);
device.onData.subscribe(async (data: RingDeviceData) => {
this.updateState(data);
});
}
updateState(data: RingDeviceData) {
this.batteryLevel = data.batteryLevel;
this.tampered = data.tamperStatus === 'tamper';
this.entryOpen = data.faulted;
this.motionDetected = this.device.deviceType === RingDeviceType.BeamsMotionSensor ? data.motionStatus === 'faulted' : data.faulted;
this.flooded = data.flood?.faulted || data.faulted;
}
isBypassable() {
return (this.device.deviceType === RingDeviceType.ContactSensor || this.device.deviceType === RingDeviceType.RetrofitZone) && this.device.data.faulted;
}
}
export class RingLocationDevice extends ScryptedDeviceBase implements DeviceProvider, SecuritySystem {
plugin: RingPlugin;
location: Location;
devices = new Map<string, any>();
locationDevices = new Map<string, RingDevice | RingCamera>();
constructor(plugin: RingPlugin, nativeId: string, location: Location) {
super(nativeId);
this.plugin = plugin;
this.location = location;
this.location.onLocationMode.subscribe(mode => this.updateLocationMode(mode));
// if the location has a base station, updates when arming/disarming are not sent to the `onLocationMode` subscription
// instead we subscribe to the security panel, which is updated during arming actions
this.location.getSecurityPanel().then(panel => {
panel.onData.subscribe(_ => {
this.location.getLocationMode();
});
}).catch(error => {
// could not find a security panel for location
// not logging this error as it is a valid case to not have a security panel
});
if (this.location.hasAlarmBaseStation) {
this.location.getLocationMode();
}
this.discoverDevices();
}
async discoverDevices() {
this.locationDevices.clear();
const devices: Device[] = [];
const cameras = this.location.cameras;
for (const camera of cameras) {
const nativeId = camera.id.toString();
const interfaces = [
ScryptedInterface.Camera,
ScryptedInterface.MotionSensor,
ScryptedInterface.RTCSignalingChannel,
];
if (!camera.isRingEdgeEnabled) {
interfaces.push(
ScryptedInterface.VideoCamera,
ScryptedInterface.Intercom,
ScryptedInterface.VideoClips,
);
}
if (camera.operatingOnBattery)
interfaces.push(ScryptedInterface.Battery);
if (camera.isDoorbot)
interfaces.push(ScryptedInterface.BinarySensor);
if (camera.hasLight)
interfaces.push(ScryptedInterface.DeviceProvider);
if (camera.hasSiren)
interfaces.push(ScryptedInterface.DeviceProvider);
const device: Device = {
info: {
model: `${camera.model} (${camera.data.kind})`,
manufacturer: 'Ring',
firmware: camera.data.firmware_version,
serialNumber: camera.data.device_id
},
providerNativeId: this.location.id,
nativeId,
name: camera.name,
type: camera.isDoorbot ? ScryptedDeviceType.Doorbell : ScryptedDeviceType.Camera,
interfaces,
};
devices.push(device);
this.locationDevices.set(nativeId, camera);
}
const locationDevices = await this.location.getDevices();
for (const locationDevice of locationDevices) {
const data: RingDeviceData = locationDevice.data;
let nativeId: string;
let type: ScryptedDeviceType;
let interfaces: ScryptedInterface[] = [];
if (data.status === 'disabled') {
continue;
}
switch (data.deviceType) {
case RingDeviceType.ContactSensor:
case RingDeviceType.RetrofitZone:
case RingDeviceType.TiltSensor:
case RingDeviceType.GlassbreakSensor:
nativeId = locationDevice.id.toString() + '-sensor';
type = ScryptedDeviceType.Sensor;
interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.EntrySensor);
break;
case RingDeviceType.MotionSensor:
case RingDeviceType.BeamsMotionSensor:
nativeId = locationDevice.id.toString() + '-sensor';
type = ScryptedDeviceType.Sensor;
interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.MotionSensor);
break;
case RingDeviceType.FloodFreezeSensor:
case RingDeviceType.WaterSensor:
nativeId = locationDevice.id.toString() + '-sensor';
type = ScryptedDeviceType.Sensor;
interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.FloodSensor);
break;
case RingDeviceType.BeamsMultiLevelSwitch:
case RingDeviceType.BeamsSwitch:
case RingDeviceType.BeamsTransformerSwitch:
case RingDeviceType.MultiLevelBulb:
nativeId = locationDevice.id.toString() + '-light';
type = ScryptedDeviceType.Light;
interfaces.push(ScryptedInterface.OnOff);
if (data.level !== undefined)
interfaces.push(ScryptedInterface.Brightness)
if (data.motionStatus !== undefined && !!data.motionSensorEnabled)
interfaces.push(ScryptedInterface.TamperSensor, ScryptedInterface.MotionSensor);
break;
case RingDeviceType.MultiLevelSwitch:
if (data.categoryId === RingDeviceCategory.Lights) {
nativeId = locationDevice.id.toString() + '-light';
type = ScryptedDeviceType.Light;
interfaces.push(ScryptedInterface.OnOff);
if (data.level !== undefined)
interfaces.push(ScryptedInterface.Brightness)
break;
}
case RingDeviceType.Switch:
nativeId = locationDevice.id.toString() + '-switch';
type = data.categoryId === RingDeviceCategory.Outlets ? ScryptedDeviceType.Outlet : ScryptedDeviceType.Switch;
interfaces.push(ScryptedInterface.OnOff);
break;
default:
if (/^lock($|\.)/.test(data.deviceType)) {
nativeId = locationDevice.id.toString() + '-lock';
type = ScryptedDeviceType.Lock;
interfaces.push(ScryptedInterface.Lock);
break;
} else {
this.console.debug(`discovered and ignoring unsupported '${locationDevice.deviceType}' device: '${locationDevice.name}'`)
continue;
}
}
if (data.batteryStatus !== 'none')
interfaces.push(ScryptedInterface.Battery);
const device: Device = {
info: {
model: data.deviceType,
manufacturer: 'Ring',
serialNumber: data.serialNumber ?? 'Unknown'
},
providerNativeId: this.location.id,
nativeId: nativeId,
name: locationDevice.name,
type: type,
interfaces,
};
devices.push(device);
this.locationDevices.set(nativeId, locationDevice);
}
await deviceManager.onDevicesChanged({
providerNativeId: this.location.id,
devices: devices,
});
// probe to intiailize location devices
for (const device of devices) {
await this.getDevice(device.nativeId);
};
}
async getDevice(nativeId: string) {
if (!this.devices.has(nativeId)) {
if (nativeId.endsWith('-sensor')) {
const device = new RingSensor(nativeId, this.locationDevices.get(nativeId) as RingDevice);
this.devices.set(nativeId, device);
} else if (nativeId.endsWith('-lock')) {
const device = new RingLock(nativeId, this.locationDevices.get(nativeId) as RingDevice);
this.devices.set(nativeId, device);
} else if (nativeId.endsWith('-light')) {
const device = new RingLight(nativeId, this.locationDevices.get(nativeId) as RingDevice);
this.devices.set(nativeId, device);
} else if (nativeId.endsWith('-switch')) {
const device = new RingSwitch(nativeId, this.locationDevices.get(nativeId) as RingDevice);
this.devices.set(nativeId, device);
} else {
const device = new RingCameraDevice(this.plugin.api, nativeId, this.locationDevices.get(nativeId) as RingCamera);
this.devices.set(nativeId, device);
}
}
return this.devices.get(nativeId);
}
async releaseDevice(id: string, nativeId: string): Promise<void> {}
updateLocationMode(locationMode: LocationMode) {
let mode: SecuritySystemMode;
if (locationMode === 'away')
mode = SecuritySystemMode.AwayArmed;
else if (locationMode === 'home')
mode = SecuritySystemMode.HomeArmed;
else
mode = SecuritySystemMode.Disarmed;
let supportedModes = [
SecuritySystemMode.Disarmed,
SecuritySystemMode.AwayArmed,
SecuritySystemMode.HomeArmed
]
if (this.plugin.settingsStorage.values.nightModeBypassAlarmState !== 'Disabled') {
supportedModes.push(SecuritySystemMode.NightArmed)
}
this.securitySystemState = {
mode,
// how to get this?
triggered: false,
supportedModes
}
}
async armSecuritySystem(mode: SecuritySystemMode): Promise<void> {
if (mode === SecuritySystemMode.AwayArmed) {
await this.location.armAway();
} else if (mode === SecuritySystemMode.HomeArmed) {
await this.location.armHome();
} else if (mode === SecuritySystemMode.NightArmed) {
const bypassContactSensors = Object.values(this.locationDevices).filter(device => device.isBypassable()).map(sensor => sensor.id);
if (this.plugin.settingsStorage.values.nightModeBypassAlarmState === 'Away') {
await this.location.armAway(bypassContactSensors);
} else {
await this.location.armHome(bypassContactSensors);
}
} else if (mode === SecuritySystemMode.Disarmed) {
await this.location.disarm();
}
}
async disarmSecuritySystem(): Promise<void> {
await this.location.disarm();
}
}