Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unify camera and doorbell implementation #247

Merged
merged 4 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading