mirror of
https://github.com/koush/scrypted.git
synced 2026-03-03 17:52:02 +00:00
* ring: fix location mode subscriptions * Update package-lock.json * ring: probe devices after discovery
398 lines
15 KiB
TypeScript
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();
|
|
}
|
|
}
|