Skip to content

Commit

Permalink
Merge pull request #247 from Drenso/rework-doorbell
Browse files Browse the repository at this point in the history
Unify camera and doorbell implementation
  • Loading branch information
bobvandevijver authored Sep 24, 2024
2 parents ebb079a + 0eefe40 commit 324915d
Show file tree
Hide file tree
Showing 15 changed files with 1,234 additions and 323 deletions.
510 changes: 508 additions & 2 deletions app.json

Large diffs are not rendered by default.

180 changes: 3 additions & 177 deletions drivers/camera/device.ts
Original file line number Diff line number Diff line change
@@ -1,179 +1,5 @@
import * as TuyaOAuth2Util from '../../lib/TuyaOAuth2Util';
import { constIncludes, getFromMap } from '../../lib/TuyaOAuth2Util';
import { SettingsEvent, TuyaStatus } from '../../types/TuyaTypes';
import {
CAMERA_ALARM_EVENT_CAPABILITIES,
CAMERA_SETTING_LABELS,
HomeyCameraSettings,
SIMPLE_CAMERA_CAPABILITIES,
TuyaCameraSettings,
} from './TuyaCameraConstants';
import TuyaTimeOutAlarmDevice from '../../lib/TuyaTimeOutAlarmDevice';
import TuyaDeviceWithCamera from '../../lib/camera/device';

module.exports = class TuyaOAuth2DeviceCamera extends TuyaTimeOutAlarmDevice {
async onOAuth2Init(): Promise<void> {
await super.onOAuth2Init();

for (const capability of this.getCapabilities()) {
// Basic capabilities
if (constIncludes(SIMPLE_CAMERA_CAPABILITIES.read_write, capability)) {
this.registerCapabilityListener(capability, value =>
this.sendCommand({
code: capability,
value: value,
}),
);
}

// PTZ control
if (capability === 'ptz_control_vertical') {
this.registerCapabilityListener(capability, value => this.ptzCapabilityListener(value, '0', '4'));
}
if (capability === 'ptz_control_horizontal') {
this.registerCapabilityListener(capability, value => this.ptzCapabilityListener(value, '6', '2'));
}

if (capability === 'ptz_control_zoom') {
this.registerCapabilityListener(capability, value => this.zoomCapabilityListener(value));
}

// Other capabilities
if (capability === 'onoff') {
this.registerCapabilityListener(capability, value =>
this.sendCommand({
code: 'basic_private',
value: !value,
}),
);
}
}

// Reset alarms in case a timeout was interrupted
for (const tuyaCapability in CAMERA_ALARM_EVENT_CAPABILITIES) {
const capability = getFromMap(CAMERA_ALARM_EVENT_CAPABILITIES, tuyaCapability);
if (capability && this.hasCapability(capability)) {
await this.setCapabilityValue(capability, false);
}
}
}

async onTuyaStatus(status: TuyaStatus, changed: string[]): Promise<void> {
await super.onTuyaStatus(status, changed);

for (const statusKey in status) {
const value = status[statusKey];

// Basic capabilities
if (
constIncludes(SIMPLE_CAMERA_CAPABILITIES.read_write, statusKey) ||
constIncludes(SIMPLE_CAMERA_CAPABILITIES.read_only, statusKey)
) {
await this.setCapabilityValue(statusKey, value).catch(this.error);
}

if (constIncludes(SIMPLE_CAMERA_CAPABILITIES.setting, statusKey)) {
await this.setSettings({
[statusKey]: value,
}).catch(this.error);
}

// PTZ control
if (
(statusKey === 'ptz_stop' && value && changed.includes('ptz_stop')) ||
(statusKey === 'ptz_control' && value === '8' && changed.includes('ptz_control'))
) {
await this.setCapabilityValue('ptz_control_horizontal', 'idle').catch(this.error);
await this.setCapabilityValue('ptz_control_vertical', 'idle').catch(this.error);
}

if (statusKey === 'zoom_stop' && value && changed.includes('zoom_stop')) {
await this.setCapabilityValue('ptz_control_zoom', 'idle').catch(this.error);
}

// Other capabilities
if (statusKey === 'basic_private') {
await this.setCapabilityValue('onoff', !value).catch(this.error);
}

if (statusKey === 'wireless_electricity') {
await this.setCapabilityValue('measure_battery', value).catch(this.error);
}

// Event messages
if (statusKey === 'initiative_message' && changed.includes('initiative_message')) {
// Event messages are base64 encoded JSON
const encoded = status[statusKey] as string;
const decoded = Buffer.from(encoded, 'base64');
const data = JSON.parse(decoded.toString());
const notificationType = data.cmd;
const dataType = data.type;
this.log('Notification:', notificationType, dataType);

// Check if the event is for a known alarm
if (notificationType in CAMERA_ALARM_EVENT_CAPABILITIES) {
const alarmCapability = getFromMap(CAMERA_ALARM_EVENT_CAPABILITIES, notificationType);
if (!alarmCapability) {
continue;
}

if (!this.hasCapability(alarmCapability)) {
await this.addCapability(alarmCapability).catch(this.error);
}
await this.setAlarm(alarmCapability);
}
}
}
}

async setAlarm(capability: string): Promise<void> {
await super.setAlarm(
capability,
async () => {
const deviceTriggerCard = this.homey.flow.getDeviceTriggerCard(`camera_${capability}_true`);
await deviceTriggerCard.trigger(this).catch(this.error);
await this.setCapabilityValue(capability, true).catch(this.error);
},
async () => {
const deviceTriggerCard = this.homey.flow.getDeviceTriggerCard(`camera_${capability}_false`);
await deviceTriggerCard.trigger(this).catch(this.error);
await this.setCapabilityValue(capability, false).catch(this.error);
},
);
}

// Map from up/idle/down to commands so the ternary UI shows arrows
async ptzCapabilityListener(value: 'up' | 'idle' | 'down', up: string, down: string): Promise<void> {
if (value === 'idle') {
await this.sendCommand({ code: 'ptz_stop', value: true });
} else {
await this.sendCommand({ code: 'ptz_control', value: value === 'up' ? up : down });
}
}

async zoomCapabilityListener(value: 'up' | 'idle' | 'down'): Promise<void> {
if (value === 'idle') {
await this.sendCommand({ code: 'zoom_stop', value: true });
} else {
await this.sendCommand({ code: 'zoom_control', value: value === 'up' ? '1' : '0' });
}
}

async onSettings(event: SettingsEvent<HomeyCameraSettings>): Promise<string | void> {
const tuyaSettingsEvent = TuyaOAuth2Util.filterTuyaSettings<HomeyCameraSettings, TuyaCameraSettings>(event, [
'motion_switch',
'motion_tracking',
'decibel_switch',
'cry_detection_switch',
'pet_detection',
'motion_sensitivity',
'decibel_sensitivity',
'basic_nightvision',
'basic_device_volume',
'basic_anti_flicker',
'basic_osd',
'basic_flip',
'basic_indicator',
]);
return await TuyaOAuth2Util.onSettings<TuyaCameraSettings>(this, tuyaSettingsEvent, CAMERA_SETTING_LABELS);
}
module.exports = class TuyaOAuth2DeviceCamera extends TuyaDeviceWithCamera {
DOORBELL_TRIGGER_FLOW = 'camera_doorbell_rang';
};
7 changes: 7 additions & 0 deletions drivers/camera/driver.flow.compose.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@
"en": "Sound is no longer detected"
},
"$filter": "capabilities=alarm_sound"
},
{
"id": "camera_doorbell_rang",
"title": {
"en": "Doorbell rang"
},
"$filter": "capabilities=hidden.doorbell"
}
]
}
95 changes: 2 additions & 93 deletions drivers/camera/driver.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,3 @@
import { DEVICE_CATEGORIES } from '../../lib/TuyaOAuth2Constants';
import TuyaOAuth2Driver, { ListDeviceProperties } from '../../lib/TuyaOAuth2Driver';
import { constIncludes, getFromMap } from '../../lib/TuyaOAuth2Util';
import {
type TuyaDeviceDataPointResponse,
TuyaDeviceResponse,
TuyaDeviceSpecificationResponse,
} from '../../types/TuyaApiTypes';
import type { StandardFlowArgs } from '../../types/TuyaTypes';
import {
CAMERA_ALARM_CAPABILITIES,
CAMERA_SETTING_LABELS,
COMPLEX_CAMERA_CAPABILITIES,
SIMPLE_CAMERA_CAPABILITIES,
SIMPLE_CAMERA_FLOWS,
} from './TuyaCameraConstants';
import TuyaDriverWithCamera from '../../lib/camera/driver';

module.exports = class TuyaOAuth2DriverCamera extends TuyaOAuth2Driver {
TUYA_DEVICE_CATEGORIES = [DEVICE_CATEGORIES.SECURITY_VIDEO_SURV.SMART_CAMERA] as const;

async onInit(): Promise<void> {
await super.onInit();

for (const capability of SIMPLE_CAMERA_FLOWS.read_write) {
this.homey.flow.getActionCard(`camera_${capability}`).registerRunListener(async (args: StandardFlowArgs) => {
await args.device.triggerCapabilityListener(capability, args.value);
});
}

// Apply the same way as in onSettings, but for an individual value
for (const setting of SIMPLE_CAMERA_FLOWS.setting) {
this.addSettingFlowHandler(setting, CAMERA_SETTING_LABELS);
}
}

onTuyaPairListDeviceProperties(
device: TuyaDeviceResponse,
specifications?: TuyaDeviceSpecificationResponse,
dataPoints?: TuyaDeviceDataPointResponse,
): ListDeviceProperties {
const props = super.onTuyaPairListDeviceProperties(device, specifications, dataPoints);

for (const status of device.status) {
const capability = status.code;

// Basic capabilities
if (
constIncludes(SIMPLE_CAMERA_CAPABILITIES.read_write, capability) ||
constIncludes(SIMPLE_CAMERA_CAPABILITIES.read_only, capability)
) {
props.store.tuya_capabilities.push(capability);
props.capabilities.push(capability);
}

// More complicated capabilities
if (constIncludes(COMPLEX_CAMERA_CAPABILITIES, capability)) {
props.store.tuya_capabilities.push(capability);
}
}

// Add battery capacity if supported
if (props.store.tuya_capabilities.includes('wireless_electricity')) {
props.capabilities.push('measure_battery');
}

// Add privacy mode control if supported
if (props.store.tuya_capabilities.includes('basic_private')) {
props.capabilities.push('onoff');
}

// Add camera movement control capabilities if supported
if (props.store.tuya_capabilities.includes('ptz_control') && props.store.tuya_capabilities.includes('ptz_stop')) {
props.capabilities.push('ptz_control_horizontal', 'ptz_control_vertical');
}

if (props.store.tuya_capabilities.includes('zoom_control') && props.store.tuya_capabilities.includes('zoom_stop')) {
props.capabilities.push('ptz_control_zoom');
}

// Add alarm event capabilities if supported, based on the toggles that are available
// e.g. motion_switch means alarm_motion gets added
if (props.store.tuya_capabilities.includes('initiative_message')) {
// Add the alarm capabilities based on the toggles that are available
for (const capability of props.store.tuya_capabilities) {
const alarmCapability = getFromMap(CAMERA_ALARM_CAPABILITIES, capability);
if (alarmCapability) {
props.capabilities.push(alarmCapability);
}
}
}

return props;
}
};
module.exports = class TuyaOAuth2DriverCamera extends TuyaDriverWithCamera {};
28 changes: 3 additions & 25 deletions drivers/doorbell/device.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,5 @@
import TuyaOAuth2Device from '../../lib/TuyaOAuth2Device';
import { TuyaStatus } from '../../types/TuyaTypes';
import TuyaDeviceWithCamera from '../../lib/camera/device';

module.exports = class TuyaOAuth2DeviceDoorbell extends TuyaOAuth2Device {
async onTuyaStatus(status: TuyaStatus, changedStatusCodes: string[]): Promise<void> {
await super.onTuyaStatus(status, changedStatusCodes);

if (changedStatusCodes.includes('alarm_message')) {
try {
const decoded = JSON.parse(Buffer.from(status['alarm_message'] as string, 'base64').toString('utf8'));
this.log(`Decoded Message: ${JSON.stringify(decoded)}`);

if (decoded.cmd === 'ipc_doorbell') {
this.log('Doorbell Rang!', decoded);

this.homey.flow
.getDeviceTriggerCard('doorbell_rang')
.trigger(this)
.catch((err: Error) => this.error(`Error Triggering Doorbell Rang: ${err.message}`));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
this.error(`Error Parsing Message: ${err.message}`);
}
}
}
module.exports = class TuyaOAuth2DeviceDoorbell extends TuyaDeviceWithCamera {
DOORBELL_TRIGGER_FLOW = 'doorbell_rang';
};
8 changes: 8 additions & 0 deletions drivers/doorbell/driver.compose.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,13 @@
"name": {
"en": "Doorbell",
"nl": "Deurbel"
},
"capabilities": ["alarm_motion", "alarm_sound"],
"capabilitiesOptions": {
"alarm_motion": {
"title": {
"en": "Motion Detected"
}
}
}
}
Loading

0 comments on commit 324915d

Please sign in to comment.